Add deployment-safe backend config and production-safe CORS defaults
This commit is contained in:
@@ -76,5 +76,5 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
2. ✅ Refactor dependency injection / explicit shared resources.
|
2. ✅ Refactor dependency injection / explicit shared resources.
|
||||||
3. ✅ Harden fail2ban client concurrency and packaging.
|
3. ✅ Harden fail2ban client concurrency and packaging.
|
||||||
4. ✅ Convert setup guard to a safer startup-driven model.
|
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.
|
6. Add lifecycle and concurrency regression tests.
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ BANGUI_DATABASE_PATH=bangui.db
|
|||||||
# Path to the fail2ban Unix domain socket.
|
# Path to the fail2ban Unix domain socket.
|
||||||
BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
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.
|
# Secret key used to sign session tokens. Use a long, random string.
|
||||||
# Generate with: python -c "import secrets; print(secrets.token_hex(64))"
|
# Generate with: python -c "import secrets; print(secrets.token_hex(64))"
|
||||||
BANGUI_SESSION_SECRET=replace-this-with-a-long-random-secret
|
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
|
# Comma-separated list of allowed CORS origins when the frontend is served
|
||||||
# from a different origin than the backend.
|
# 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
|
BANGUI_CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_
|
|||||||
and validated at startup via the Settings singleton.
|
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 pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from app.utils.constants import (
|
||||||
|
DEFAULT_DATABASE_PATH,
|
||||||
|
DEFAULT_FAIL2BAN_SOCKET,
|
||||||
|
DEFAULT_SESSION_DURATION_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""BanGUI runtime configuration.
|
"""BanGUI runtime configuration.
|
||||||
@@ -18,11 +24,11 @@ class Settings(BaseSettings):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
database_path: str = Field(
|
database_path: str = Field(
|
||||||
default="bangui.db",
|
default=DEFAULT_DATABASE_PATH,
|
||||||
description="Filesystem path to the BanGUI SQLite application database.",
|
description="Filesystem path to the BanGUI SQLite application database.",
|
||||||
)
|
)
|
||||||
fail2ban_socket: str = Field(
|
fail2ban_socket: str = Field(
|
||||||
default="/var/run/fail2ban/fail2ban.sock",
|
default=DEFAULT_FAIL2BAN_SOCKET,
|
||||||
description="Path to the fail2ban Unix domain socket.",
|
description="Path to the fail2ban Unix domain socket.",
|
||||||
)
|
)
|
||||||
session_secret: str = Field(
|
session_secret: str = Field(
|
||||||
@@ -33,7 +39,7 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
session_duration_minutes: int = Field(
|
session_duration_minutes: int = Field(
|
||||||
default=60,
|
default=DEFAULT_SESSION_DURATION_MINUTES,
|
||||||
ge=1,
|
ge=1,
|
||||||
description="Number of minutes a session token remains valid after creation.",
|
description="Number of minutes a session token remains valid after creation.",
|
||||||
)
|
)
|
||||||
@@ -41,14 +47,24 @@ class Settings(BaseSettings):
|
|||||||
default="UTC",
|
default="UTC",
|
||||||
description="IANA timezone name used when displaying timestamps in the UI.",
|
description="IANA timezone name used when displaying timestamps in the UI.",
|
||||||
)
|
)
|
||||||
cors_allowed_origins: list[str] = Field(
|
cors_allowed_origins: str | list[str] = Field(
|
||||||
default_factory=lambda: ["http://localhost:5173"],
|
default_factory=list,
|
||||||
description=(
|
description=(
|
||||||
"Comma-separated list of allowed CORS origins when the frontend is "
|
"Comma-separated list of allowed CORS origins when the frontend is "
|
||||||
"served from a different origin than the backend. "
|
"served from a different origin than the backend. "
|
||||||
"Leave empty to disable cross-origin requests in production."
|
"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(
|
log_level: str = Field(
|
||||||
default="info",
|
default="info",
|
||||||
description="Application log level: debug | info | warning | error | critical.",
|
description="Application log level: debug | info | warning | error | critical.",
|
||||||
@@ -72,8 +88,8 @@ class Settings(BaseSettings):
|
|||||||
default="fail2ban-client start",
|
default="fail2ban-client start",
|
||||||
description=(
|
description=(
|
||||||
"Shell command used to start (not reload) the fail2ban daemon during "
|
"Shell command used to start (not reload) the fail2ban daemon during "
|
||||||
"recovery rollback. Split by whitespace to build the argument list — "
|
"recovery rollback. Split by whitespace to build the argument list — "
|
||||||
"no shell interpretation is performed. "
|
"no shell interpretation is performed. "
|
||||||
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
|
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,3 +48,23 @@ def test_create_app_skips_cors_when_no_origins_are_configured() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
assert cors_middleware == []
|
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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user