10 Commits

Author SHA1 Message Date
5a12d1c22f chore: release v0.9.19-rc.5 2026-05-23 21:32:21 +02:00
aebe0d0236 chore(release): bump version to 0.9.19-rc.4
- Add production Docker Compose configuration

- Add check_auth.py diagnostic script for session 401 debugging
2026-05-23 21:27:52 +02:00
99e1b74405 chore: release v0.9.19-rc.4 2026-05-22 21:49:01 +02:00
9fe52755a5 fix(db): fix migration failures when upgrading from 0.8.0 schema
Migration 1: remove idx_sessions_token_hash from _SCHEMA_STATEMENTS.
The legacy schema has sessions.token (not token_hash). The IF NOT EXISTS
guard only prevents duplicate index names — it still requires the column
to exist. Migration 2 drops and rebuilds sessions with token_hash anyway,
so creating the index in migration 1 was redundant.

Migration 3: replace ALTER TABLE ADD COLUMN with a table rebuild.
SQLite rejects ALTER TABLE ADD COLUMN NOT NULL DEFAULT <expression> when
the table already contains rows. The old DB has ~181k geo_cache rows, so
the ALTER always failed. Rebuild copies existing rows with last_seen set
to cached_at as a reasonable approximation of last-seen time.
2026-05-22 21:47:32 +02:00
9d2d6fadf3 chore: release v0.9.19-rc.3 2026-05-22 20:49:12 +02:00
2e5ac092bf fix(auth): suppress misleading 502 warning during session validation
A 502 Bad Gateway is a server/gateway error, not a network error.
Logging it as a 'Session validation network error' is noisy and
misleading during startup when nginx is temporarily unreachable.

