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:
2026-05-03 11:52:01 +02:00
parent 8f26776bb3
commit 7b93499551
9 changed files with 1249 additions and 415 deletions

View File

@@ -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()