This commit is contained in:
2026-05-05 18:47:56 +02:00
parent d25b56e7e1
commit 481f32bb85
15 changed files with 1887 additions and 77 deletions

View File

@@ -70,7 +70,7 @@ services:
volumes: volumes:
- ../backend/app:/app/app:z - ../backend/app:/app/app:z
- ../fail2ban-master:/app/fail2ban-master:ro,z - ../fail2ban-master:/app/fail2ban-master:ro,z
- bangui-dev-data:/data - ../data:/data
- fail2ban-dev-run:/var/run/fail2ban:ro - fail2ban-dev-run:/var/run/fail2ban:ro
- ./fail2ban-dev-config:/config:rw - ./fail2ban-dev-config:/config:rw
ports: ports:
@@ -82,7 +82,7 @@ services:
"--reload", "--reload-dir", "/app/app" "--reload", "--reload-dir", "/app/app"
] ]
healthcheck: 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 interval: 15s
timeout: 5s timeout: 5s
start_period: 45s 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] [blocklist-import]
enabled = true enabled = false
# No log-based detection — only manual banip commands are used.
filter = filter =
logpath = /dev/null logpath = /dev/null
backend = auto backend = auto
maxretry = 1 maxretry = 1
findtime = 1d findtime = 1d
# Block imported IPs for 24 hours.
bantime = 86400 bantime = 86400
# Never ban the Docker bridge network or localhost.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 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] [manual-Jail]
enabled = true enabled = false
filter = manual-Jail filter = manual-Jail
logpath = /remotelogs/bangui/auth.log logpath = /remotelogs/bangui/auth.log
backend = polling backend = polling
maxretry = 3 maxretry = 3
findtime = 120 findtime = 120
bantime = 60 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 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 | | Variable | Type | Default | Description |
|----------|------|---------|-------------| |----------|------|---------|-------------|
| `BANGUI_RATE_LIMIT_BANS_PER_MINUTE` | int | `100` | Max ban/unban requests per IP per minute. | | `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. | | `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. **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). ## Start the debug stack (detached).
## Ensures log stub files exist so fail2ban can open them on first start. ## 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 up: ensure-env
@mkdir -p Docker/logs @mkdir -p Docker/logs
@touch Docker/logs/auth.log @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. ## Stop the debug stack.
down: ensure-env down: ensure-env

View File

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

View File

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

View File

@@ -118,7 +118,7 @@ RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100 RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
"""Max unban requests per IP per minute.""" """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.""" """Max blocklist import requests per IP per hour."""
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50 RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50

View File

@@ -80,7 +80,7 @@ def _load_vendored_fail2ban_client() -> type[object]:
return CSocket return CSocket
except ImportError: 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(): if not vendor_root.is_dir():
raise raise

View File

