Compare commits
8 Commits
77df5d5d65
...
v0.9.19-rc
| Author | SHA1 | Date | |
|---|---|---|---|
| 99e1b74405 | |||
| 9fe52755a5 | |||
| 9d2d6fadf3 | |||
| 2e5ac092bf | |||
| dcee222a41 | |||
| 12fe70d768 | |||
| 83b2cb67b1 | |||
| 7308ff88d6 |
@@ -18,7 +18,7 @@ WORKDIR /build
|
|||||||
COPY frontend/package.json frontend/package-lock.json* /build/
|
COPY frontend/package.json frontend/package-lock.json* /build/
|
||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
# Copy source and build
|
# Copy source + local OpenAPI spec (avoids needing a running backend during build)
|
||||||
COPY frontend/ /build/
|
COPY frontend/ /build/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v0.9.19
|
v0.9.19-rc.4
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# ./release.sh
|
# ./release.sh
|
||||||
#
|
#
|
||||||
# The current version is stored in VERSION (next to this script).
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -24,24 +24,60 @@ CURRENT="$(cat "${VERSION_FILE}")"
|
|||||||
# Strip leading 'v' for arithmetic
|
# Strip leading 'v' for arithmetic
|
||||||
VERSION="${CURRENT#v}"
|
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 "============================================"
|
||||||
echo " BanGUI — Release"
|
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 ""
|
echo ""
|
||||||
echo "How would you like to bump the version?"
|
echo "How would you like to bump the version?"
|
||||||
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
if [[ -n "${RC_SUFFIX}" ]]; then
|
||||||
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH}-rc.${RC_NUM} → v${MAJOR}.${MINOR}.${PATCH})"
|
||||||
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
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 ""
|
echo ""
|
||||||
read -rp "Enter choice [1/2/3]: " CHOICE
|
read -rp "Enter choice [1/2/3/4]: " CHOICE
|
||||||
|
|
||||||
case "${CHOICE}" in
|
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" ;;
|
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||||
3) NEW_TAG="v$((MAJOR + 1)).0.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
|
echo "Invalid choice. Aborting." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -81,7 +117,13 @@ fi
|
|||||||
# Push containers
|
# Push containers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -102,10 +102,15 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Ordered list of DDL statements to execute on initialisation.
|
# 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] = [
|
_SCHEMA_STATEMENTS: list[str] = [
|
||||||
_CREATE_SETTINGS,
|
_CREATE_SETTINGS,
|
||||||
_CREATE_SESSIONS,
|
_CREATE_SESSIONS,
|
||||||
_CREATE_SESSIONS_TOKEN_INDEX,
|
|
||||||
_CREATE_BLOCKLIST_SOURCES,
|
_CREATE_BLOCKLIST_SOURCES,
|
||||||
_CREATE_IMPORT_LOG,
|
_CREATE_IMPORT_LOG,
|
||||||
_CREATE_GEO_CACHE,
|
_CREATE_GEO_CACHE,
|
||||||
@@ -133,8 +138,24 @@ CREATE UNIQUE INDEX idx_sessions_token_hash ON sessions (token_hash);
|
|||||||
3: """
|
3: """
|
||||||
-- Migration 3: Add last_seen timestamp to geo_cache for retention policy.
|
-- 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.
|
-- Tracks when each IP was last referenced to enable purging of stale entries.
|
||||||
-- Default to current timestamp for existing rows.
|
-- SQLite rejects ALTER TABLE ADD COLUMN with a non-constant NOT NULL default
|
||||||
ALTER TABLE geo_cache ADD COLUMN last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
-- 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: """
|
4: """
|
||||||
-- Migration 4: Add scheduler_lock table for multi-worker safety.
|
-- Migration 4: Add scheduler_lock table for multi-worker safety.
|
||||||
|
|||||||
@@ -242,9 +242,9 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# deployments, it should be replaced with a shared backend.
|
# deployments, it should be replaced with a shared backend.
|
||||||
_update_session_cache(app, settings)
|
_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.
|
# 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")
|
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
|
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
|
||||||
else NoOpSessionCache()
|
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
|
# This is also re-initialized in the lifespan, but must be present here
|
||||||
# for tests that bypass the lifespan via ASGITransport.
|
# 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)
|
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(CsrfMiddleware)
|
||||||
app.add_middleware(DeprecationHeaderMiddleware)
|
app.add_middleware(DeprecationHeaderMiddleware)
|
||||||
# Auth endpoints (login, setup) need a dedicated higher-rate bucket to avoid
|
# Auth endpoints (login, setup) need a dedicated higher-rate bucket to avoid
|
||||||
# rate limiting when running e2e tests sequentially. Auth uses the default
|
# rate limiting when running e2e tests sequentially.
|
||||||
# global rate limiter at 200 req/min per IP.
|
|
||||||
# Auth endpoints: /api/v1/login, /api/v1/setup
|
|
||||||
# 1000 req/min per IP — generous for e2e testing.
|
# 1000 req/min per IP — generous for e2e testing.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
RateLimitMiddleware,
|
RateLimitMiddleware,
|
||||||
@@ -1146,6 +1144,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
bucket_override="auth:login",
|
bucket_override="auth:login",
|
||||||
bucket_max_requests=1000,
|
bucket_max_requests=1000,
|
||||||
bucket_window_seconds=60,
|
bucket_window_seconds=60,
|
||||||
|
path_prefixes=["/api/v1/auth/login", "/api/v1/setup"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# History endpoints get a dedicated higher-rate bucket to avoid
|
# 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_override="history:list",
|
||||||
bucket_max_requests=10000,
|
bucket_max_requests=10000,
|
||||||
bucket_window_seconds=60,
|
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.
|
# Validate middleware order before returning the app.
|
||||||
|
|||||||
@@ -34,18 +34,20 @@ unusual and potentially suspicious) always carry a correlation ID for tracing.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from app.utils.logging_compat import get_logger
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.requests import Request
|
|
||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
from app.exceptions import RateLimitError
|
from app.exceptions import RateLimitError
|
||||||
from app.utils.client_ip import get_client_ip
|
from app.utils.client_ip import get_client_ip
|
||||||
|
from app.utils.logging_compat import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.utils.rate_limiter import GlobalRateLimiter
|
from app.utils.rate_limiter import GlobalRateLimiter
|
||||||
|
|
||||||
@@ -53,11 +55,15 @@ log = get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
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.
|
Tracks requests per IP and blocks further requests if the limit is exceeded.
|
||||||
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
|
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
|
||||||
for consistent IP extraction.
|
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__(
|
def __init__(
|
||||||
@@ -68,6 +74,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
bucket_override: str | None = None,
|
bucket_override: str | None = None,
|
||||||
bucket_max_requests: int | None = None,
|
bucket_max_requests: int | None = None,
|
||||||
bucket_window_seconds: int | None = None,
|
bucket_window_seconds: int | None = None,
|
||||||
|
path_prefixes: list[str] | None = None,
|
||||||
|
skip_paths: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the rate limit middleware.
|
"""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_override: Optional named bucket to use instead of the default limiter.
|
||||||
bucket_max_requests: Max requests for the bucket override.
|
bucket_max_requests: Max requests for the bucket override.
|
||||||
bucket_window_seconds: Window 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]
|
super().__init__(app) # type: ignore[arg-type]
|
||||||
self.rate_limiter: GlobalRateLimiter = rate_limiter
|
self.rate_limiter: GlobalRateLimiter = rate_limiter
|
||||||
@@ -85,6 +99,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self.bucket_override = bucket_override
|
self.bucket_override = bucket_override
|
||||||
self.bucket_max_requests = bucket_max_requests
|
self.bucket_max_requests = bucket_max_requests
|
||||||
self.bucket_window_seconds = bucket_window_seconds
|
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(
|
async def dispatch(
|
||||||
self,
|
self,
|
||||||
@@ -103,37 +134,28 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
Returns:
|
Returns:
|
||||||
A response object (either rate limit response or from handler).
|
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
|
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 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(
|
||||||
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
|
self.bucket_override,
|
||||||
self.bucket_override,
|
client_ip,
|
||||||
client_ip,
|
self.bucket_max_requests,
|
||||||
self.bucket_max_requests,
|
self.bucket_window_seconds,
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
|
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
|
||||||
|
|
||||||
if not is_allowed:
|
if not is_allowed:
|
||||||
log.warning(
|
log.warning(
|
||||||
"global_rate_limit_exceeded",
|
"global_rate_limit_exceeded",
|
||||||
client_ip=client_ip,
|
client_ip=client_ip,
|
||||||
path=request.url.path,
|
path=path,
|
||||||
method=request.method,
|
method=request.method,
|
||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
)
|
)
|
||||||
@@ -141,7 +163,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
"Too many requests. Please try again later.",
|
"Too many requests. Please try again later.",
|
||||||
retry_after_seconds=retry_after,
|
retry_after_seconds=retry_after,
|
||||||
)
|
)
|
||||||
# Return the error response directly
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
content={
|
content={
|
||||||
@@ -153,6 +174,5 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
headers={"Retry-After": str(int(retry_after))},
|
headers={"Retry-After": str(int(retry_after))},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Request is allowed, continue to next handler
|
|
||||||
response: Response = await call_next(request)
|
response: Response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bangui-backend"
|
name = "bangui-backend"
|
||||||
version = "0.9.19"
|
version = "0.9.19-rc.3"
|
||||||
description = "BanGUI backend — fail2ban web management interface"
|
description = "BanGUI backend — fail2ban web management interface"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -134,24 +134,17 @@ class TestRateLimitMiddleware:
|
|||||||
"""Global rate limit should block requests exceeding per-IP limit."""
|
"""Global rate limit should block requests exceeding per-IP limit."""
|
||||||
await _do_setup(client)
|
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 = client._transport.app.state.global_rate_limiter
|
||||||
limiter.reset()
|
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
|
original_max = limiter.max_requests
|
||||||
limiter.max_requests = 7
|
limiter.max_requests = 3
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First 3 requests should succeed
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 200, f"Request {i + 1} failed"
|
assert response.status_code == 200, f"Request {i + 1} failed"
|
||||||
|
|
||||||
# Fourth request should be rate limited
|
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
assert response.json()["code"] == "rate_limit_exceeded"
|
assert response.json()["code"] == "rate_limit_exceeded"
|
||||||
@@ -166,22 +159,47 @@ class TestRateLimitMiddleware:
|
|||||||
limiter = client._transport.app.state.global_rate_limiter
|
limiter = client._transport.app.state.global_rate_limiter
|
||||||
limiter.reset()
|
limiter.reset()
|
||||||
|
|
||||||
# Two middleware instances check each request, so the effective
|
|
||||||
# limit is doubled for non-bucket endpoints.
|
|
||||||
original_max = limiter.max_requests
|
original_max = limiter.max_requests
|
||||||
limiter.max_requests = 3
|
limiter.max_requests = 2
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First request succeeds
|
|
||||||
response = await client.get("/api/v1/health")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 200
|
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")
|
response = await client.get("/api/v1/health")
|
||||||
assert response.status_code == 429
|
assert response.status_code == 429
|
||||||
assert "Retry-After" in response.headers
|
assert "Retry-After" in response.headers
|
||||||
retry_after = int(response.headers["Retry-After"])
|
retry_after = int(response.headers["Retry-After"])
|
||||||
assert retry_after > 0
|
assert retry_after > 0
|
||||||
assert retry_after <= 60 # Should be less than window
|
assert retry_after <= 60
|
||||||
finally:
|
finally:
|
||||||
limiter.max_requests = original_max
|
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
|
||||||
|
|||||||
10343
frontend/openapi.json
Normal file
10343
frontend/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.19",
|
"version": "0.9.19-rc.4",
|
||||||
"description": "BanGUI frontend — fail2ban web management interface",
|
"description": "BanGUI frontend — fail2ban web management interface",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"validate:types": "bash scripts/validate-types.sh",
|
||||||
"build": "npm run generate:types && tsc --noEmit && vite build",
|
"build": "npm run generate:types && tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
@@ -17,17 +17,23 @@ GENERATED_FILE="${TYPES_DIR}/generated.ts"
|
|||||||
TEMP_FILE=$(mktemp)
|
TEMP_FILE=$(mktemp)
|
||||||
trap "rm -f $TEMP_FILE" EXIT
|
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}"
|
BACKEND_URL="${BANGUI_BACKEND_URL:-http://localhost:8000}"
|
||||||
if ! curl -sf "${BACKEND_URL}/api/openapi.json" > /dev/null 2>&1; then
|
OPENAPI_SOURCE=""
|
||||||
echo "❌ Backend not accessible at ${BACKEND_URL}/api/openapi.json" >&2
|
|
||||||
|
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
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📋 Validating OpenAPI schema types..."
|
|
||||||
|
|
||||||
# Generate types to a temporary file
|
# 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
|
echo "❌ Failed to generate types from OpenAPI schema" >&2
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { ErrorBoundary } from "../ErrorBoundary";
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
import * as telemetry from "../../utils/telemetry";
|
|
||||||
|
|
||||||
// Mock telemetry to verify it's called
|
// Mock telemetry to verify it's called
|
||||||
vi.mock("../../utils/telemetry");
|
vi.mock("../../utils/telemetry");
|
||||||
|
|||||||
@@ -468,13 +468,10 @@ describe("useFetchData", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("last subscriber abort cancels underlying request", async () => {
|
it("last subscriber abort cancels underlying request", async () => {
|
||||||
let resolveFirst: ((value: { value: string }) => void) | null = null;
|
|
||||||
const abortSignals: AbortSignal[] = [];
|
const abortSignals: AbortSignal[] = [];
|
||||||
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
|
const fetcher = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||||
abortSignals.push(signal);
|
abortSignals.push(signal);
|
||||||
return new Promise((resolve) => {
|
return new Promise(() => {});
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const selector = vi.fn((response: { value: string }) => response.value);
|
const selector = vi.fn((response: { value: string }) => response.value);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("useJailBannedIps", () => {
|
|||||||
const fetchMock = vi.mocked(api.fetchJailBannedIps);
|
const fetchMock = vi.mocked(api.fetchJailBannedIps);
|
||||||
const unbanMock = vi.mocked(api.unbanIp);
|
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 });
|
unbanMock.mockResolvedValue({ message: "ok", jail: "sshd", success: true });
|
||||||
|
|
||||||
const { result } = renderHook(() => useJailBannedIps("sshd"));
|
const { result } = renderHook(() => useJailBannedIps("sshd"));
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ describe("usePolledData", () => {
|
|||||||
vi.runAllTimersAsync();
|
vi.runAllTimersAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
const callCountAfterInitial = fetcher.mock.calls.length;
|
|
||||||
|
|
||||||
// Reset timer and advance to ensure no more polls
|
// Reset timer and advance to ensure no more polls
|
||||||
vi.clearAllTimers();
|
vi.clearAllTimers();
|
||||||
fetcher.mockClear();
|
fetcher.mockClear();
|
||||||
@@ -66,8 +64,6 @@ describe("usePolledData", () => {
|
|||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialCalls = fetcher.mock.calls.length;
|
|
||||||
|
|
||||||
// Clear for clean test
|
// Clear for clean test
|
||||||
fetcher.mockClear();
|
fetcher.mockClear();
|
||||||
|
|
||||||
@@ -135,7 +131,6 @@ describe("usePolledData", () => {
|
|||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialCalls = fetcher.mock.calls.length;
|
|
||||||
fetcher.mockClear();
|
fetcher.mockClear();
|
||||||
|
|
||||||
// Call refresh
|
// Call refresh
|
||||||
|
|||||||
@@ -77,11 +77,34 @@ export function usePolledData<TResponse, TData>(
|
|||||||
pauseWhenHidden = false,
|
pauseWhenHidden = false,
|
||||||
} = options;
|
} = 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({
|
const { data, loading, error, refresh } = useFetchData({
|
||||||
fetcher,
|
fetcher: stableFetcher,
|
||||||
selector,
|
selector: stableSelector,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onSuccess,
|
onSuccess: onSuccessRef.current ? stableOnSuccess : undefined,
|
||||||
initialData,
|
initialData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,15 +174,10 @@ export function usePolledData<TResponse, TData>(
|
|||||||
return;
|
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();
|
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 => {
|
return (): void => {
|
||||||
cancelledRef.current = true;
|
cancelledRef.current = true;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import * as authApi from "../api/auth";
|
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 { setAuthErrorHandler, resetLogoutState as resetFetchErrorLogoutState } from "../utils/fetchError";
|
||||||
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
|
import { STORAGE_KEY_AUTHENTICATED } from "../utils/constants";
|
||||||
import { SessionValidationLoading } from "../components/SessionValidationLoading";
|
import { SessionValidationLoading } from "../components/SessionValidationLoading";
|
||||||
@@ -133,6 +133,11 @@ export function AuthProvider({
|
|||||||
|
|
||||||
const handleValidationError = useCallback(
|
const handleValidationError = useCallback(
|
||||||
(error: Error): void => {
|
(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.
|
// Network error — log but don't logout.
|
||||||
console.warn("Session validation network error:", error);
|
console.warn("Session validation network error:", error);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,11 +177,6 @@ export interface paths {
|
|||||||
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
* On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
|
||||||
* cookie so the browser SPA benefits from automatic credential handling.
|
* 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
|
* Cache invalidation: On successful login, any existing cached sessions for
|
||||||
* the same user are invalidated so that stale tokens (e.g., from a stolen
|
* the same user are invalidated so that stale tokens (e.g., from a stolen
|
||||||
* device) cannot be reused beyond the cache TTL window.
|
* 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).
|
* request: The incoming HTTP request (used to extract client IP).
|
||||||
* session_ctx: Session service context containing db and repository.
|
* session_ctx: Session service context containing db and repository.
|
||||||
* settings: Application settings (used for session duration and trusted proxies).
|
* 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.
|
* session_cache: Session cache for invalidating old sessions on login.
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
@@ -200,7 +194,6 @@ export interface paths {
|
|||||||
*
|
*
|
||||||
* Raises:
|
* Raises:
|
||||||
* AuthenticationError: if the password is incorrect.
|
* AuthenticationError: if the password is incorrect.
|
||||||
* RateLimitError: if the rate limit is exceeded.
|
|
||||||
*/
|
*/
|
||||||
post: operations["login_api_v1_auth_login_post"];
|
post: operations["login_api_v1_auth_login_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -6274,13 +6267,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
/** @description Too many login attempts, retry after delay */
|
|
||||||
429: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
/** @description Setup not complete */
|
/** @description Setup not complete */
|
||||||
503: {
|
503: {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user