refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
15 changed files with 1887 additions and 77 deletions
Showing only changes of commit 481f32bb85 - Show all commits

View File

@@ -70,7 +70,7 @@ services:
volumes:
- ../backend/app:/app/app:z
- ../fail2ban-master:/app/fail2ban-master:ro,z
- bangui-dev-data:/data
- ../data:/data
- fail2ban-dev-run:/var/run/fail2ban:ro
- ./fail2ban-dev-config:/config:rw
ports:
@@ -82,7 +82,7 @@ services:
"--reload", "--reload-dir", "/app/app"
]
healthcheck:
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/health\", timeout=4)'"]
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/v1/health/live\", timeout=4)'"]
interval: 15s
timeout: 5s
start_period: 45s

View File

@@ -1,13 +0,0 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Simulated authentication failure filter
#
# Matches lines written by Docker/simulate_failed_logins.sh
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
# Jail: manual-Jail
# ──────────────────────────────────────────────────────────────
[Definition]
failregex = ^.* bangui-auth: authentication failure from <HOST>\s*$
ignoreregex =

View File

@@ -1,25 +1,10 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Blocklist-import jail
#
# Dedicated jail for IPs banned via the BanGUI blocklist import
# feature. This is a manual-ban jail: it does not watch any log
# file. All bans are injected programmatically via
# fail2ban-client set blocklist-import banip <ip>
# which the BanGUI backend uses through its fail2ban socket
# client.
# ──────────────────────────────────────────────────────────────
[blocklist-import]
enabled = true
# No log-based detection — only manual banip commands are used.
enabled = false
filter =
logpath = /dev/null
backend = auto
maxretry = 1
findtime = 1d
# Block imported IPs for 24 hours.
bantime = 86400
# Never ban the Docker bridge network or localhost.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -1,19 +1,10 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Simulated authentication failure jail
#
# Watches Docker/logs/auth.log (mounted at /remotelogs/bangui)
# for lines produced by Docker/simulate_failed_logins.sh.
# ──────────────────────────────────────────────────────────────
[manual-Jail]
enabled = true
enabled = false
filter = manual-Jail
logpath = /remotelogs/bangui/auth.log
backend = polling
maxretry = 3
findtime = 120
bantime = 60
# Never ban localhost, the Docker bridge network, or the host machine.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -1,6 +0,0 @@
# Local overrides — not overwritten by the container init script.
# Provides banaction so all jails can resolve %(action_)s interpolation.
[DEFAULT]
banaction = iptables-multiport
banaction_allports = iptables-allports

View File

