diff --git a/Docs/Tasks.md b/Docs/Tasks.md index cc71973..fa5033c 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -76,5 +76,5 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. 2. ✅ Refactor dependency injection / explicit shared resources. 3. ✅ Harden fail2ban client concurrency and packaging. 4. ✅ Convert setup guard to a safer startup-driven model. -5. Add deployment-safe configuration and production-ready CORS. +5. ✅ Add deployment-safe configuration and production-ready CORS. 6. Add lifecycle and concurrency regression tests. diff --git a/backend/.env.example b/backend/.env.example index e1932ed..a2681df 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,6 +8,12 @@ BANGUI_DATABASE_PATH=bangui.db # Path to the fail2ban Unix domain socket. BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock +# Path to the fail2ban configuration directory used by the web UI. +BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban + +# Shell command used to start fail2ban during recovery operations. +BANGUI_FAIL2BAN_START_COMMAND=fail2ban-client start + # Secret key used to sign session tokens. Use a long, random string. # Generate with: python -c "import secrets; print(secrets.token_hex(64))" BANGUI_SESSION_SECRET=replace-this-with-a-long-random-secret @@ -23,4 +29,5 @@ BANGUI_LOG_LEVEL=info # Comma-separated list of allowed CORS origins when the frontend is served # from a different origin than the backend. +# Leave this blank in production when the UI is served from the same origin. BANGUI_CORS_ALLOWED_ORIGINS=http://localhost:5173 diff --git a/backend/app/config.py b/backend/app/config.py index 6ad8575..953d8b6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,9 +4,15 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_ and validated at startup via the Settings singleton. """ -from pydantic import Field +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. @@ -18,11 +24,11 @@ class Settings(BaseSettings): """ database_path: str = Field( - default="bangui.db", + default=DEFAULT_DATABASE_PATH, description="Filesystem path to the BanGUI SQLite application database.", ) fail2ban_socket: str = Field( - default="/var/run/fail2ban/fail2ban.sock", + default=DEFAULT_FAIL2BAN_SOCKET, description="Path to the fail2ban Unix domain socket.", ) session_secret: str = Field( @@ -33,7 +39,7 @@ class Settings(BaseSettings): ), ) session_duration_minutes: int = Field( - default=60, + default=DEFAULT_SESSION_DURATION_MINUTES, ge=1, description="Number of minutes a session token remains valid after creation.", ) @@ -41,14 +47,24 @@ class Settings(BaseSettings): default="UTC", description="IANA timezone name used when displaying timestamps in the UI.", ) - cors_allowed_origins: list[str] = Field( - default_factory=lambda: ["http://localhost:5173"], + 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.", @@ -72,8 +88,8 @@ class Settings(BaseSettings): 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. " + "recovery rollback. Split by whitespace to build the argument list — " + "no shell interpretation is performed. " "Example: 'systemctl start fail2ban' or 'fail2ban-client start'." ), ) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index b1453ad..3c07699 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -48,3 +48,23 @@ def test_create_app_skips_cors_when_no_origins_are_configured() -> None: ] assert cors_middleware == [] + + +def test_create_app_disables_cors_by_default() -> None: + """The FastAPI app does not add CORS middleware when no origins are configured by environment.""" + settings = Settings( + database_path="/tmp/test.db", + fail2ban_socket="/tmp/fake_fail2ban.sock", + fail2ban_config_dir="/tmp/fail2ban", + session_secret="test-secret-key-do-not-use-in-production", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + + app = create_app(settings=settings) + cors_middleware = [ + middleware for middleware in app.user_middleware if middleware.cls is CORSMiddleware + ] + + assert cors_middleware == []