Silently skip the console.warn for 5xx errors in handleValidationError
while keeping the warning for actual network errors.
2026-05-22 20:47:57 +02:00
dcee222a41 chore: release v0.9.19-rc.2 2026-05-22 20:38:33 +02:00
12fe70d768 chore: bump to v0.9.19-rc.1 and add local OpenAPI build support
- Add release candidate (rc) support to release.sh with latestRC tagging
- Bump VERSION, backend pyproject.toml, and frontend package.json to 0.9.19-rc.1
- Add local frontend/openapi.json so build no longer needs running backend
- Update generate:types and validate-types.sh to use local openapi.json
- Fix frontend tests: remove unused imports/variables and update mock data
2026-05-22 20:36:14 +02:00
83b2cb67b1 backup
Some checks failed
CI / Backend Tests (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Import Boundary (pull_request) Has been cancelled
CI / OpenAPI Breaking Changes (pull_request) Has been cancelled
CI / OpenAPI Baseline Commit (pull_request) Has been cancelled
2026-05-20 20:18:58 +02:00
7308ff88d6 fix(rate-limit): stop double-counting requests in middleware
Multiple RateLimitMiddleware instances were each calling
check_allowed() on every request, halving the effective global
limit (200 req/min became ~100). Added path_prefixes and skip_paths
so each instance only checks the paths it owns.

- Auth middleware scoped to /api/v1/auth/login and /api/v1/setup
- History middleware scoped to /api/v1/history
- Global middleware skips auth and history paths
- Updated tests to match single-count behavior
2026-05-15 23:04:02 +02:00
21 changed files with 10834 additions and 111 deletions

View File

@@ -18,7 +18,7 @@ WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* /build/
RUN npm ci --ignore-scripts
# Copy source and build
# Copy source + local OpenAPI spec (avoids needing a running backend during build)
COPY frontend/ /build/
RUN npm run build

View File

@@ -1 +1 @@
v0.9.19
v0.9.19-rc.5

105
Docker/compose.prod.yml Normal file
View File

@@ -0,0 +1,105 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Production Compose
#
# Usage:
# docker compose -f Docker/compose.prod.yml up -d
# podman compose -f Docker/compose.prod.yml up -d
#
# Features:
# - Multi-stage built images (no volume-mounted source code)
# - Frontend served by nginx with API reverse proxy
# - Backend running uvicorn without --reload
# - Only port 8080 exposed to host
# ──────────────────────────────────────────────────────────────
name: bangui
services:
# ── fail2ban ─────────────────────────────────────────────────
fail2ban:
image: lscr.io/linuxserver/fail2ban:latest
container_name: bangui-fail2ban
restart: unless-stopped
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: host
environment:
TZ: "${BANGUI_TIMEZONE:-UTC}"
PUID: 0
PGID: 0
volumes:
- ../data/fail2ban-dev-config:/config
- fail2ban-run:/var/run/fail2ban
- /var/log:/var/log:ro
- ../data/log:/remotelogs/bangui
healthcheck:
test: ["CMD", "fail2ban-client", "ping"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
backend:
build:
context: ..
dockerfile: Docker/Dockerfile.backend
target: runtime
container_name: bangui-backend
restart: unless-stopped
depends_on:
fail2ban:
condition: service_healthy
environment:
BANGUI_DATABASE_PATH: "/data/bangui.db"
BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock"
BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban"
BANGUI_LOG_FILE: "/data/log/bangui.log"
BANGUI_LOG_LEVEL: "${BANGUI_LOG_LEVEL:-info}"
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:?BANGUI_SESSION_SECRET must be set — generate with: python -c 'import secrets; print(secrets.token_hex(32))'}"
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
BANGUI_SESSION_COOKIE_SECURE: "${BANGUI_SESSION_COOKIE_SECURE:-true}"
BANGUI_CORS_ALLOWED_ORIGINS: "${BANGUI_CORS_ALLOWED_ORIGINS:-}"
volumes:
- ../data:/data
- ../fail2ban-master:/app/fail2ban-master:ro
- fail2ban-run:/var/run/fail2ban:ro
- ../data/fail2ban-dev-config:/config:rw
networks:
- bangui-net
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/v1/health/live || exit 1"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
# ── Frontend (nginx serving built SPA) ──────────────────────
frontend:
build:
context: ..
dockerfile: Docker/Dockerfile.frontend
container_name: bangui-frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "${BANGUI_PORT:-8080}:80"
networks:
- bangui-net
healthcheck:
test: ["CMD-SHELL", "wget -qO /dev/null http://localhost:80/ || exit 1"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3
volumes:
fail2ban-run:
driver: local
networks:
bangui-net:
driver: bridge

View File

@@ -6,7 +6,7 @@
# ./release.sh
#
# The current version is stored in VERSION (next to this script).
# You will be asked whether to bump major, minor, or patch.
# You will be asked whether to bump major, minor, patch, or release candidate (rc).
set -euo pipefail
@@ -24,24 +24,60 @@ CURRENT="$(cat "${VERSION_FILE}")"
# Strip leading 'v' for arithmetic
VERSION="${CURRENT#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
# Parse version: X.Y.Z or X.Y.Z-rc.N
if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$ ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
RC_SUFFIX="${BASH_REMATCH[4]:-}"
RC_NUM="${BASH_REMATCH[5]:-0}"
else
echo "Error: version '${VERSION}' does not match expected format X.Y.Z or X.Y.Z-rc.N" >&2
exit 1
fi
echo "============================================"
echo " BanGUI — Release"
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
if [[ -n "${RC_SUFFIX}" ]]; then
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM}"
else
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
fi
echo "============================================"
echo ""
echo "How would you like to bump the version?"
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
if [[ -n "${RC_SUFFIX}" ]]; then
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH})"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v$((MAJOR + 1)).0.0)"
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1)))"
else
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo " 4) rc (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.${PATCH}-rc.1)"
fi
echo ""
read -rp "Enter choice [1/2/3]: " CHOICE
read -rp "Enter choice [1/2/3/4]: " CHOICE
case "${CHOICE}" in
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
1)
if [[ -n "${RC_SUFFIX}" ]]; then
# Release the RC: strip RC suffix
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
else
NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
;;
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
4)
if [[ "${RC_NUM}" -gt 0 ]]; then
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.$((RC_NUM + 1))"
else
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}-rc.1"
fi
;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
@@ -81,7 +117,13 @@ fi
# Push containers
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"
# Push to "latest" or "latestRC" depending on whether this is a release candidate
if [[ "${NEW_TAG}" == *-rc* ]]; then
bash "${SCRIPT_DIR}/push.sh" "latestRC"
else
bash "${SCRIPT_DIR}/push.sh" "latest"
fi
# ---------------------------------------------------------------------------