@@ -123,7 +123,7 @@ Per-IP rate limits applied to API endpoints.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_RATE_LIMIT_BANS_PER_MINUTE` | int | `100` | Max ban/unban requests per IP per minute. |
| `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `10` | Max blocklist import requests per IP per hour. |
| `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `100` | Max blocklist import requests per IP per hour. |
| `BANGUI_RATE_LIMIT_CONFIG_UPDATE_PER_MINUTE` | int | `50` | Max config update requests per IP per minute. |
**Rate limit reset mechanism:** Each limit is applied per-client IP. To bypass the blocklist import rate limit in automated tests (E2E-4), send a unique `X-Forwarded-For` header with each import request — e.g., `X-Forwarded-For: 10.0.0.99`. The header is only honoured when the client IP falls within `BANGUI_TRUSTED_PROXIES`; otherwise the real client IP is used.

View File

@@ -64,10 +64,11 @@ print('Created .env with a generated BANGUI_SESSION_SECRET.')"; \
## Start the debug stack (detached).
## Ensures log stub files exist so fail2ban can open them on first start.
## All output is logged to Docker/logs/make-up.log.
up: ensure-env
@mkdir -p Docker/logs
@touch Docker/logs/auth.log
$(COMPOSE) $(COMPOSE_OPTS) up -d
$(COMPOSE) $(COMPOSE_OPTS) up -d 2>&1 | tee Docker/logs/make-up.log
## Stop the debug stack.
down: ensure-env

View File

@@ -291,8 +291,11 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
)
# 3. Close HTTP session to release connections.
await http_session.close()
log.debug("http_session_closed")
try:
await http_session.close()
log.debug("http_session_closed")
except asyncio.CancelledError:
log.debug("http_session_close_cancelled")
# 4. Shutdown external logging handler.
if _external_log_handler:

View File

@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/v1/setup", tags=["setup"])
@router.get(
"/",
"",
response_model=SetupStatusResponse,
summary="Check whether setup has been completed",
responses={

View File

@@ -118,7 +118,7 @@ RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
"""Max unban requests per IP per minute."""
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 10
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 100
"""Max blocklist import requests per IP per hour."""
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50

View File

@@ -80,7 +80,7 @@ def _load_vendored_fail2ban_client() -> type[object]:
return CSocket
except ImportError:
vendor_root = Path(__file__).resolve().parents[4] / "fail2ban-master"
vendor_root = Path(__file__).resolve().parents[2] / "fail2ban-master"
if not vendor_root.is_dir():
raise

View File

@@ -12,6 +12,7 @@ from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db import init_db
from app.main import _lifespan, create_app
from app.models.server import ServerStatus
from app.services import setup_service
# ---------------------------------------------------------------------------
@@ -42,10 +43,16 @@ async def app_and_client(tmp_path: Path) -> tuple[object, AsyncClient]: # type:
Yields:
A tuple of ``(FastAPI app instance, AsyncClient)``.
"""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "setup_cache_test.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-setup-cache-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-setup-cache-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -115,7 +122,7 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "short"},
)
assert response.status_code == 422
assert response.status_code == 400
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing an uppercase character."""
@@ -123,11 +130,9 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "lowercase1!"},
)
assert response.status_code == 422
assert any(
"uppercase" in error["msg"].lower()
for error in response.json()["detail"]
)
assert response.status_code == 400
body = response.json()
assert body["code"] == "invalid_input"
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing a numeric character."""
@@ -135,11 +140,9 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "NoNumbers!"},
)
assert response.status_code == 422
assert any(
"number" in error["msg"].lower()
for error in response.json()["detail"]
)
assert response.status_code == 400
body = response.json()
assert body["code"] == "invalid_input"
async def test_rejects_missing_special_character_password(
self, client: AsyncClient
@@ -149,11 +152,9 @@ class TestPostSetup:
"/api/v1/setup",
json={"master_password": "NoSpecial1"},
)
assert response.status_code == 422
assert any(
"special character" in error["msg"].lower()
for error in response.json()["detail"]
)
assert response.status_code == 400
body = response.json()
assert body["code"] == "invalid_input"
async def test_rejects_second_call(self, client: AsyncClient) -> None:
"""Setup endpoint returns 409 if setup has already been completed."""
@@ -354,10 +355,20 @@ class TestLifespanDatabaseDirectoryCreation:
nested_db = tmp_path / "deep" / "nested" / "bangui.db"
assert not nested_db.parent.exists()
# Settings requires the database parent to exist at construction time,
# so create the immediate parent. The intermediate directory
# (nested_db.parent.parent) is deliberately left non-existent to verify
# lifespan creates it.
nested_db.parent.mkdir(parents=True, exist_ok=True)
config_dir = tmp_path / "fail2ban"
config_dir.mkdir(parents=True, exist_ok=True)
settings = Settings(
database_path=str(nested_db),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-mkdir-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-lifespan-mkdir-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -398,10 +409,14 @@ class TestLifespanDatabaseDirectoryCreation:
db_path = tmp_path / "bangui.db"
# tmp_path already exists — this simulates a pre-existing volume.
config_dir = tmp_path / "fail2ban"
config_dir.mkdir(parents=True, exist_ok=True)
settings = Settings(
database_path=str(db_path),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-exist-ok-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-lifespan-exist-ok-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -436,10 +451,16 @@ class TestLifespanSetupCache:
async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None:
"""Lifespan should populate ``setup_complete_cached`` based on the DB."""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-setup-cache-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-lifespan-setup-cache-secret-that-is-long",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -495,10 +516,16 @@ class TestSetupRedirectMiddlewareDbNone:
Simulates the race window where a request arrives before the lifespan
has finished initialising the database connection.
"""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-db-none-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-db-none-secret-that-is-long-enough",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
@@ -517,15 +544,22 @@ class TestSetupRedirectMiddlewareDbNone:
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
"""Health endpoint is always reachable even when db is not initialised."""
config_dir = tmp_path / "fail2ban"
(config_dir / "jail.d").mkdir(parents=True)
(config_dir / "filter.d").mkdir(parents=True)
(config_dir / "action.d").mkdir(parents=True)
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-db-none-health-secret",
fail2ban_config_dir=str(config_dir),
session_secret="test-db-none-health-secret-that-is-long",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
app.state.server_status = ServerStatus(online=True)
transport = ASGITransport(app=app)
async with AsyncClient(

1812
backend/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'" />
<meta name="description" content="BanGUI — fail2ban management interface." />
<meta name="theme-color" content="#0F6CBD" />
<meta name="robots" content="noindex, nofollow" />

3
uv.lock generated Normal file
View File

@@ -0,0 +1,3 @@
version = 1
revision = 3
requires-python = ">=3.12"