@@ -12,6 +12,7 @@ from httpx import ASGITransport, AsyncClient
from app.config import Settings from app.config import Settings
from app.db import init_db from app.db import init_db
from app.main import _lifespan, create_app from app.main import _lifespan, create_app
from app.models.server import ServerStatus
from app.services import setup_service from app.services import setup_service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -42,10 +43,16 @@ async def app_and_client(tmp_path: Path) -> tuple[object, AsyncClient]: # type:
Yields: Yields:
A tuple of ``(FastAPI app instance, AsyncClient)``. 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( settings = Settings(
database_path=str(tmp_path / "setup_cache_test.db"), database_path=str(tmp_path / "setup_cache_test.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock", 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, session_duration_minutes=60,
timezone="UTC", timezone="UTC",
log_level="debug", log_level="debug",
@@ -115,7 +122,7 @@ class TestPostSetup:
"/api/v1/setup", "/api/v1/setup",
json={"master_password": "short"}, 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: async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing an uppercase character.""" """Setup endpoint rejects passwords missing an uppercase character."""
@@ -123,11 +130,9 @@ class TestPostSetup:
"/api/v1/setup", "/api/v1/setup",
json={"master_password": "lowercase1!"}, json={"master_password": "lowercase1!"},
) )
assert response.status_code == 422 assert response.status_code == 400
assert any( body = response.json()
"uppercase" in error["msg"].lower() assert body["code"] == "invalid_input"
for error in response.json()["detail"]
)
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None: async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
"""Setup endpoint rejects passwords missing a numeric character.""" """Setup endpoint rejects passwords missing a numeric character."""
@@ -135,11 +140,9 @@ class TestPostSetup:
"/api/v1/setup", "/api/v1/setup",
json={"master_password": "NoNumbers!"}, json={"master_password": "NoNumbers!"},
) )
assert response.status_code == 422 assert response.status_code == 400
assert any( body = response.json()
"number" in error["msg"].lower() assert body["code"] == "invalid_input"
for error in response.json()["detail"]
)
async def test_rejects_missing_special_character_password( async def test_rejects_missing_special_character_password(
self, client: AsyncClient self, client: AsyncClient
@@ -149,11 +152,9 @@ class TestPostSetup:
"/api/v1/setup", "/api/v1/setup",
json={"master_password": "NoSpecial1"}, json={"master_password": "NoSpecial1"},
) )
assert response.status_code == 422 assert response.status_code == 400
assert any( body = response.json()
"special character" in error["msg"].lower() assert body["code"] == "invalid_input"
for error in response.json()["detail"]
)
async def test_rejects_second_call(self, client: AsyncClient) -> None: async def test_rejects_second_call(self, client: AsyncClient) -> None:
"""Setup endpoint returns 409 if setup has already been completed.""" """Setup endpoint returns 409 if setup has already been completed."""
@@ -354,10 +355,20 @@ class TestLifespanDatabaseDirectoryCreation:
nested_db = tmp_path / "deep" / "nested" / "bangui.db" nested_db = tmp_path / "deep" / "nested" / "bangui.db"
assert not nested_db.parent.exists() 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( settings = Settings(
database_path=str(nested_db), database_path=str(nested_db),
fail2ban_socket="/tmp/fake.sock", 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, session_duration_minutes=60,
timezone="UTC", timezone="UTC",
log_level="debug", log_level="debug",
@@ -398,10 +409,14 @@ class TestLifespanDatabaseDirectoryCreation:
db_path = tmp_path / "bangui.db" db_path = tmp_path / "bangui.db"
# tmp_path already exists — this simulates a pre-existing volume. # 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( settings = Settings(
database_path=str(db_path), database_path=str(db_path),
fail2ban_socket="/tmp/fake.sock", 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, session_duration_minutes=60,
timezone="UTC", timezone="UTC",
log_level="debug", log_level="debug",
@@ -436,10 +451,16 @@ class TestLifespanSetupCache:
async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None: async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None:
"""Lifespan should populate ``setup_complete_cached`` based on the DB.""" """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( settings = Settings(
database_path=str(tmp_path / "bangui.db"), database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake.sock", 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, session_duration_minutes=60,
timezone="UTC", timezone="UTC",
log_level="debug", log_level="debug",
@@ -495,10 +516,16 @@ class TestSetupRedirectMiddlewareDbNone:
Simulates the race window where a request arrives before the lifespan Simulates the race window where a request arrives before the lifespan
has finished initialising the database connection. 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( settings = Settings(
database_path=str(tmp_path / "bangui.db"), database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock", 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, session_duration_minutes=60,
timezone="UTC", timezone="UTC",
log_level="debug", log_level="debug",
@@ -517,15 +544,22 @@ class TestSetupRedirectMiddlewareDbNone:
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None: 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.""" """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( settings = Settings(
database_path=str(tmp_path / "bangui.db"), database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock", 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, session_duration_minutes=60,
timezone="UTC", timezone="UTC",
log_level="debug", log_level="debug",
) )
app = create_app(settings=settings) app = create_app(settings=settings)
app.state.server_status = ServerStatus(online=True)
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient( 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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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="description" content="BanGUI — fail2ban management interface." />
<meta name="theme-color" content="#0F6CBD" /> <meta name="theme-color" content="#0F6CBD" />
<meta name="robots" content="noindex, nofollow" /> <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"