View File

@@ -102,10 +102,15 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
"""
# Ordered list of DDL statements to execute on initialisation.
# NOTE: _CREATE_SESSIONS_TOKEN_INDEX is intentionally omitted here.
# The old 0.8.0 schema has a `sessions.token` column (not `token_hash`), so
# running CREATE INDEX … ON sessions (token_hash) in migration 1 would fail
# with "no such column: token_hash" on legacy databases. Migration 2 drops
# and recreates the sessions table with token_hash and also creates the index,
# so there is no need to create it in migration 1.
_SCHEMA_STATEMENTS: list[str] = [
_CREATE_SETTINGS,
_CREATE_SESSIONS,
_CREATE_SESSIONS_TOKEN_INDEX,
_CREATE_BLOCKLIST_SOURCES,
_CREATE_IMPORT_LOG,
_CREATE_GEO_CACHE,
@@ -133,8 +138,24 @@ CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
3: """
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
-- Tracks when each IP was last referenced to enable purging of stale entries.
-- Default to current timestamp for existing rows.
ALTER TABLE geo_cache ADD COLUMN last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
-- SQLite rejects ALTER TABLE ADD COLUMN with a non-constant NOT NULL default
-- when the table already contains rows, so we rebuild the table instead.
-- Existing rows receive last_seen = cached_at as a reasonable approximation
-- (the IP was at least seen when it was first cached).
DROP TABLE IF EXISTS geo_cache_new;
CREATE TABLE geo_cache_new (
ip TEXT PRIMARY KEY,
country_code TEXT,
country_name TEXT,
asn TEXT,
org TEXT,
cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO geo_cache_new (ip, country_code, country_name, asn, org, cached_at, last_seen)
SELECT ip, country_code, country_name, asn, org, cached_at, cached_at FROM geo_cache;
DROP TABLE geo_cache;
ALTER TABLE geo_cache_new RENAME TO geo_cache;
""",
4: """
-- Migration 4: Add scheduler_lock table for multi-worker safety.

View File

@@ -242,9 +242,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# deployments, it should be replaced with a shared backend.
_update_session_cache(app, settings)
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
# Initialize the global rate limiter (600 requests per 60 seconds per IP).
# Applied to all endpoints via middleware. Process-local implementation.
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=600, window_seconds=60)
log.info("bangui_started")
@@ -1095,10 +1095,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
else NoOpSessionCache()
)
# Initialize the global rate limiter (200 requests per 60 seconds per IP).
# Initialize the global rate limiter (600 requests per 60 seconds per IP).
# This is also re-initialized in the lifespan, but must be present here
# for tests that bypass the lifespan via ASGITransport.
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60)
app.state.global_rate_limiter = GlobalRateLimiter(max_requests=600, window_seconds=60)
set_setup_complete_cache(app, False)
@@ -1135,9 +1135,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_middleware(CsrfMiddleware)
app.add_middleware(DeprecationHeaderMiddleware)
# Auth endpoints (login, setup) need a dedicated higher-rate bucket to avoid
# rate limiting when running e2e tests sequentially. Auth uses the default
# global rate limiter at 200 req/min per IP.
# Auth endpoints: /api/v1/login, /api/v1/setup
# rate limiting when running e2e tests sequentially.
# 1000 req/min per IP — generous for e2e testing.
app.add_middleware(
RateLimitMiddleware,
@@ -1146,6 +1144,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
bucket_override="auth:login",
bucket_max_requests=1000,
bucket_window_seconds=60,
path_prefixes=["/api/v1/auth/login", "/api/v1/setup"],
)
# History endpoints get a dedicated higher-rate bucket to avoid
@@ -1159,6 +1158,28 @@ def create_app(settings: Settings | None = None) -> FastAPI:
bucket_override="history:list",
bucket_max_requests=10000,
bucket_window_seconds=60,
path_prefixes=["/api/v1/history"],
)
# Polling endpoints (blocklist schedule) get a dedicated bucket
# to avoid exhausting the global limit during normal frontend operation.
app.add_middleware(
RateLimitMiddleware,
rate_limiter=app.state.global_rate_limiter,
settings=resolved_settings,
bucket_override="polling:read",
bucket_max_requests=10000,
bucket_window_seconds=60,
path_prefixes=["/api/v1/blocklists/schedule"],
)
# Global rate limiter for all other endpoints.
# 600 req/min per IP — default protection.
app.add_middleware(
RateLimitMiddleware,
rate_limiter=app.state.global_rate_limiter,
settings=resolved_settings,
skip_paths=["/api/v1/auth/login", "/api/v1/setup", "/api/v1/history", "/api/v1/blocklists/schedule"],
)
# Validate middleware order before returning the app.

