Refactor config loading and add status code docs
- Move config loading to dedicated ConfigLoader class with validation - Add DATABASE_MIGRATIONS.md content to TROUBLESHOOTING.md - Add API_STATUS_CODES.md documenting all API response codes - Update runner.csx to use new config structure - Add check_responses.py validation script - Update config tests for new structure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -5,7 +5,9 @@ and validated at startup via the Settings singleton.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
@@ -134,6 +136,142 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("database_path", mode="after")
|
||||
@classmethod
|
||||
def _validate_database_path(cls, value: str) -> str:
|
||||
"""Validate database_path parent directory exists and is writable.
|
||||
|
||||
Args:
|
||||
value: The database path string.
|
||||
|
||||
Returns:
|
||||
The validated path string.
|
||||
|
||||
Raises:
|
||||
ValueError: If parent directory does not exist or is not writable.
|
||||
"""
|
||||
path = Path(value)
|
||||
parent = path.parent
|
||||
|
||||
if not parent.exists():
|
||||
raise ValueError(
|
||||
f"Database parent directory does not exist: {parent}\n"
|
||||
f"Hint: Create it with: mkdir -p {parent}"
|
||||
)
|
||||
|
||||
if not os.access(parent, os.W_OK):
|
||||
raise ValueError(
|
||||
f"Database parent directory not writable: {parent}\n"
|
||||
f"Hint: Fix with: chmod 755 {parent}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("fail2ban_socket", mode="after")
|
||||
@classmethod
|
||||
def _validate_fail2ban_socket(cls, value: str) -> str:
|
||||
"""Validate fail2ban socket exists and is readable.
|
||||
|
||||
Args:
|
||||
value: The fail2ban socket path string.
|
||||
|
||||
Returns:
|
||||
The validated path string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the socket path exists but is not readable.
|
||||
"""
|
||||
path = Path(value)
|
||||
|
||||
if path.exists() and not os.access(path, os.R_OK):
|
||||
raise ValueError(
|
||||
f"fail2ban socket not readable: {path}\n"
|
||||
f"Hint: Fix with: chmod 644 {path}"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("geoip_db_path", mode="after")
|
||||
@classmethod
|
||||
def _validate_geoip_db_path(cls, value: str | None) -> str | None:
|
||||
"""Validate geoip_db_path exists if set.
|
||||
|
||||
Args:
|
||||
value: The GeoIP database path or None.
|
||||
|
||||
Returns:
|
||||
The validated path or None.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is set but the file does not exist.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
path = Path(value)
|
||||
|
||||
if not path.exists():
|
||||
raise ValueError(
|
||||
f"GeoIP database file does not exist: {path}\n"
|
||||
f"Hint: Download from https://dev.maxmind.com/geoip/geolite2-country"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("fail2ban_config_dir", mode="after")
|
||||
@classmethod
|
||||
def _validate_fail2ban_config_dir(cls, value: str) -> str:
|
||||
"""Validate fail2ban_config_dir exists.
|
||||
|
||||
Args:
|
||||
value: The fail2ban configuration directory path.
|
||||
|
||||
Returns:
|
||||
The validated path string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the directory does not exist.
|
||||
"""
|
||||
path = Path(value)
|
||||
|
||||
if not path.exists():
|
||||
raise ValueError(
|
||||
f"fail2ban config directory does not exist: {path}\n"
|
||||
f"Hint: Mount the fail2ban config directory or adjust BANGUI_FAIL2BAN_CONFIG_DIR"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("session_secret", mode="after")
|
||||
@classmethod
|
||||
def _validate_session_secret(cls, value: str) -> str:
|
||||
"""Validate session_secret is sufficiently long and non-trivial.
|
||||
|
||||
Args:
|
||||
value: The session secret string.
|
||||
|
||||
Returns:
|
||||
The validated secret string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the secret is too short or appears weak.
|
||||
"""
|
||||
if len(value) < 32:
|
||||
raise ValueError(
|
||||
f"session_secret must be at least 32 characters. Got {len(value)}.\n"
|
||||
f"Hint: Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
)
|
||||
|
||||
weak_indicators = {"password", "secret", "123", "abc", "admin"}
|
||||
value_lower = value.lower()
|
||||
if any(value_lower.startswith(w) for w in weak_indicators):
|
||||
raise ValueError(
|
||||
"session_secret is too weak (found common word).\n"
|
||||
"Hint: Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("cors_allowed_origins", mode="before")
|
||||
@classmethod
|
||||
def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]:
|
||||
@@ -429,4 +567,4 @@ def get_settings() -> Settings:
|
||||
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
|
||||
if required keys are absent or values fail validation.
|
||||
"""
|
||||
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
|
||||
return Settings()
|
||||
|
||||
Reference in New Issue
Block a user