Files
BanGUI/backend/app/config.py
Lukas d9022b9d06 Refactor config and add comprehensive tests
- Updated config.py to support environment-based configuration
- Added test_config.py with full test coverage
- Updated Backend-Development.md with configuration documentation
- Removed outdated tasks from Tasks.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:14:35 +02:00

208 lines
7.3 KiB
Python

"""Application configuration loaded from environment variables and .env file.
Follows pydantic-settings patterns: all values are prefixed with BANGUI_
and validated at startup via the Settings singleton.
"""
import shlex
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.utils.constants import (
DEFAULT_DATABASE_PATH,
DEFAULT_FAIL2BAN_SOCKET,
DEFAULT_SESSION_DURATION_MINUTES,
)
class Settings(BaseSettings):
"""BanGUI runtime configuration.
All fields are loaded from environment variables prefixed with ``BANGUI_``
or from a ``.env`` file located next to the process working directory.
The application will raise a :class:`pydantic.ValidationError` on startup
if any required field is missing or has an invalid value.
"""
database_path: str = Field(
default=DEFAULT_DATABASE_PATH,
description="Filesystem path to the BanGUI SQLite application database.",
)
fail2ban_socket: str = Field(
default=DEFAULT_FAIL2BAN_SOCKET,
description="Path to the fail2ban Unix domain socket.",
)
session_secret: str = Field(
...,
min_length=32,
description=(
"Secret key used when generating session tokens. "
"Must be at least 32 characters. "
"Must be unique and never committed to source control. "
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
),
)
session_duration_minutes: int = Field(
default=DEFAULT_SESSION_DURATION_MINUTES,
ge=1,
description="Number of minutes a session token remains valid after creation.",
)
session_cache_enabled: bool = Field(
default=False,
description=(
"Enable the in-memory session validation cache. "
"Disable it in multi-worker deployments to avoid stale revoked sessions."
),
)
session_cache_ttl_seconds: float = Field(
default=10.0,
ge=0.0,
description=(
"How long (seconds) a cached session validation entry remains fresh. "
"Ignored when session_cache_enabled is false."
),
)
http_request_timeout_seconds: float = Field(
default=20.0,
ge=0.0,
description="Maximum total time in seconds for outbound external HTTP requests.",
)
http_connect_timeout_seconds: float = Field(
default=5.0,
ge=0.0,
description="Maximum time in seconds to establish outbound external HTTP connections.",
)
http_max_connections: int = Field(
default=10,
ge=1,
description="Maximum number of concurrent outbound HTTP connections.",
)
http_keepalive_timeout_seconds: float = Field(
default=15.0,
ge=0.0,
description="How long idle keepalive connections are retained by the HTTP connector.",
)
timezone: str = Field(
default="UTC",
description="IANA timezone name used when displaying timestamps in the UI.",
)
session_cookie_httponly: bool = Field(
default=True,
description=(
"Mark the session cookie as HttpOnly so browser scripts cannot access it."
),
)
session_cookie_samesite: Literal["lax", "strict", "none"] = Field(
default="lax",
description=(
"SameSite policy for the session cookie. "
"Use 'lax', 'strict', or 'none' depending on deployment requirements."
),
)
session_cookie_secure: bool = Field(
default=True,
description=(
"Set the session cookie Secure flag when the backend is served over HTTPS. "
"Defaults to True for security. Set to False only for local development over HTTP."
),
)
cors_allowed_origins: str | list[str] = Field(
default_factory=list,
description=(
"Comma-separated list of allowed CORS origins when the frontend is "
"served from a different origin than the backend. "
"Leave empty to disable cross-origin requests in production."
),
)
@field_validator("cors_allowed_origins", mode="before")
@classmethod
def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
log_level: str = Field(
default="info",
description="Application log level: debug | info | warning | error | critical.",
)
geoip_db_path: str | None = Field(
default=None,
description=(
"Optional path to a MaxMind GeoLite2-Country .mmdb file. "
"When set, failed ip-api.com lookups fall back to local resolution."
),
)
fail2ban_config_dir: str = Field(
default="/config/fail2ban",
description=(
"Path to the fail2ban configuration directory. "
"Must contain subdirectories jail.d/, filter.d/, and action.d/. "
"Used for listing, viewing, and editing configuration files through the web UI."
),
)
allowed_log_dirs: list[str] = Field(
default_factory=lambda: ["/var/log", "/config/log"],
description=(
"List of allowed directory prefixes for jail log paths. "
"Any log path added must resolve to a path within one of these directories. "
"Use absolute paths. Symlinks are resolved before validation."
),
)
fail2ban_start_command: str = Field(
default="fail2ban-client start",
description=(
"Shell command used to start (not reload) the fail2ban daemon during "
"recovery rollback. Split by whitespace to build the argument list — "
"no shell interpretation is performed. "
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
),
)
@field_validator("fail2ban_start_command", mode="after")
@classmethod
def _validate_fail2ban_start_command(cls, value: str) -> str:
"""Validate fail2ban_start_command by attempting to parse it with shlex.
Ensures the command can be split into arguments without shell interpretation.
Raises ValueError if the command contains mismatched quotes.
Args:
value: The fail2ban start command string.
Returns:
The validated command string.
Raises:
ValueError: If the command contains mismatched quotes.
"""
try:
shlex.split(value)
except ValueError as e:
raise ValueError(
f"fail2ban_start_command contains mismatched quotes or is otherwise "
f"unparseable: {value!r}{e}"
) from e
return value
model_config = SettingsConfigDict(
env_prefix="BANGUI_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
def get_settings() -> Settings:
"""Return a fresh :class:`Settings` instance loaded from the environment.
Returns:
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
if required keys are absent or values fail validation.
"""
return Settings() # pydantic-settings populates required fields from env vars