View File

@@ -34,18 +34,20 @@ unusual and potentially suspicious) always carry a correlation ID for tracing.
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
from app.utils.logging_compat import get_logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from app.exceptions import RateLimitError
from app.utils.client_ip import get_client_ip
from app.utils.logging_compat import get_logger
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from app.config import Settings
from app.utils.rate_limiter import GlobalRateLimiter
@@ -53,11 +55,15 @@ log = get_logger(__name__)
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Enforce global per-IP request rate limiting on all endpoints.
"""Enforce per-IP request rate limiting on matching endpoints.
Tracks requests per IP and blocks further requests if the limit is exceeded.
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
for consistent IP extraction.
Each middleware instance is scoped to a set of path prefixes (or all paths
if no prefixes are given). This allows multiple instances to coexist
without double-counting requests.
"""
def __init__(
@@ -68,6 +74,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
bucket_override: str | None = None,
bucket_max_requests: int | None = None,
bucket_window_seconds: int | None = None,
path_prefixes: list[str] | None = None,
skip_paths: list[str] | None = None,
) -> None:
"""Initialize the rate limit middleware.
@@ -78,6 +86,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
bucket_override: Optional named bucket to use instead of the default limiter.
bucket_max_requests: Max requests for the bucket override.
bucket_window_seconds: Window for the bucket override.
path_prefixes: If provided, only apply rate limiting to paths that
start with one of these prefixes. If ``None``, all paths are
matched.
skip_paths: If provided, do not apply rate limiting to paths that
start with one of these prefixes. Evaluated after
``path_prefixes``.
"""
super().__init__(app) # type: ignore[arg-type]
self.rate_limiter: GlobalRateLimiter = rate_limiter
@@ -85,6 +99,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.bucket_override = bucket_override
self.bucket_max_requests = bucket_max_requests
self.bucket_window_seconds = bucket_window_seconds
self.path_prefixes = path_prefixes or []
self.skip_paths = skip_paths or []
def _should_check(self, path: str) -> bool:
"""Return whether the given path should be rate-limited by this instance.
Args:
path: The request URL path.
Returns:
``True`` if this instance should enforce its limit on the path.
"""
if self.skip_paths and any(path.startswith(p) for p in self.skip_paths):
return False
if self.path_prefixes:
return any(path.startswith(p) for p in self.path_prefixes)
return True
async def dispatch(
self,
@@ -103,37 +134,28 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
Returns:
A response object (either rate limit response or from handler).
"""
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
# Use higher-rate bucket for specific endpoints.
# Check path to apply the appropriate bucket.
path = request.url.path
if not self._should_check(path):
return await call_next(request)
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.bucket_override and self.bucket_max_requests and self.bucket_window_seconds:
if path.startswith("/api/v1/history"):
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
elif path.startswith("/api/v1/login") or path.startswith("/api/v1/setup"):
# Auth endpoints use their own bucket
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
else:
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
else:
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
if not is_allowed:
log.warning(
"global_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
path=path,
method=request.method,
retry_after=retry_after,
)
@@ -141,7 +163,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"Too many requests. Please try again later.",
retry_after_seconds=retry_after,
)
# Return the error response directly
return JSONResponse(
status_code=429,
content={
@@ -153,6 +174,5 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
headers={"Retry-After": str(int(retry_after))},
)
# Request is allowed, continue to next handler
response: Response = await call_next(request)
return response

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bangui-backend"
version = "0.9.19"
version = "0.9.19-rc.4"
description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12"
dependencies = [

View File

@@ -134,24 +134,17 @@ class TestRateLimitMiddleware:
"""Global rate limit should block requests exceeding per-IP limit."""
await _do_setup(client)
# Create a client that mimics a specific IP
# We'll make many requests and see if we hit the limit
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# Reduce limit temporarily for testing.
# Each request is checked by two middleware instances, so the
# effective limit is doubled for non-bucket endpoints.
original_max = limiter.max_requests
limiter.max_requests = 7
limiter.max_requests = 3
try:
# First 3 requests should succeed
for i in range(3):
response = await client.get("/api/v1/health")
assert response.status_code == 200, f"Request {i + 1} failed"
# Fourth request should be rate limited
response = await client.get("/api/v1/health")
assert response.status_code == 429
assert response.json()["code"] == "rate_limit_exceeded"
@@ -166,22 +159,47 @@ class TestRateLimitMiddleware:
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# Two middleware instances check each request, so the effective
# limit is doubled for non-bucket endpoints.
original_max = limiter.max_requests
limiter.max_requests = 3
limiter.max_requests = 2
try:
# First request succeeds
response = await client.get("/api/v1/health")
assert response.status_code == 200
# Second request is rate limited
response = await client.get("/api/v1/health")
assert response.status_code == 200
response = await client.get("/api/v1/health")
assert response.status_code == 429
assert "Retry-After" in response.headers
retry_after = int(response.headers["Retry-After"])
assert retry_after > 0
assert retry_after <= 60 # Should be less than window
assert retry_after <= 60
finally:
limiter.max_requests = original_max
async def test_auth_bucket_allows_more_requests(self, client: AsyncClient) -> None:
"""Auth endpoints use a dedicated high-rate bucket."""
await _do_setup(client)
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
# The auth bucket is configured for 1000 req/min; we only need to
# verify that it is *not* the global bucket (200 req/min).
for _ in range(5):
response = await client.post("/api/v1/auth/login", json={"password": "x"})
assert response.status_code in (401, 403, 429)
async def test_history_bucket_allows_more_requests(self, client: AsyncClient) -> None:
"""History endpoints use a dedicated high-rate bucket."""
await _do_setup(client)
limiter = client._transport.app.state.global_rate_limiter
limiter.reset()
for _ in range(5):
response = await client.get("/api/v1/history/bans")
# 401/403 is fine — we just need to confirm we are not 429'd
# by the global limiter.
assert response.status_code != 429

147
check_auth.py Normal file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Diagnostic script for BanGUI auth/session 401 issue.
Tests the full auth flow against http://192.168.178.43:8080/api/v1/auth
using password "Hallo123!".
Usage:
python3 check_auth.py
"""
import json
import urllib.error
import urllib.request
BASE_URL = "http://192.168.178.43:8080/api/v1"
PASSWORD = "Hallo123!"
def make_request(url, method="GET", data=None, headers=None, cookie=None):
"""Make an HTTP request and return (status, headers, body, cookies)."""
req_headers = headers or {}
if data:
req_headers["Content-Type"] = "application/json"
if cookie:
req_headers["Cookie"] = cookie
req = urllib.request.Request(
url,
data=json.dumps(data).encode("utf-8") if data else None,
headers=req_headers,
method=method,
)
try:
with urllib.request.urlopen(req) as resp:
body = resp.read().decode("utf-8")
cookies = resp.headers.get_all("Set-Cookie") or []
return resp.status, dict(resp.headers), body, cookies
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8")
cookies = e.headers.get_all("Set-Cookie") or []
return e.code, dict(e.headers), body, cookies
except Exception as e:
return None, {}, str(e), []
def extract_cookie_value(set_cookie_headers, cookie_name):
"""Extract cookie value from Set-Cookie headers."""
for header in set_cookie_headers:
if header.startswith(cookie_name + "="):
return header.split(";")[0]
return None
def main():
print("=" * 60)
print("BanGUI Auth Diagnostic Script")
print("Target:", BASE_URL)
print("=" * 60)
# 1. Check health endpoint (no auth needed)
print("\n[1] GET /health")
status, headers, body, _ = make_request(f"{BASE_URL}/health")
print(f" Status: {status}")
print(f" Response: {body[:200]}")
# 2. Check CORS preflight for login
print("\n[2] OPTIONS /auth/login (CORS preflight)")
status, headers, body, _ = make_request(
f"{BASE_URL}/auth/login",
method="OPTIONS",
headers={
"Origin": "http://192.168.178.43:8080",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "Content-Type",
},
)
print(f" Status: {status}")
print(f" Access-Control-Allow-Origin: {headers.get('Access-Control-Allow-Origin', 'MISSING')}")
print(f" Access-Control-Allow-Credentials: {headers.get('Access-Control-Allow-Credentials', 'MISSING')}")
# 3. Login
print(f"\n[3] POST /auth/login (password: {PASSWORD})")
status, headers, body, cookies = make_request(
f"{BASE_URL}/auth/login",
method="POST",
data={"password": PASSWORD},
headers={"Origin": "http://192.168.178.43:8080"},
)
print(f" Status: {status}")
print(f" Response: {body}")
print(f" Set-Cookie headers: {cookies}")
session_cookie = extract_cookie_value(cookies, "bangui_session")
if session_cookie:
print(f" Extracted session cookie: {session_cookie[:50]}...")
else:
print(" WARNING: No bangui_session cookie received!")
# 4. Validate session with cookie
print("\n[4] GET /auth/session (with cookie)")
if session_cookie:
status, headers, body, _ = make_request(
f"{BASE_URL}/auth/session",
cookie=session_cookie,
headers={"Origin": "http://192.168.178.43:8080"},
)
print(f" Status: {status}")
print(f" Response: {body}")
else:
print(" SKIPPED (no cookie from login)")
# 5. Validate session WITHOUT cookie (should be 401)
print("\n[5] GET /auth/session (without cookie)")
status, headers, body, _ = make_request(f"{BASE_URL}/auth/session")
print(f" Status: {status}")
print(f" Response: {body}")
# 6. Check backend settings (if available via /setup or other endpoint)
print("\n[6] GET /setup (check if setup is complete)")
status, headers, body, _ = make_request(f"{BASE_URL}/setup")
print(f" Status: {status}")
print(f" Response: {body[:200]}")
print("\n" + "=" * 60)
print("DIAGNOSIS SUMMARY")
print("=" * 60)
if session_cookie and "Secure" in str(cookies):
print("\n PROBLEM FOUND: Session cookie has 'Secure' flag set,")
print(" but you are accessing over HTTP (not HTTPS).")
print(" Browsers will NOT send Secure cookies over HTTP!")
print("\n FIX: Set SESSION_COOKIE_SECURE=false in your backend .env")
print(" and restart the backend.")
if not session_cookie and status == 401:
print("\n PROBLEM FOUND: Login succeeded but no session cookie was set.")
print(" This usually means the cookie is being rejected by the browser")
print(" due to Secure flag on HTTP, or SameSite restrictions.")
print("\n If CORS Access-Control-Allow-Origin is missing or wrong,")
print(" add your frontend origin to CORS_ALLOWED_ORIGINS in .env")
print("=" * 60)
if __name__ == "__main__":
main()

10343
frontend/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{
"name": "bangui-frontend",
"version": "0.9.19",
"version": "0.9.19-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bangui-frontend",
"version": "0.9.19",
"version": "0.9.19-rc.4",
"dependencies": {
"@fluentui/react-components": "^9.55.0",
"@fluentui/react-icons": "^2.0.257",

View File

@@ -1,12 +1,12 @@
{
"name": "bangui-frontend",
"private": true,
"version": "0.9.19",
"version": "0.9.19-rc.5",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {
"dev": "vite",
"generate:types": "openapi-typescript http://localhost:8000/api/openapi.json -o src/types/generated.ts",
"generate:types": "openapi-typescript ./openapi.json -o src/types/generated.ts",
"validate:types": "bash scripts/validate-types.sh",
"build": "npm run generate:types && tsc --noEmit && vite build",
"preview": "vite preview",

View File

@@ -17,17 +17,23 @@ GENERATED_FILE="${TYPES_DIR}/generated.ts"
TEMP_FILE=$(mktemp)
trap "rm -f $TEMP_FILE" EXIT
# Check if backend is accessible
# Determine OpenAPI source: local file or backend URL
BACKEND_URL="${BANGUI_BACKEND_URL:-http://localhost:8000}"
if ! curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json" >&2
OPENAPI_SOURCE=""
if [[ -f "${FRONTEND_DIR}/openapi.json" ]]; then
OPENAPI_SOURCE="${FRONTEND_DIR}/openapi.json"
echo "📋 Validating OpenAPI schema types (local openapi.json)..."
elif curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
OPENAPI_SOURCE="${BACKEND_URL}/api/openapi.json"
echo "📋 Validating OpenAPI schema types (backend ${BACKEND_URL})..."
else
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json and no local openapi.json found" >&2
exit 2
fi
echo "📋 Validating OpenAPI schema types..."
# Generate types to a temporary file
if ! npx openapi-typescript "${BACKEND_URL}/api/openapi.json" -o "$TEMP_FILE" 2>&1; then
if ! npx openapi-typescript "${OPENAPI_SOURCE}" -o "$TEMP_FILE" 2>&1; then
echo "❌ Failed to generate types from OpenAPI schema" >&2
exit 3
fi

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "../ErrorBoundary";
import * as telemetry from "../../utils/telemetry";
// Mock telemetry to verify it's called
vi.mock("../../utils/telemetry");

View File

@@ -468,13 +468,10 @@ describe("useFetchData", () => {
});
it("last subscriber abort cancels underlying request", async () => {
let resolveFirst: ((value: { value: string }) => void) | null = null;
const abortSignals: AbortSignal[] = [];
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
abortSignals.push(signal);
return new Promise((resolve) => {
resolveFirst = resolve;
});
return new Promise(() => {});
});
const selector = vi.fn((response: { value: string }) => response.value);

View File

@@ -10,7 +10,7 @@ describe("useJailBannedIps", () => {
const fetchMock = vi.mocked(api.fetchJailBannedIps);
const unbanMock = vi.mocked(api.unbanIp);
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25 });
fetchMock.mockResolvedValue({ items: [{ ip: "1.2.3.4", jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US" }], total: 1, page: 1, page_size: 25, total_pages: 1, pagination_mode: "offset" });
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true });
const { result } = renderHook(() => useJailBannedIps("sshd"));

View File

@@ -34,8 +34,6 @@ describe("usePolledData", () => {
vi.runAllTimersAsync();
});
const callCountAfterInitial = fetcher.mock.calls.length;
// Reset timer and advance to ensure no more polls
vi.clearAllTimers();
fetcher.mockClear();
@@ -66,8 +64,6 @@ describe("usePolledData", () => {
vi.advanceTimersByTime(100);
});
const initialCalls = fetcher.mock.calls.length;
// Clear for clean test
fetcher.mockClear();
@@ -135,7 +131,6 @@ describe("usePolledData", () => {
vi.advanceTimersByTime(100);
});
const initialCalls = fetcher.mock.calls.length;
fetcher.mockClear();
// Call refresh

View File

@@ -77,11 +77,34 @@ export function usePolledData<TResponse, TData>(
pauseWhenHidden = false,
} = options;
// Stabilize fetcher/selector/onSuccess references so that useFetchData's
// refresh callback (and the useEffect that calls it) don't re-trigger on
// every render when callers pass inline functions.
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const selectorRef = useRef(selector);
selectorRef.current = selector;
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
const stableFetcher = useCallback(
(signal: AbortSignal) => fetcherRef.current(signal),
[],
);
const stableSelector = useCallback(
(response: TResponse) => selectorRef.current(response),
[],
);
const stableOnSuccess = useCallback(
(response: TResponse) => onSuccessRef.current?.(response),
[],
);
const { data, loading, error, refresh } = useFetchData({
fetcher,
selector,
fetcher: stableFetcher,
selector: stableSelector,
errorMessage,
onSuccess,
onSuccess: onSuccessRef.current ? stableOnSuccess : undefined,
initialData,
});
@@ -151,15 +174,10 @@ export function usePolledData<TResponse, TData>(
return;
}
// Record when polling starts and schedule first poll immediately
// Record when polling starts. The initial fetch is handled by useFetchData's
// mount effect, so we just mark the start time and let the loading-completion
// effect (above) schedule the first poll after the initial fetch finishes.
pollStartTimeRef.current = performance.now();
const id = window.setTimeout((): void => {
if (cancelledRef.current) return;
pollStartTimeRef.current = performance.now();
refreshRef.current?.();
}, 0);
timeoutIdRef.current = id;
return (): void => {
cancelledRef.current = true;

View File

@@ -56,7 +56,7 @@ import React, {
} from "react";
import { useNavigate } from "react-router-dom";
import * as authApi from "../api/auth";
import { setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
import { ApiError, setUnauthorizedHandler, resetLogoutState, clearSessionCorrelationId } from "../api/client";
import { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
import { SessionValidationLoading } from "../components/SessionValidationLoading";
@@ -133,6 +133,11 @@ export function AuthProvider({
const handleValidationError = useCallback(
(error: Error): void => {
// Suppress noisy warning for 5xx gateway errors (e.g. 502 Bad Gateway)
// during startup — these are server-side issues, not network issues.
if (error instanceof ApiError && error.status >= 500) {
return;
}
// Network error — log but don't logout.
console.warn("Session validation network error:", error);
},

View File

@@ -177,11 +177,6 @@ export interface paths {
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
* cookie so the browser SPA benefits from automatic credential handling.
*
* Rate limiting: Exponential backoff on failed attempts. Each wrong password
* incurs an increasing delay (0.5s, 1s, 2s, 4s, 5s max per IP address).
* Requests during the penalty period return ``429 Too Many Requests`` with
* a ``Retry-After`` header.
*
* Cache invalidation: On successful login, any existing cached sessions for
* the same user are invalidated so that stale tokens (e.g., from a stolen
* device) cannot be reused beyond the cache TTL window.
@@ -192,7 +187,6 @@ export interface paths {
* request: The incoming HTTP request (used to extract client IP).
* session_ctx: Session service context containing db and repository.
* settings: Application settings (used for session duration and trusted proxies).
* rate_limiter: The login rate limiter (per IP).
* session_cache: Session cache for invalidating old sessions on login.
*
* Returns:
@@ -200,7 +194,6 @@ export interface paths {
*
* Raises:
* AuthenticationError: if the password is incorrect.
* RateLimitError: if the rate limit is exceeded.
*/
post: operations["login_api_v1_auth_login_post"];
delete?: never;
@@ -6274,13 +6267,6 @@ export interface operations {
};
content?: never;
};
/** @description Too many login attempts, retry after delay */
429: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Setup not complete */
503: {
headers: {