From 12fe70d76842b28f7bf69bea727a7c54f260a9e7 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 22 May 2026 20:36:14 +0200 Subject: [PATCH 01/14] 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 --- Docker/Dockerfile.frontend | 2 +- Docker/VERSION | 2 +- Docker/release.sh | 60 +- backend/pyproject.toml | 2 +- frontend/openapi.json | 10343 ++++++++++++++++ frontend/package.json | 4 +- frontend/scripts/validate-types.sh | 18 +- .../__tests__/ErrorBoundary.test.tsx | 1 - .../src/hooks/__tests__/useFetchData.test.ts | 5 +- .../hooks/__tests__/useJailBannedIps.test.ts | 2 +- .../src/hooks/__tests__/usePolledData.test.ts | 5 - frontend/src/types/generated.ts | 14 - 12 files changed, 10413 insertions(+), 45 deletions(-) create mode 100644 frontend/openapi.json diff --git a/Docker/Dockerfile.frontend b/Docker/Dockerfile.frontend index bb24ecf..d821e95 100644 --- a/Docker/Dockerfile.frontend +++ b/Docker/Dockerfile.frontend @@ -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 diff --git a/Docker/VERSION b/Docker/VERSION index 68eda73..3c05eb4 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.19 +v0.9.19-rc.1 diff --git a/Docker/release.sh b/Docker/release.sh index 8ca2d23..42266f0 100644 --- a/Docker/release.sh +++ b/Docker/release.sh @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5cc2033..5b268d5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bangui-backend" -version = "0.9.19" +version = "0.9.19-rc.1" description = "BanGUI backend — fail2ban web management interface" requires-python = ">=3.12" dependencies = [ diff --git a/frontend/openapi.json b/frontend/openapi.json new file mode 100644 index 0000000..c2c8eab --- /dev/null +++ b/frontend/openapi.json @@ -0,0 +1,10343 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "BanGUI", + "description": "Web interface for monitoring, managing, and configuring fail2ban.", + "version": "0.9.19-rc.1" + }, + "paths": { + "/api/v1/health": { + "get": { + "tags": [ + "Health" + ], + "summary": "Application health check", + "description": "Return application and component status.\n\nPerforms lightweight checks on key application components and returns\nHTTP 200 if all healthy, HTTP 503 if fail2ban is offline.\n\nDocker/orchestration health checks interpret 503 as unhealthy and restart\nthe container if fail2ban remains unreachable.\n\nArgs:\n app_state: Injected application state containing runtime components.\n server_status: Injected cached server status snapshot.\n\nReturns:\n HTTP 200 with :class:`~app.models.response.HealthResponse` when healthy,\n HTTP 503 with :class:`~app.models.response.HealthResponse` when fail2ban\n is offline.", + "operationId": "health_check_api_v1_health_get", + "responses": { + "200": { + "description": "All components healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + }, + "503": { + "description": "fail2ban offline or component degraded" + } + } + } + }, + "/api/v1/health/live": { + "get": { + "tags": [ + "Health" + ], + "summary": "Process liveness probe", + "description": "Lightweight liveness check for Kubernetes.\n\nReturns 200 when the Python process and event loop are responsive.\nA non-2xx response tells Kubernetes to restart the container.\nNo subsystem checks are performed \u2014 this endpoint must be fast.", + "operationId": "liveness_probe_api_v1_health_live_get", + "responses": { + "200": { + "description": "Process is alive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadyResponse" + } + } + } + } + } + } + }, + "/api/v1/health/ready": { + "get": { + "tags": [ + "Health" + ], + "summary": "Subsystem readiness probe", + "description": "Readiness check for Kubernetes.\n\nVerifies all critical sub-systems are reachable:\n- Database connectivity\n- fail2ban socket (via cached server status)\n- Config directory read access\n- Background scheduler liveness\n\nReturns HTTP 200 only when every check passes; returns HTTP 503 with a\nJSON body listing every failed subsystem otherwise. Each check has a\nshort per-subsystem timeout to prevent the endpoint from overwhelming the\nsystem under load.", + "operationId": "readiness_probe_api_v1_health_ready_get", + "responses": { + "200": { + "description": "All subsystems healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadyResponse" + } + } + } + }, + "503": { + "description": "One or more subsystems unreachable" + } + } + } + }, + "/api/v1/setup": { + "get": { + "tags": [ + "setup" + ], + "summary": "Check whether setup has been completed", + "description": "Return whether the initial setup wizard has been completed.\n\nReturns:\n :class:`~app.models.setup.SetupStatusResponse` with ``completed``\n set to ``True`` if setup is done, ``False`` otherwise.", + "operationId": "get_setup_status_api_v1_setup_get", + "responses": { + "200": { + "description": "Setup status returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupStatusResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "setup" + ], + "summary": "Run the initial setup wizard", + "description": "Persist the initial BanGUI configuration.\n\nArgs:\n app: The FastAPI application instance.\n body: Setup request payload validated by Pydantic.\n settings_ctx: Settings service context containing db and repository.\n\nReturns:\n :class:`~app.models.setup.SetupResponse` on success.\n\nRaises:\n SetupAlreadyCompleteError: if setup has already been completed.", + "operationId": "post_setup_api_v1_setup_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Setup completed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupResponse" + } + } + } + }, + "400": { + "description": "Validation error in request body" + }, + "409": { + "description": "Setup already completed" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/setup/timezone": { + "get": { + "tags": [ + "setup" + ], + "summary": "Return the configured IANA timezone", + "description": "Return the IANA timezone configured during the initial setup wizard.\n\nThe frontend uses this to convert UTC timestamps to the local time zone\nchosen by the administrator.\n\nReturns:\n :class:`~app.models.setup.SetupTimezoneResponse` with ``timezone``\n set to the stored IANA identifier (e.g. ``\"UTC\"`` or\n ``\"Europe/Berlin\"``), defaulting to ``\"UTC\"`` if unset.", + "operationId": "get_timezone_api_v1_setup_timezone_get", + "responses": { + "200": { + "description": "Timezone returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupTimezoneResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Authenticate with the master password", + "description": "Verify the master password and return a session token.\n\nOn success the token is also set as an ``HttpOnly`` ``SameSite=Lax``\ncookie so the browser SPA benefits from automatic credential handling.\n\nCache invalidation: On successful login, any existing cached sessions for\nthe same user are invalidated so that stale tokens (e.g., from a stolen\ndevice) cannot be reused beyond the cache TTL window.\n\nArgs:\n body: Login request validated by Pydantic.\n response: FastAPI response object used to set the cookie.\n request: The incoming HTTP request (used to extract client IP).\n session_ctx: Session service context containing db and repository.\n settings: Application settings (used for session duration and trusted proxies).\n session_cache: Session cache for invalidating old sessions on login.\n\nReturns:\n :class:`~app.models.auth.LoginResponse` containing the token.\n\nRaises:\n AuthenticationError: if the password is incorrect.", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "Invalid password" + }, + "422": { + "description": "Validation error \u2014 invalid request body" + }, + "503": { + "description": "Setup not complete" + } + } + } + }, + "/api/v1/auth/session": { + "get": { + "tags": [ + "auth" + ], + "summary": "Validate the current session", + "description": "Validate the current session.\n\nThis endpoint requires a valid session and returns 200 if the session is\nvalid and still active. If the session is invalid, expired, or missing,\nFastAPI's ``require_auth`` dependency returns 401 automatically.\n\nThe frontend calls this on mount to bootstrap its authentication state\nfrom the backend rather than relying solely on cached ``sessionStorage``.\n\nArgs:\n _: The injected session object (unused, but its presence triggers validation).\n\nReturns:\n :class:`~app.models.auth.SessionValidResponse` confirming the session state.", + "operationId": "validate_session_api_v1_auth_session_get", + "responses": { + "200": { + "description": "Session valid", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionValidResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Revoke the current session", + "description": "Invalidate the active session.\n\nThe session token is read from the ``bangui_session`` cookie or the\n``Authorization: Bearer`` header. If no token is present the request\nis silently treated as a successful logout (idempotent).\n\nArgs:\n request: FastAPI request (used to extract the token).\n response: FastAPI response (used to clear the cookie).\n session_ctx: Session service context containing db and repository.\n settings: Application settings (used to unwrap signed tokens).\n session_cache: Session cache for invalidation.\n\nReturns:\n :class:`~app.models.auth.LogoutResponse`.", + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "200": { + "description": "Logout successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + } + }, + "401": { + "description": "Session missing or invalid (silently successful)" + } + } + } + }, + "/api/v1/dashboard/status": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Return the cached fail2ban server status", + "description": "Return the most recent fail2ban health snapshot.\n\nThe snapshot is populated by a background task that runs every 30 seconds.\nIf the task has not yet executed a placeholder ``online=False`` status is\nreturned so the response is always well-formed.\n\nArgs:\n server_status: Cached fail2ban server health snapshot (injected).\n _auth: Validated session \u2014 enforces authentication on this endpoint.\n\nReturns:\n :class:`~app.models.server.ServerStatusResponse` containing the\n current health snapshot.", + "operationId": "get_server_status_api_v1_dashboard_status_get", + "responses": { + "200": { + "description": "Server status returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerStatusResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/dashboard/bans": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Return a paginated list of recent bans", + "description": "Return a paginated list of bans within the selected time window.\n\nReads from the fail2ban database and enriches each entry with\ngeolocation data (country, ASN, organisation) from the ip-api.com\nfree API. Results are sorted newest-first. Geo lookups are served\nfrom the in-memory cache only; no database writes occur during this\nGET request.\n\nArgs:\n _auth: Validated session dependency.\n ban_ctx: Ban service context containing db and repository.\n socket_path: Path to fail2ban Unix domain socket.\n http_session: Shared HTTP session for geolocation.\n geo_cache: Geolocation cache instance.\n range: Time-range preset \u2014 ``\"24h\"``, ``\"7d\"``, ``\"30d\"``, or\n ``\"365d\"``.\n page: 1-based page number.\n page_size: Maximum items per page (1\u2013500).\n origin: Optional filter by ban origin.\n\nReturns:\n :class:`~app.models.ban.DashboardBanListResponse` with paginated\n ban items and the total count for the selected window.", + "operationId": "get_dashboard_bans_api_v1_dashboard_bans_get", + "parameters": [ + { + "name": "range", + "in": "query", + "required": false, + "schema": { + "enum": [ + "24h", + "7d", + "30d", + "365d" + ], + "type": "string", + "description": "Time-range preset.", + "default": "24h", + "title": "Range" + }, + "description": "Time-range preset." + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "enum": [ + "fail2ban", + "archive" + ], + "type": "string", + "description": "Data source: 'fail2ban' or 'archive'.", + "default": "fail2ban", + "title": "Source" + }, + "description": "Data source: 'fail2ban' or 'archive'." + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "1-based page number.", + "default": 1, + "title": "Page" + }, + "description": "1-based page number." + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Items per page.", + "default": 100, + "title": "Page Size" + }, + "description": "Items per page." + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "blocklist", + "selfblock" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + "title": "Origin" + }, + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all." + } + ], + "responses": { + "200": { + "description": "Ban list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardBanListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboard/bans/by-country": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Return ban counts aggregated by country", + "description": "Return ban counts aggregated by ISO country code.\n\nUses SQL aggregation (``GROUP BY ip``) and batch geo-resolution to handle\n10 000+ banned IPs efficiently. Returns a ``{country_code: count}`` map\nand the 200 most recent raw ban rows for the companion access table. Geo\nlookups are served from the in-memory cache only; no database writes occur\nduring this GET request.\n\nArgs:\n _auth: Validated session dependency.\n ban_ctx: Ban service context containing db and repository.\n socket_path: Path to fail2ban Unix domain socket.\n http_session: Shared HTTP session for geolocation.\n geo_cache: Geolocation cache instance.\n range: Time-range preset.\n origin: Optional filter by ban origin.\n\nReturns:\n :class:`~app.models.ban.BansByCountryResponse` with per-country\n aggregation and the companion ban list.", + "operationId": "get_bans_by_country_api_v1_dashboard_bans_by_country_get", + "parameters": [ + { + "name": "range", + "in": "query", + "required": false, + "schema": { + "enum": [ + "24h", + "7d", + "30d", + "365d" + ], + "type": "string", + "description": "Time-range preset.", + "default": "24h", + "title": "Range" + }, + "description": "Time-range preset." + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "enum": [ + "fail2ban", + "archive" + ], + "type": "string", + "description": "Data source: 'fail2ban' or 'archive'.", + "default": "fail2ban", + "title": "Source" + }, + "description": "Data source: 'fail2ban' or 'archive'." + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "blocklist", + "selfblock" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + "title": "Origin" + }, + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all." + }, + { + "name": "country_code", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO alpha-2 country code to filter companion rows.", + "title": "Country Code" + }, + "description": "ISO alpha-2 country code to filter companion rows." + } + ], + "responses": { + "200": { + "description": "Ban counts by country returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BansByCountryResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboard/bans/trend": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Return ban counts aggregated into time buckets", + "description": "Return ban counts grouped into equal-width time buckets.\n\nEach bucket represents a contiguous time interval within the selected\nwindow. All buckets are returned \u2014 empty buckets (zero bans) are\nincluded so the frontend always receives a complete, gap-free series\nsuitable for rendering a continuous area or line chart.\n\nBucket sizes:\n\n* ``24h`` \u2192 1-hour buckets (24 total)\n* ``7d`` \u2192 6-hour buckets (28 total)\n* ``30d`` \u2192 1-day buckets (30 total)\n* ``365d`` \u2192 7-day buckets (~53 total)\n\nArgs:\n _auth: Validated session dependency.\n ban_ctx: Ban service context containing db and repository.\n socket_path: Path to fail2ban Unix domain socket.\n range: Time-range preset.\n origin: Optional filter by ban origin.\n\nReturns:\n :class:`~app.models.ban.BanTrendResponse` with the ordered bucket\n list and the bucket-size label.", + "operationId": "get_ban_trend_api_v1_dashboard_bans_trend_get", + "parameters": [ + { + "name": "range", + "in": "query", + "required": false, + "schema": { + "enum": [ + "24h", + "7d", + "30d", + "365d" + ], + "type": "string", + "description": "Time-range preset.", + "default": "24h", + "title": "Range" + }, + "description": "Time-range preset." + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "enum": [ + "fail2ban", + "archive" + ], + "type": "string", + "description": "Data source: 'fail2ban' or 'archive'.", + "default": "fail2ban", + "title": "Source" + }, + "description": "Data source: 'fail2ban' or 'archive'." + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "blocklist", + "selfblock" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + "title": "Origin" + }, + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all." + } + ], + "responses": { + "200": { + "description": "Ban trend data returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanTrendResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboard/bans/by-jail": { + "get": { + "tags": [ + "Dashboard" + ], + "summary": "Return ban counts aggregated by jail", + "description": "Return ban counts grouped by jail name for the selected time window.\n\nQueries the fail2ban database and returns a list of jails sorted by\nban count descending. This endpoint is intended for the dashboard jail\ndistribution bar chart.\n\nArgs:\n _auth: Validated session dependency.\n ban_ctx: Ban service context containing db and repository.\n socket_path: Path to fail2ban Unix domain socket.\n range: Time-range preset \u2014 ``\"24h\"``, ``\"7d\"``, ``\"30d\"``, or\n ``\"365d\"``.\n origin: Optional filter by ban origin.\n\nReturns:\n :class:`~app.models.ban.BansByJailResponse` with per-jail counts\n sorted descending and the total for the selected window.", + "operationId": "get_bans_by_jail_api_v1_dashboard_bans_by_jail_get", + "parameters": [ + { + "name": "range", + "in": "query", + "required": false, + "schema": { + "enum": [ + "24h", + "7d", + "30d", + "365d" + ], + "type": "string", + "description": "Time-range preset.", + "default": "24h", + "title": "Range" + }, + "description": "Time-range preset." + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "enum": [ + "fail2ban", + "archive" + ], + "type": "string", + "description": "Data source: 'fail2ban' or 'archive'.", + "default": "fail2ban", + "title": "Source" + }, + "description": "Data source: 'fail2ban' or 'archive'." + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "blocklist", + "selfblock" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + "title": "Origin" + }, + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all." + } + ], + "responses": { + "200": { + "description": "Ban counts by jail returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BansByJailResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails": { + "get": { + "tags": [ + "Jails" + ], + "summary": "List all active fail2ban jails", + "description": "Return a summary of every active fail2ban jail.\n\nIncludes runtime metrics (currently banned, total bans, failures) and\nkey configuration (find time, ban time, max retries, backend, idle state)\nfor each jail.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n socket_path: Path to the fail2ban Unix domain socket.\n state: The jail service state holder.\n\nReturns:\n :class:`~app.models.jail.JailListResponse` with all active jails.", + "operationId": "get_jails_api_v1_jails_get", + "responses": { + "200": { + "description": "Jails list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/jails/{name}": { + "get": { + "tags": [ + "Jails" + ], + "summary": "Return full detail for a single jail", + "description": "Return the complete configuration and runtime state for one jail.\n\nIncludes log paths, fail regex and ignore regex patterns, date pattern,\nlog encoding, attached action names, ban-time settings, and runtime\ncounters.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n\nReturns:\n :class:`~app.models.jail.JailDetailResponse` with the full jail.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_jail_api_v1_jails__name__get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Jail detail returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailDetailResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/reload-all": { + "post": { + "tags": [ + "Jails" + ], + "summary": "Reload all fail2ban jails", + "description": "Reload every fail2ban jail to apply configuration changes.\n\nThis command instructs fail2ban to re-read its configuration for all\njails simultaneously.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the reload.\n\nRaises:\n HTTPException: 502 when fail2ban is unreachable.\n HTTPException: 409 when fail2ban reports the operation failed.", + "operationId": "reload_all_jails_api_v1_jails_reload_all_post", + "responses": { + "200": { + "description": "All jails reloaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/jails/{name}/start": { + "post": { + "tags": [ + "Jails" + ], + "summary": "Start a stopped jail", + "description": "Start a fail2ban jail that is currently stopped.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the start.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "start_jail_api_v1_jails__name__start_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Jail started", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/{name}/stop": { + "post": { + "tags": [ + "Jails" + ], + "summary": "Stop a running jail", + "description": "Stop a running fail2ban jail.\n\nThe jail will no longer monitor logs or issue new bans. Existing bans\nmay or may not be removed depending on fail2ban configuration. If the\njail is already stopped the request succeeds silently (idempotent).\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the stop.\n\nRaises:\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "stop_jail_api_v1_jails__name__stop_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Jail stopped", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/{name}/idle": { + "post": { + "tags": [ + "Jails" + ], + "summary": "Toggle idle mode for a jail", + "description": "Enable or disable idle mode for a fail2ban jail.\n\nIn idle mode the jail suspends log monitoring without fully stopping,\npreserving all existing bans.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n on: ``true`` to enable idle, ``false`` to disable.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the change.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "toggle_idle_api_v1_jails__name__idle_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "``true`` to enable idle, ``false`` to disable.", + "title": "On" + } + } + } + }, + "responses": { + "200": { + "description": "Idle mode toggled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/{name}/reload": { + "post": { + "tags": [ + "Jails" + ], + "summary": "Reload a single jail", + "description": "Reload a single fail2ban jail to pick up configuration changes.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the reload.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "reload_jail_api_v1_jails__name__reload_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Jail reloaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/{name}/ignoreip": { + "get": { + "tags": [ + "Jails" + ], + "summary": "List the ignore IPs for a jail", + "description": "Return the current ignore list (IP whitelist) for a fail2ban jail.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n\nReturns:\n List of IP addresses and CIDR networks on the ignore list.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_ignore_list_api_v1_jails__name__ignoreip_get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Ignore list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IgnoreListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "Jails" + ], + "summary": "Add an IP or network to the ignore list", + "description": "Add an IP address or CIDR network to a jail's ignore list.\n\nIPs on the ignore list are never banned by that jail, even if they\ntrigger the configured fail regex.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n body: Payload containing the IP or CIDR to add.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the addition.\n\nRaises:\n HTTPException: 400 when the IP address or network is invalid.\n HTTPException: 404 when the jail does not exist.\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "add_ignore_ip_api_v1_jails__name__ignoreip_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IgnoreIpRequest" + } + } + } + }, + "responses": { + "201": { + "description": "IP added to ignore list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "400": { + "description": "IP or network invalid" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Jails" + ], + "summary": "Remove an IP or network from the ignore list", + "description": "Remove an IP address or CIDR network from a jail's ignore list.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n body: Payload containing the IP or CIDR to remove.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the removal.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "del_ignore_ip_api_v1_jails__name__ignoreip_delete", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IgnoreIpRequest" + } + } + } + }, + "responses": { + "200": { + "description": "IP removed from ignore list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/{name}/ignoreself": { + "post": { + "tags": [ + "Jails" + ], + "summary": "Toggle the ignoreself option for a jail", + "description": "Toggle the ``ignoreself`` flag for a fail2ban jail.\n\nWhen ``ignoreself`` is enabled fail2ban automatically adds the server's\nown IP addresses to the ignore list so the host can never ban itself.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n name: Jail name.\n on: ``true`` to enable, ``false`` to disable.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the change.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 409 when fail2ban reports the operation failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "toggle_ignore_self_api_v1_jails__name__ignoreself_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "``true`` to enable ignoreself, ``false`` to disable.", + "title": "On" + } + } + } + }, + "responses": { + "200": { + "description": "ignoreself toggled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "fail2ban reports operation failed" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/jails/{name}/banned": { + "get": { + "tags": [ + "Jails" + ], + "summary": "Return paginated currently-banned IPs for a single jail", + "description": "Return a paginated list of IPs currently banned by a specific jail.\n\nThe full ban list is fetched from the fail2ban socket, filtered by the\noptional *search* substring, sliced to the requested page, and then\ngeo-enriched exclusively for that page slice.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n ban_ctx: Ban service context containing db and repository.\n name: Jail name.\n socket_path: Path to fail2ban Unix domain socket.\n http_session: Shared HTTP session for geolocation.\n geo_cache: Geolocation cache instance.\n page: 1-based page number (default 1, min 1).\n page_size: Items per page (default 100, max 100).\n search: Optional case-insensitive substring filter on the IP address.\n\nReturns:\n :class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.\n\nRaises:\n HTTPException: 400 when *page* or *page_size* are out of range.\n HTTPException: 404 when the jail does not exist.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_jail_banned_ips_api_v1_jails__name__banned_get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "1-based page number.", + "default": 1, + "title": "Page" + }, + "description": "1-based page number." + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "description": "Items per page (max 100).", + "default": 100, + "title": "Page Size" + }, + "description": "Items per page (max 100)." + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Search" + } + } + ], + "responses": { + "200": { + "description": "Banned IPs returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailBannedIpsResponse" + } + } + } + }, + "400": { + "description": "page or page_size out of range" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/bans/active": { + "get": { + "tags": [ + "Bans" + ], + "summary": "List all currently banned IPs across all jails", + "description": "Return every IP that is currently banned across all fail2ban jails.\n\nEach entry includes the jail name, ban start time, expiry time, and\nenriched geolocation data (country code).\n\nArgs:\n request: Incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n ban_ctx: Ban service context containing db and repository.\n socket_path: Path to fail2ban Unix domain socket.\n http_session: Shared HTTP session for geolocation.\n geo_cache: Geolocation cache instance.\n\nReturns:\n :class:`~app.models.ban.ActiveBanListResponse` with all active bans.\n\nRaises:\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_active_bans_api_v1_bans_active_get", + "responses": { + "200": { + "description": "Active ban list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActiveBanListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/bans": { + "post": { + "tags": [ + "Bans" + ], + "summary": "Ban an IP address in a specific jail", + "description": "Ban an IP address in the specified fail2ban jail.\n\nThe IP address is validated before the command is sent. IPv4 and\nIPv6 addresses are both accepted.\n\nArgs:\n request: Incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n body: Payload containing the IP address and target jail.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the ban.\n\nRaises:\n HTTPException: 400 when the IP address is invalid.\n HTTPException: 404 when the specified jail does not exist.\n HTTPException: 409 when fail2ban reports the ban failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "ban_ip_api_v1_bans_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BanRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "IP banned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "400": { + "description": "Invalid IP address" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "Ban command failed in fail2ban" + }, + "429": { + "description": "Rate limit exceeded for ban operations" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Bans" + ], + "summary": "Unban an IP address from one or all jails", + "description": "Unban an IP address from a specific jail or all jails.\n\nWhen ``unban_all`` is ``true`` the IP is removed from every jail using\nfail2ban's global unban command. When ``jail`` is specified only that\njail is targeted. If neither ``unban_all`` nor ``jail`` is provided the\nIP is unbanned from all jails (equivalent to ``unban_all=true``).\n\nArgs:\n request: Incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n body: Payload with the IP address, optional jail, and unban_all flag.\n\nReturns:\n :class:`~app.models.jail.JailCommandResponse` confirming the unban.\n\nRaises:\n HTTPException: 400 when the IP address is invalid.\n HTTPException: 404 when the specified jail does not exist.\n HTTPException: 409 when fail2ban reports the unban failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "unban_ip_api_v1_bans_delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnbanRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "IP unbanned successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailCommandResponse" + } + } + } + }, + "400": { + "description": "Invalid IP address" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "409": { + "description": "Unban command failed in fail2ban" + }, + "429": { + "description": "Rate limit exceeded for unban operations" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/bans/all": { + "delete": { + "tags": [ + "Bans" + ], + "summary": "Unban every currently banned IP across all jails", + "description": "Remove all active bans from every fail2ban jail in a single operation.\n\nUses fail2ban's ``unban --all`` command to atomically clear every active\nban across all jails. Returns the number of IPs that were unbanned.\n\nArgs:\n request: Incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.ban.UnbanAllResponse` with the count of\n unbanned IPs.\n\nRaises:\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "unban_all_api_v1_bans_all_delete", + "responses": { + "200": { + "description": "All bans cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnbanAllResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/geo/lookup/{ip}": { + "get": { + "tags": [ + "Geo" + ], + "summary": "Look up ban status and geo information for an IP", + "description": "Return current ban status, geo data, and network information for an IP.\n\nChecks every running fail2ban jail to determine whether the IP is\ncurrently banned, and enriches the result with country, ASN, and\norganisation data from ip-api.com.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n ip: The IP address to look up.\n\nReturns:\n :class:`~app.models.geo.IpLookupResponse` with ban status and geo data.\n\nRaises:\n HTTPException: 400 when *ip* is not a valid IP address.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "lookup_ip_api_v1_geo_lookup__ip__get", + "parameters": [ + { + "name": "ip", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "IPv4 or IPv6 address to look up.", + "title": "Ip" + }, + "description": "IPv4 or IPv6 address to look up." + } + ], + "responses": { + "200": { + "description": "IP lookup result returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IpLookupResponse" + } + } + } + }, + "400": { + "description": "Invalid IP address" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/geo/stats": { + "get": { + "tags": [ + "Geo" + ], + "summary": "Geo cache diagnostic counters", + "description": "Return diagnostic counters for the geo cache subsystem.\n\nUseful for operators and the UI to gauge geo-resolution health.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n ban_ctx: Ban service context containing db and repository.\n\nReturns:\n :class:`~app.models.geo.GeoCacheStatsResponse` with current counters.", + "operationId": "geo_stats_api_v1_geo_stats_get", + "responses": { + "200": { + "description": "Geo cache stats returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeoCacheStatsResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + } + }, + "/api/v1/geo/re-resolve": { + "post": { + "tags": [ + "Geo" + ], + "summary": "Re-resolve all IPs whose country could not be determined", + "description": "Retry geo resolution for every IP in ``geo_cache`` with a null country.\n\nClears the in-memory negative cache first so that previously failing IPs\nare immediately eligible for a new API attempt.\n\nArgs:\n _auth: Validated session \u2014 enforces authentication.\n ban_ctx: Ban service context containing db and repository.\n http_session: Shared HTTP session for geo lookups.\n\nReturns:\n A :class:`~app.models.geo.GeoReResolveResponse` with retry counts.", + "operationId": "re_resolve_geo_api_v1_geo_re_resolve_post", + "responses": { + "200": { + "description": "Re-resolve result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GeoReResolveResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + } + }, + "/api/v1/config/jails": { + "get": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "List configuration for all active jails", + "description": "Return editable configuration for every active fail2ban jail.\n\nFetches ban time, find time, max retries, regex patterns, log paths,\ndate pattern, encoding, backend, and attached actions for all jails.\n\nArgs:\n request: Incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.config.JailConfigListResponse`.", + "operationId": "get_jail_configs_api_v1_config_jails_get", + "responses": { + "200": { + "description": "Jail configs returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailConfigListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/config/jails/inactive": { + "get": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "List all inactive jails discovered in config files", + "description": "Return all jails defined in fail2ban config files that are not running.\n\nParses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the\nfail2ban merge order. Jails that fail2ban currently reports as running\nare excluded; only truly inactive entries are returned.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.config.InactiveJailListResponse`.", + "operationId": "get_inactive_jails_api_v1_config_jails_inactive_get", + "responses": { + "200": { + "description": "Inactive jail list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InactiveJailListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/config/jails/pending-recovery": { + "get": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Return active crash-recovery record if one exists", + "description": "Return the current :class:`~app.models.config.PendingRecovery` record.\n\nA non-null response means fail2ban crashed shortly after a jail activation\nand the user should be offered a rollback option. Returns ``null`` (HTTP\n200 with ``null`` body) when no recovery is pending.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n\nReturns:\n :class:`~app.models.config.PendingRecovery` or ``None``.", + "operationId": "get_pending_recovery_api_v1_config_jails_pending_recovery_get", + "responses": { + "200": { + "description": "Recovery record or null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PendingRecovery", + "anyOf": [ + { + "$ref": "#/components/schemas/PendingRecovery" + }, + { + "type": "null" + } + ], + "title": "Response Get Pending Recovery Api V1 Config Jails Pending Recovery Get" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + } + }, + "/api/v1/config/jails/{name}": { + "get": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Return configuration for a single jail", + "description": "Return the full editable configuration for one fail2ban jail.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Jail name.\n\nReturns:\n :class:`~app.models.config.JailConfigResponse`.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_jail_config_api_v1_config_jails__name__get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Jail config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailConfigResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Update jail configuration", + "description": "Update one or more configuration fields for an active fail2ban jail.\n\nRegex patterns are validated before being sent to fail2ban. An invalid\npattern returns 422 with the regex error message.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Jail name.\n body: Partial update \u2014 only non-None fields are written.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 422 when a regex pattern fails to compile.\n HTTPException: 400 when a set command is rejected.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "update_jail_config_api_v1_config_jails__name__put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailConfigUpdate" + } + } + } + }, + "responses": { + "204": { + "description": "Jail config updated successfully" + }, + "400": { + "description": "Set command rejected or invalid regex" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "422": { + "description": "Regex pattern failed to compile" + }, + "429": { + "description": "Rate limit exceeded for jail update operations" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/config/jails/{name}/logpath": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Add a log file path to an existing jail", + "description": "Register an additional log file for an existing jail to monitor.\n\nUses ``set addlogpath `` to add the path\nwithout requiring a daemon restart.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Jail name.\n body: Log path and tail/head preference.\n\nRaises:\n HTTPException: 404 when the jail does not exist.\n HTTPException: 400 when the command is rejected or path is invalid.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "add_log_path_api_v1_config_jails__name__logpath_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddLogPathRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Log path added successfully" + }, + "400": { + "description": "Command rejected or path invalid" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "429": { + "description": "Rate limit exceeded for jail create operations" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Remove a monitored log path from a jail", + "description": "Stop a jail from monitoring the specified log file.\n\nUses ``set dellogpath `` to remove the log path at runtime\nwithout requiring a daemon restart.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Jail name.\n log_path: Absolute path to the log file to remove (query parameter).\n\nRaises:\n HTTPException: 422 when the log path is outside allowed directories.\n HTTPException: 404 when the jail does not exist.\n HTTPException: 400 when the command is rejected.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "delete_log_path_api_v1_config_jails__name__logpath_delete", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + }, + { + "name": "log_path", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Absolute path of the log file to stop monitoring.", + "title": "Log Path" + }, + "description": "Absolute path of the log file to stop monitoring." + } + ], + "responses": { + "204": { + "description": "Log path removed successfully" + }, + "400": { + "description": "Command rejected" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found" + }, + "422": { + "description": "Log path outside allowed directories" + }, + "429": { + "description": "Rate limit exceeded for jail delete operations" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/config/jails/{name}/activate": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Activate an inactive jail", + "description": "Enable an inactive jail and reload fail2ban.\n\nWrites ``enabled = true`` (plus any override values from the request\nbody) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so\nthe jail starts immediately.\n\nArgs:\n app: FastAPI application instance.\n _auth: Validated session.\n config_dir: Absolute path to the fail2ban configuration directory.\n socket_path: Path to the fail2ban Unix domain socket.\n health_probe: Injectable health probe function for checking fail2ban status.\n name: Name of the jail to activate.\n body: Optional override values (bantime, findtime, maxretry, port,\n logpath).\n\nReturns:\n :class:`~app.models.config.JailActivationResponse`.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if *name* is not found in any config file.\n HTTPException: 409 if the jail is already active.\n HTTPException: 502 if fail2ban is unreachable.", + "operationId": "activate_jail_api_v1_config_jails__name__activate_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActivateJailRequest" + }, + { + "type": "null" + } + ], + "title": "Body" + } + } + } + }, + "responses": { + "200": { + "description": "Jail activated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailActivationResponse" + } + } + } + }, + "400": { + "description": "Invalid jail name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found in config files" + }, + "409": { + "description": "Jail already active" + }, + "429": { + "description": "Rate limit exceeded for jail activate operations" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/deactivate": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Deactivate an active jail", + "description": "Disable an active jail and reload fail2ban.\n\nWrites ``enabled = false`` to ``jail.d/{name}.local`` and triggers a\nfull fail2ban reload so the jail stops immediately.\n\nArgs:\n _auth: Validated session.\n config_dir: Absolute path to the fail2ban configuration directory.\n socket_path: Path to the fail2ban Unix domain socket.\n health_probe: Injectable health probe function for checking fail2ban status.\n name: Name of the jail to deactivate.\n\nReturns:\n :class:`~app.models.config.JailActivationResponse`.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if *name* is not found in any config file.\n HTTPException: 409 if the jail is already inactive.\n HTTPException: 502 if fail2ban is unreachable.", + "operationId": "deactivate_jail_api_v1_config_jails__name__deactivate_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Jail deactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailActivationResponse" + } + } + } + }, + "400": { + "description": "Invalid jail name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found in config files" + }, + "409": { + "description": "Jail already inactive" + }, + "429": { + "description": "Rate limit exceeded for jail deactivate operations" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/local": { + "delete": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Delete the jail.d override file for an inactive jail", + "description": "Remove the ``jail.d/{name}.local`` override file for an inactive jail.\n\nThis endpoint is the clean-up action for inactive jails that still carry\na ``.local`` override file (e.g. one written with ``enabled = false`` by a\nprevious deactivation). The file is deleted without modifying fail2ban's\nrunning state, since the jail is already inactive.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Name of the jail whose ``.local`` file should be removed.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if *name* is not found in any config file.\n HTTPException: 409 if the jail is currently active.\n HTTPException: 500 if the file cannot be deleted.\n HTTPException: 502 if fail2ban is unreachable.", + "operationId": "delete_jail_local_override_api_v1_config_jails__name__local_delete", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "204": { + "description": "Override file deleted successfully" + }, + "400": { + "description": "Invalid jail name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found in config files" + }, + "409": { + "description": "Jail currently active" + }, + "500": { + "description": "File cannot be deleted" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/validate": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Validate jail configuration before activation", + "description": "Run pre-activation validation checks on a jail configuration.\n\nValidates filter and action file existence, regex pattern compilation, and\nlog path existence without modifying any files or reloading fail2ban.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Jail name to validate.\n\nReturns:\n :class:`~app.models.config.JailValidationResult` with any issues found.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if *name* is not found in any config file.", + "operationId": "validate_jail_api_v1_config_jails__name__validate_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Validation result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailValidationResult" + } + } + } + }, + "400": { + "description": "Invalid jail name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found in config files" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/rollback": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Disable a bad jail config and restart fail2ban", + "description": "Disable the specified jail and attempt to restart fail2ban.\n\nWrites ``enabled = false`` to ``jail.d/{name}.local`` (works even when\nfail2ban is down \u2014 no socket is needed), then runs the configured start\ncommand and waits up to ten seconds for the daemon to come back online.\n\nOn success, clears the :class:`~app.models.config.PendingRecovery` record.\n\nArgs:\n _auth: Validated session.\n app: FastAPI application instance.\n name: Jail name to disable and roll back.\n\nReturns:\n :class:`~app.models.config.RollbackResponse`.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 500 if writing the .local override file fails.", + "operationId": "rollback_jail_api_v1_config_jails__name__rollback_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + } + ], + "responses": { + "200": { + "description": "Rollback completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollbackResponse" + } + } + } + }, + "400": { + "description": "Invalid jail name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "500": { + "description": "Failed to write .local override file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/filter": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Assign a filter to a jail", + "description": "Write ``filter = {filter_name}`` to the jail's ``.local`` config.\n\nExisting keys in the jail's ``.local`` file are preserved. If the file\ndoes not exist it is created.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Jail name.\n body: Filter to assign.\n reload: When ``true``, trigger a fail2ban reload after writing.\n\nRaises:\n HTTPException: 400 if *name* or *filter_name* contain invalid characters.\n HTTPException: 404 if the jail or filter does not exist.\n HTTPException: 500 if writing fails.", + "operationId": "assign_filter_to_jail_api_v1_config_jails__name__filter_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + }, + { + "name": "reload", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Reload fail2ban after assigning.", + "default": false, + "title": "Reload" + }, + "description": "Reload fail2ban after assigning." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignFilterRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Filter assigned successfully" + }, + "400": { + "description": "Invalid jail name or filter name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail or filter not found" + }, + "429": { + "description": "Rate limit exceeded for jail create operations" + }, + "500": { + "description": "Failed to write .local override file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/action": { + "post": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Add an action to a jail", + "description": "Append an action entry to the jail's ``.local`` config.\n\nExisting keys in the jail's ``.local`` file are preserved. If the file\ndoes not exist it is created. The action is not duplicated if it is\nalready present.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Jail name.\n body: Action to add plus optional per-jail parameters.\n reload: When ``true``, trigger a fail2ban reload after writing.\n\nRaises:\n HTTPException: 400 if *name* or *action_name* contain invalid characters.\n HTTPException: 404 if the jail or action does not exist.\n HTTPException: 500 if writing fails.", + "operationId": "assign_action_to_jail_api_v1_config_jails__name__action_post", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + }, + { + "name": "reload", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Reload fail2ban after assigning.", + "default": false, + "title": "Reload" + }, + "description": "Reload fail2ban after assigning." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignActionRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Action added successfully" + }, + "400": { + "description": "Invalid jail name or action name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail or action not found" + }, + "429": { + "description": "Rate limit exceeded for jail create operations" + }, + "500": { + "description": "Failed to write .local override file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jails/{name}/action/{action_name}": { + "delete": { + "tags": [ + "Config", + "Jail Config" + ], + "summary": "Remove an action from a jail", + "description": "Remove an action from the jail's ``.local`` config.\n\nIf the jail has no ``.local`` file or the action is not listed there,\nthe call is silently idempotent.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Jail name.\n action_name: Base name of the action to remove.\n reload: When ``true``, trigger a fail2ban reload after writing.\n\nRaises:\n HTTPException: 400 if *name* or *action_name* contain invalid characters.\n HTTPException: 404 if the jail is not found in config files.\n HTTPException: 500 if writing fails.", + "operationId": "remove_action_from_jail_api_v1_config_jails__name__action__action_name__delete", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Jail name as configured in fail2ban.", + "title": "Name" + }, + "description": "Jail name as configured in fail2ban." + }, + { + "name": "action_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Action base name to remove.", + "title": "Action Name" + }, + "description": "Action base name to remove." + }, + { + "name": "reload", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Reload fail2ban after removing.", + "default": false, + "title": "Reload" + }, + "description": "Reload fail2ban after removing." + } + ], + "responses": { + "204": { + "description": "Action removed successfully" + }, + "400": { + "description": "Invalid jail name or action name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail not found in config files" + }, + "429": { + "description": "Rate limit exceeded for jail delete operations" + }, + "500": { + "description": "Failed to write .local override file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/filters": { + "get": { + "tags": [ + "Config", + "Filter Config" + ], + "summary": "List all available filters with active/inactive status", + "description": "Return all filters discovered in ``filter.d/`` with active/inactive status.\n\nScans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any\ncorresponding ``.local`` overrides, and cross-references each filter's\nname against the ``filter`` fields of currently running jails to determine\nwhether it is active.\n\nActive filters (those used by at least one running jail) are sorted to the\ntop of the list; inactive filters follow. Both groups are sorted\nalphabetically within themselves.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.config.FilterListResponse` with all discovered\n filters.", + "operationId": "list_filters_api_v1_config_filters_get", + "responses": { + "200": { + "description": "Filter list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + }, + "post": { + "tags": [ + "Config", + "Filter Config" + ], + "summary": "Create a new user-defined filter", + "description": "Create a new user-defined filter at ``filter.d/{name}.local``.\n\nThe filter is created as a ``.local`` file so it can coexist safely with\nshipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for\nthe requested name already exists.\n\nAll regex patterns are validated before writing. Validation includes:\n\n- **Length limit**: Patterns must not exceed 1000 characters (prevents DoS)\n- **Compilation timeout**: Pattern compilation must complete within 2 seconds\n (prevents ReDoS attacks via catastrophic backtracking)\n- **Syntax validation**: Patterns must be valid Python regex\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n body: Filter name and ``[Definition]`` fields.\n reload: When ``true``, trigger a fail2ban reload after creating.\n\nReturns:\n :class:`~app.models.config.FilterConfig` for the new filter.\n\nRaises:\n HTTPException: 400 if the name contains invalid characters.\n HTTPException: 400 if any regex pattern exceeds 1000 characters.\n HTTPException: 400 if any regex pattern times out during compilation (ReDoS).\n HTTPException: 409 if the filter already exists.\n HTTPException: 422 if any regex pattern is invalid.\n HTTPException: 500 if writing fails.", + "operationId": "create_filter_api_v1_config_filters_post", + "parameters": [ + { + "name": "reload", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Reload fail2ban after creating.", + "default": false, + "title": "Reload" + }, + "description": "Reload fail2ban after creating." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Filter created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterConfig" + } + } + } + }, + "400": { + "description": "Invalid filter name or regex too long" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "Filter already exists" + }, + "422": { + "description": "Regex pattern failed to compile" + }, + "429": { + "description": "Rate limit exceeded for filter create operations" + }, + "500": { + "description": "Failed to write .local file" + } + } + } + }, + "/api/v1/config/filters/{name}": { + "get": { + "tags": [ + "Config", + "Filter Config" + ], + "summary": "Return full parsed detail for a single filter", + "description": "Return the full parsed configuration and active/inactive status for one filter.\n\nReads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding\n``.local`` override, and annotates the result with ``active``,\n``used_by_jails``, ``source_file``, and ``has_local_override``.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session \u2014 enforces authentication.\n name: Filter base name (with or without ``.conf`` extension).\n\nReturns:\n :class:`~app.models.config.FilterConfig`.\n\nRaises:\n HTTPException: 404 if the filter is not found in ``filter.d/``.\n HTTPException: 502 if fail2ban is unreachable.", + "operationId": "get_filter_api_v1_config_filters__name__get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Filter base name, e.g. ``sshd`` or ``sshd.conf``.", + "title": "Name" + }, + "description": "Filter base name, e.g. ``sshd`` or ``sshd.conf``." + } + ], + "responses": { + "200": { + "description": "Filter config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterConfig" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Filter not found in filter.d/" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config", + "Filter Config" + ], + "summary": "Update a filter's .local override with new regex/pattern values", + "description": "Update a filter's ``[Definition]`` fields by writing a ``.local`` override.\n\nAll regex patterns are validated before writing. Validation includes:\n\n- **Length limit**: Patterns must not exceed 1000 characters (prevents DoS)\n- **Compilation timeout**: Pattern compilation must complete within 2 seconds\n (prevents ReDoS attacks via catastrophic backtracking)\n- **Syntax validation**: Patterns must be valid Python regex\n\nThe original ``.conf`` file is never modified. Fields left as ``null`` in the\nrequest body are kept at their current values.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Filter base name (with or without ``.conf`` extension).\n body: Partial update \u2014 ``failregex``, ``ignoreregex``, ``datepattern``,\n ``journalmatch``.\n reload: When ``true``, trigger a fail2ban reload after writing.\n\nReturns:\n Updated :class:`~app.models.config.FilterConfig`.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 400 if any regex pattern exceeds 1000 characters.\n HTTPException: 400 if any regex pattern times out during compilation (ReDoS).\n HTTPException: 422 if any regex pattern fails to compile.\n HTTPException: 404 if the filter does not exist.\n HTTPException: 500 if writing the ``.local`` file fails.", + "operationId": "update_filter_api_v1_config_filters__name__put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Filter base name, e.g. ``sshd`` or ``sshd.conf``.", + "title": "Name" + }, + "description": "Filter base name, e.g. ``sshd`` or ``sshd.conf``." + }, + { + "name": "reload", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Reload fail2ban after writing.", + "default": false, + "title": "Reload" + }, + "description": "Reload fail2ban after writing." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Filter updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterConfig" + } + } + } + }, + "400": { + "description": "Invalid filter name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Filter not found" + }, + "422": { + "description": "Regex pattern failed to compile" + }, + "429": { + "description": "Rate limit exceeded for filter update operations" + }, + "500": { + "description": "Failed to write .local file" + } + } + }, + "delete": { + "tags": [ + "Config", + "Filter Config" + ], + "summary": "Delete a user-created filter's .local file", + "description": "Delete a user-created filter's ``.local`` override file.\n\nShipped ``.conf``-only filters cannot be deleted (returns 409). When\nboth a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.\nWhen only a ``.local`` exists (user-created filter), the file is deleted\nentirely.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Filter base name.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if the filter does not exist.\n HTTPException: 409 if the filter is a shipped default (conf-only).\n HTTPException: 500 if deletion fails.", + "operationId": "delete_filter_api_v1_config_filters__name__delete", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Filter base name, e.g. ``sshd`` or ``sshd.conf``.", + "title": "Name" + }, + "description": "Filter base name, e.g. ``sshd`` or ``sshd.conf``." + } + ], + "responses": { + "204": { + "description": "Filter deleted successfully" + }, + "400": { + "description": "Invalid filter name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Filter not found" + }, + "409": { + "description": "Filter is a shipped default (conf-only)" + }, + "429": { + "description": "Rate limit exceeded for filter delete operations" + }, + "500": { + "description": "Failed to delete .local file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/actions": { + "get": { + "tags": [ + "Config" + ], + "summary": "List all action definition files", + "description": "Return a list of every ``.conf`` and ``.local`` file in ``action.d/``.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n\nReturns:\n :class:`~app.models.file_config.ConfFilesResponse`.", + "operationId": "list_action_files_api_v1_config_actions_get", + "responses": { + "200": { + "description": "Action files returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFilesResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "503": { + "description": "Config directory unavailable" + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Create a new action definition file", + "description": "Create a new ``.conf`` file in ``action.d/``.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n body: Name and initial content for the new file.\n\nReturns:\n The created :class:`~app.models.file_config.ConfFileContent`.\n\nRaises:\n HTTPException: 400 if *name* is invalid or content exceeds limit.\n HTTPException: 409 if a file with that name already exists.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "create_action_file_api_v1_config_actions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Action file created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileContent" + } + } + } + }, + "400": { + "description": "Name invalid or content exceeds limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "File with that name already exists" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/actions/{name}": { + "get": { + "tags": [ + "Config", + "Action Config" + ], + "summary": "Return full parsed detail for a single action", + "description": "Return the full parsed configuration and active/inactive status for one action.\n\nReads ``{config_dir}/action.d/{name}.conf``, merges any corresponding\n``.local`` override, and annotates the result with ``active``,\n``used_by_jails``, ``source_file``, and ``has_local_override``.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session \u2014 enforces authentication.\n name: Action base name (with or without ``.conf`` extension).\n\nReturns:\n :class:`~app.models.config.ActionConfig`.\n\nRaises:\n HTTPException: 404 if the action is not found in ``action.d/``.", + "operationId": "get_action_api_v1_config_actions__name__get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Action base name, e.g. ``iptables`` or ``iptables.conf``.", + "title": "Name" + }, + "description": "Action base name, e.g. ``iptables`` or ``iptables.conf``." + } + ], + "responses": { + "200": { + "description": "Action config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionConfig" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Action not found in action.d/" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config", + "Action Config" + ], + "summary": "Update an action's .local override with new lifecycle command values", + "description": "Update an action's ``[Definition]`` fields by writing a ``.local`` override.\n\nOnly non-``null`` fields in the request body are written. The original\n``.conf`` file is never modified.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Action base name (with or without ``.conf`` extension).\n body: Partial update \u2014 lifecycle commands and ``[Init]`` parameters.\n reload: When ``true``, trigger a fail2ban reload after writing.\n\nReturns:\n Updated :class:`~app.models.config.ActionConfig`.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if the action does not exist.\n HTTPException: 500 if writing the ``.local`` file fails.", + "operationId": "update_action_api_v1_config_actions__name__put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Action base name, e.g. ``iptables`` or ``iptables.conf``.", + "title": "Name" + }, + "description": "Action base name, e.g. ``iptables`` or ``iptables.conf``." + }, + { + "name": "reload", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Reload fail2ban after writing.", + "default": false, + "title": "Reload" + }, + "description": "Reload fail2ban after writing." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Action updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionConfig" + } + } + } + }, + "400": { + "description": "Invalid action name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Action not found" + }, + "429": { + "description": "Rate limit exceeded for action update operations" + }, + "500": { + "description": "Failed to write .local file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Config", + "Action Config" + ], + "summary": "Delete a user-created action's .local file", + "description": "Delete a user-created action's ``.local`` override file.\n\nShipped ``.conf``-only actions cannot be deleted (returns 409). When\nboth a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.\n\nArgs:\n request: FastAPI request object.\n _auth: Validated session.\n name: Action base name.\n\nRaises:\n HTTPException: 400 if *name* contains invalid characters.\n HTTPException: 404 if the action does not exist.\n HTTPException: 409 if the action is a shipped default (conf-only).\n HTTPException: 500 if deletion fails.", + "operationId": "delete_action_api_v1_config_actions__name__delete", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Action base name, e.g. ``iptables`` or ``iptables.conf``.", + "title": "Name" + }, + "description": "Action base name, e.g. ``iptables`` or ``iptables.conf``." + } + ], + "responses": { + "204": { + "description": "Action deleted successfully" + }, + "400": { + "description": "Invalid action name" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Action not found" + }, + "409": { + "description": "Action is a shipped default (conf-only)" + }, + "429": { + "description": "Rate limit exceeded for action delete operations" + }, + "500": { + "description": "Failed to delete .local file" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/global": { + "get": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Return global fail2ban settings", + "description": "Return global fail2ban settings.\n\nIncludes log level, log target, and database configuration.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n\nReturns:\n :class:`~app.models.config.GlobalConfigResponse`.\n\nRaises:\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_global_config_api_v1_config_global_get", + "responses": { + "200": { + "description": "Global config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalConfigResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + }, + "put": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Update global fail2ban settings", + "description": "Update global fail2ban settings.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n body: Partial update \u2014 only non-None fields are written.\n\nRaises:\n HTTPException: 400 when a set command is rejected or log_target is invalid.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "update_global_config_api_v1_config_global_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GlobalConfigUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Global config updated successfully" + }, + "400": { + "description": "Set command rejected or log_target invalid" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "429": { + "description": "Rate limit exceeded for config update operations" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/reload": { + "post": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Reload fail2ban to apply configuration changes", + "description": "Trigger a full fail2ban reload.\n\nAll jails are stopped and restarted with the current configuration.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n\nRaises:\n HTTPException: 409 when fail2ban reports the reload failed.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "reload_fail2ban_api_v1_config_reload_post", + "responses": { + "204": { + "description": "Fail2ban reloaded successfully" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "Reload command failed in fail2ban" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/config/restart": { + "post": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Restart the fail2ban service", + "description": "Trigger a full fail2ban service restart.\n\nStops the fail2ban daemon via the Unix domain socket, then starts it\nagain using the configured ``fail2ban_start_command``. After starting,\nprobes the socket for up to 10 seconds to confirm the daemon came back\nonline.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n\nRaises:\n HTTPException: 409 when fail2ban reports the stop command failed.\n HTTPException: 502 when fail2ban is unreachable for the stop command.\n HTTPException: 503 when fail2ban does not come back online within\n 10 seconds after being started. Check the fail2ban log for\n initialisation errors. Use\n ``POST /api/config/jails/{name}/rollback``\n if a specific jail is suspect.", + "operationId": "restart_fail2ban_api_v1_config_restart_post", + "responses": { + "204": { + "description": "Fail2ban restarted successfully" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "Stop command failed in fail2ban" + }, + "502": { + "description": "fail2ban unreachable for stop command" + }, + "503": { + "description": "fail2ban did not come back online within 10s" + } + } + } + }, + "/api/v1/config/regex-test": { + "post": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Test a fail regex pattern against a sample log line", + "description": "Test whether a regex pattern matches a given log line.\n\nThis endpoint is entirely in-process \u2014 no fail2ban socket call is made.\nReturns the match result and any captured groups.\n\nArgs:\n _auth: Validated session.\n body: Sample log line and regex pattern.\n\nReturns:\n :class:`~app.models.config.RegexTestResponse` with match result and\n groups.", + "operationId": "regex_test_api_v1_config_regex_test_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegexTestRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Regex test result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegexTestResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "422": { + "description": "Invalid regex pattern" + } + } + } + }, + "/api/v1/config/preview-log": { + "post": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Preview log file lines against a regex pattern", + "description": "Read the last N lines of a log file and test a regex against each one.\n\nReturns each line with a flag indicating whether the regex matched, and\nthe captured groups for matching lines. The log file is read from the\nserver's local filesystem.\n\nArgs:\n _auth: Validated session.\n body: Log file path, regex pattern, and number of lines to read.\n\nReturns:\n :class:`~app.models.config.LogPreviewResponse` with per-line results.", + "operationId": "preview_log_api_v1_config_preview_log_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogPreviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Log preview result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogPreviewResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "422": { + "description": "Invalid regex pattern" + } + } + } + }, + "/api/v1/config/map-color-thresholds": { + "get": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Get map color threshold configuration", + "description": "Return the configured map color thresholds.\n\nArgs:\n _request: FastAPI request object.\n _auth: Validated session.\n settings_ctx: Settings service context containing db and repository.\n\nReturns:\n :class:`~app.models.config.MapColorThresholdsResponse` with\n current thresholds.", + "operationId": "get_map_color_thresholds_api_v1_config_map_color_thresholds_get", + "responses": { + "200": { + "description": "Color thresholds returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapColorThresholdsResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + }, + "put": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Update map color threshold configuration", + "description": "Update the map color threshold configuration.\n\nArgs:\n _request: FastAPI request object.\n _auth: Validated session.\n settings_ctx: Settings service context containing db and repository.\n body: New threshold values.\n\nReturns:\n :class:`~app.models.config.MapColorThresholdsResponse` with\n updated thresholds.\n\nRaises:\n HTTPException: 400 if validation fails (thresholds not\n properly ordered).", + "operationId": "update_map_color_thresholds_api_v1_config_map_color_thresholds_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapColorThresholdsUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Color thresholds updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MapColorThresholdsResponse" + } + } + } + }, + "400": { + "description": "Validation error (thresholds not properly ordered)" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "429": { + "description": "Rate limit exceeded for config update operations" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/fail2ban-log": { + "get": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Read the tail of the fail2ban daemon log file", + "description": "Return the tail of the fail2ban daemon log file.\n\nQueries the fail2ban socket for the current log target and log level,\nreads the last *lines* entries from the file, and optionally filters\nthem by *filter*. Only file-based log targets are supported.\n\nArgs:\n request: Incoming request.\n _auth: Validated session \u2014 enforces authentication.\n lines: Number of tail lines to return (1\u20132000, default 200).\n filter: Optional plain-text substring \u2014 only matching lines returned.\n\nReturns:\n :class:`~app.models.config.Fail2BanLogResponse`.\n\nRaises:\n HTTPException: 400 when the log target is not a file or path is outside\n the allowed directory.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_fail2ban_log_api_v1_config_fail2ban_log_get", + "parameters": [ + { + "name": "lines", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 2000, + "minimum": 1, + "description": "Number of lines to return from the tail.", + "default": 200, + "title": "Lines" + }, + "description": "Number of lines to return from the tail." + }, + { + "name": "filter", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Plain-text substring filter; only matching lines are returned.", + "title": "Filter" + }, + "description": "Plain-text substring filter; only matching lines are returned." + } + ], + "responses": { + "200": { + "description": "Log file lines returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Fail2BanLogResponse" + } + } + } + }, + "400": { + "description": "Log target not a file or path outside allowed directory" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/service-status": { + "get": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Return fail2ban service health status with log configuration", + "description": "Return fail2ban service health and current log configuration.\n\nProbes the fail2ban daemon to determine online/offline state, then\naugments the result with the current log level and log target values.\n\nArgs:\n request: Incoming request.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.config.ServiceStatusResponse`.\n\nRaises:\n HTTPException: 502 when fail2ban is unreachable (the service itself\n handles this gracefully and returns ``online=False``).", + "operationId": "get_service_status_api_v1_config_service_status_get", + "responses": { + "200": { + "description": "Service status returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServiceStatusResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/config/security-headers": { + "get": { + "tags": [ + "Config", + "Config Misc" + ], + "summary": "Return security-relevant header configuration", + "description": "Return the header name and value used for CSRF protection.\n\nThis endpoint allows the frontend to discover the required CSRF header\nname and value at runtime rather than hard-coding them. The response\nis derived from the same constants used by the backend CSRF middleware,\nensuring a single source of truth.\n\nArgs:\n request: Incoming request.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.config.SecurityHeadersResponse` with\n ``csrf_header_name`` and ``csrf_header_value``.", + "operationId": "get_security_headers_api_v1_config_security_headers_get", + "responses": { + "200": { + "description": "Security header names and values returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SecurityHeadersResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + } + }, + "/api/v1/config/jail-files": { + "get": { + "tags": [ + "Config" + ], + "summary": "List all jail config files", + "description": "Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``.\n\nThe ``enabled`` field reflects the value of the ``enabled`` key inside the\nfile (defaulting to ``true`` when the key is absent).\n\nArgs:\n config_dir: Config directory path injected from application settings.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.file_config.JailConfigFilesResponse`.", + "operationId": "list_jail_config_files_api_v1_config_jail_files_get", + "responses": { + "200": { + "description": "Jail config files returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailConfigFilesResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "503": { + "description": "Config directory unavailable" + } + } + }, + "post": { + "tags": [ + "Config" + ], + "summary": "Create a new jail.d config file", + "description": "Create a new ``.conf`` file in ``jail.d/``.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n body: :class:`~app.models.file_config.ConfFileCreateRequest` with name and content.\n\nReturns:\n :class:`~app.models.file_config.ConfFileContent` with the created file metadata.\n\nRaises:\n HTTPException: 400 if the name is unsafe or the content exceeds the size limit.\n HTTPException: 409 if a file with that name already exists.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "create_jail_config_file_api_v1_config_jail_files_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "File created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileContent" + } + } + } + }, + "400": { + "description": "Name unsafe or content exceeds size limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "File with that name already exists" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jail-files/{filename}": { + "get": { + "tags": [ + "Config" + ], + "summary": "Return a single jail config file with its content", + "description": "Return the metadata and raw content of one jail config file.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n filename: Filename including extension (e.g. ``sshd.conf``).\n\nReturns:\n :class:`~app.models.file_config.JailConfigFileContent`.\n\nRaises:\n HTTPException: 400 if *filename* is unsafe.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "get_jail_config_file_api_v1_config_jail_files__filename__get", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Config filename including extension (e.g. ``sshd.conf``).", + "title": "Filename" + }, + "description": "Config filename including extension (e.g. ``sshd.conf``)." + } + ], + "responses": { + "200": { + "description": "Jail config file returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailConfigFileContent" + } + } + } + }, + "400": { + "description": "Filename unsafe" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Overwrite a jail.d config file with new raw content", + "description": "Overwrite the raw content of an existing jail.d config file.\n\nThe change is written directly to disk. You must reload fail2ban\n(``POST /api/config/reload``) separately for the change to take effect.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n filename: Filename of the jail config file (e.g. ``sshd.conf``).\n body: New raw file content.\n\nRaises:\n HTTPException: 400 if *filename* is unsafe or content is invalid.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "write_jail_config_file_api_v1_config_jail_files__filename__put", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Config filename including extension (e.g. ``sshd.conf``).", + "title": "Filename" + }, + "description": "Config filename including extension (e.g. ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileUpdateRequest" + } + } + } + }, + "responses": { + "204": { + "description": "File overwritten successfully" + }, + "400": { + "description": "Filename unsafe or content invalid" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jail-files/{filename}/enabled": { + "put": { + "tags": [ + "Config" + ], + "summary": "Enable or disable a jail configuration file", + "description": "Set the ``enabled = true/false`` key inside a jail config file.\n\nThe change modifies the file on disk. You must reload fail2ban\n(``POST /api/config/reload``) separately for the change to take effect.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n filename: Filename of the jail config file (e.g. ``sshd.conf``).\n body: New enabled state.\n\nRaises:\n HTTPException: 400 if *filename* is unsafe or the operation fails.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "set_jail_config_file_enabled_api_v1_config_jail_files__filename__enabled_put", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Config filename including extension (e.g. ``sshd.conf``).", + "title": "Filename" + }, + "description": "Config filename including extension (e.g. ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailConfigFileEnabledUpdate" + } + } + } + }, + "responses": { + "204": { + "description": "Enabled state updated successfully" + }, + "400": { + "description": "Filename unsafe or operation failed" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/filters/{name}/raw": { + "get": { + "tags": [ + "Config" + ], + "summary": "Return a filter definition file's raw content", + "description": "Return the raw content of a filter definition file.\n\nThis endpoint provides direct access to the file bytes for the raw\nconfig editor. For structured parsing with active/inactive status use\n``GET /api/config/filters/{name}`` (served by the config router).\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).\n\nReturns:\n :class:`~app.models.file_config.ConfFileContent`.\n\nRaises:\n HTTPException: 400 if *name* is unsafe.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "get_filter_file_raw_api_v1_config_filters__name__raw_get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "responses": { + "200": { + "description": "Filter file returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileContent" + } + } + } + }, + "400": { + "description": "Name unsafe" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Update a filter definition file (raw content)", + "description": "Overwrite the content of an existing filter definition file.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name with or without extension.\n body: New file content.\n\nRaises:\n HTTPException: 400 if *name* is unsafe or content exceeds the size limit.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "write_filter_file_api_v1_config_filters__name__raw_put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileUpdateRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Filter file updated successfully" + }, + "400": { + "description": "Name unsafe or content exceeds size limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/filters/raw": { + "post": { + "tags": [ + "Config" + ], + "summary": "Create a new filter definition file (raw content)", + "description": "Create a new ``.conf`` file in ``filter.d/``.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n body: Name and initial content for the new file.\n\nReturns:\n The created :class:`~app.models.file_config.ConfFileContent`.\n\nRaises:\n HTTPException: 400 if *name* is invalid or content exceeds limit.\n HTTPException: 409 if a file with that name already exists.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "create_filter_file_api_v1_config_filters_raw_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Filter file created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileContent" + } + } + } + }, + "400": { + "description": "Name invalid or content exceeds limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "File with that name already exists" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/actions/{name}/raw": { + "get": { + "tags": [ + "Config" + ], + "summary": "Return an action definition file with its content", + "description": "Return the content of an action definition file.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name with or without extension.\n\nReturns:\n :class:`~app.models.file_config.ConfFileContent`.\n\nRaises:\n HTTPException: 400 if *name* is unsafe.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "get_action_file_api_v1_config_actions__name__raw_get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "responses": { + "200": { + "description": "Action file returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileContent" + } + } + } + }, + "400": { + "description": "Name unsafe" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Update an action definition file", + "description": "Overwrite the content of an existing action definition file.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name with or without extension.\n body: New file content.\n\nRaises:\n HTTPException: 400 if *name* is unsafe or content exceeds the size limit.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "write_action_file_api_v1_config_actions__name__raw_put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfFileUpdateRequest" + } + } + } + }, + "responses": { + "204": { + "description": "Action file updated successfully" + }, + "400": { + "description": "Name unsafe or content exceeds size limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "File not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/filters/{name}/parsed": { + "get": { + "tags": [ + "Config" + ], + "summary": "Return a filter file parsed into a structured model", + "description": "Parse a filter definition file and return its structured fields.\n\nThe file is read from ``filter.d/``, parsed as fail2ban INI format, and\nreturned as a :class:`~app.models.config.FilterConfig` JSON object. This\nis the input model for the form-based filter editor (Task 2.3).\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name (e.g. ``sshd`` or ``sshd.conf``).\n\nReturns:\n :class:`~app.models.config.FilterConfig`.\n\nRaises:\n HTTPException: 400 if *name* is unsafe.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "get_parsed_filter_api_v1_config_filters__name__parsed_get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "responses": { + "200": { + "description": "Filter config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterConfig" + } + } + } + }, + "400": { + "description": "Name unsafe" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Filter file not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Update a filter file from a structured model", + "description": "Apply a partial structured update to a filter definition file.\n\nFields set to ``null`` in the request body are left unchanged. The file is\nre-serialized to fail2ban INI format after merging.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name of the filter to update.\n body: Partial :class:`~app.models.config.FilterConfigUpdate`.\n\nRaises:\n HTTPException: 400 if *name* is unsafe or content exceeds the size limit.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "update_parsed_filter_api_v1_config_filters__name__parsed_put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterConfigUpdate" + } + } + } + }, + "responses": { + "204": { + "description": "Filter file updated successfully" + }, + "400": { + "description": "Name unsafe or content exceeds size limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Filter file not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/actions/{name}/parsed": { + "get": { + "tags": [ + "Config" + ], + "summary": "Return an action file parsed into a structured model", + "description": "Parse an action definition file and return its structured fields.\n\nThe file is read from ``action.d/``, parsed as fail2ban INI format, and\nreturned as a :class:`~app.models.config.ActionConfig` JSON object. This\nis the input model for the form-based action editor (Task 3.3).\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name (e.g. ``iptables`` or ``iptables.conf``).\n\nReturns:\n :class:`~app.models.config.ActionConfig`.\n\nRaises:\n HTTPException: 400 if *name* is unsafe.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "get_parsed_action_api_v1_config_actions__name__parsed_get", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "responses": { + "200": { + "description": "Action config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionConfig" + } + } + } + }, + "400": { + "description": "Name unsafe" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Action file not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Update an action file from a structured model", + "description": "Apply a partial structured update to an action definition file.\n\nFields set to ``null`` in the request body are left unchanged. The file is\nre-serialized to fail2ban INI format after merging.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n name: Base name of the action to update.\n body: Partial :class:`~app.models.config.ActionConfigUpdate`.\n\nRaises:\n HTTPException: 400 if *name* is unsafe or content exceeds the size limit.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "update_parsed_action_api_v1_config_actions__name__parsed_put", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Name" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActionConfigUpdate" + } + } + } + }, + "responses": { + "204": { + "description": "Action file updated successfully" + }, + "400": { + "description": "Name unsafe or content exceeds size limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Action file not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/config/jail-files/{filename}/parsed": { + "get": { + "tags": [ + "Config" + ], + "summary": "Return a jail.d file parsed into a structured model", + "description": "Parse a jail.d config file and return its structured fields.\n\nThe file is read from ``jail.d/``, parsed as fail2ban INI format, and\nreturned as a :class:`~app.models.config.JailFileConfig` JSON object. This\nis the input model for the form-based jail file editor (Task 6.2).\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n filename: Filename including extension (e.g. ``sshd.conf``).\n\nReturns:\n :class:`~app.models.config.JailFileConfig`.\n\nRaises:\n HTTPException: 400 if *filename* is unsafe.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "get_parsed_jail_file_api_v1_config_jail_files__filename__parsed_get", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Filename" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "responses": { + "200": { + "description": "Jail file config returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailFileConfig" + } + } + } + }, + "400": { + "description": "Filename unsafe" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail file not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Config" + ], + "summary": "Update a jail.d file from a structured model", + "description": "Apply a partial structured update to a jail.d config file.\n\nFields set to ``null`` in the request body are left unchanged. The file is\nre-serialized to fail2ban INI format after merging.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n filename: Filename including extension (e.g. ``sshd.conf``).\n body: Partial :class:`~app.models.config.JailFileConfigUpdate`.\n\nRaises:\n HTTPException: 400 if *filename* is unsafe or content exceeds size limit.\n HTTPException: 404 if the file does not exist.\n HTTPException: 503 if the config directory is unavailable.", + "operationId": "update_parsed_jail_file_api_v1_config_jail_files__filename__parsed_put", + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).", + "title": "Filename" + }, + "description": "Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``)." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JailFileConfigUpdate" + } + } + } + }, + "responses": { + "204": { + "description": "Jail file updated successfully" + }, + "400": { + "description": "Filename unsafe or content exceeds size limit" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Jail file not found" + }, + "503": { + "description": "Config directory unavailable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/server/settings": { + "get": { + "tags": [ + "Server" + ], + "summary": "Return fail2ban server-level settings", + "description": "Return the current fail2ban server-level settings.\n\nIncludes log level, log target, syslog socket, database file path,\ndatabase purge age, and maximum stored matches per record.\n\nArgs:\n request: Incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.server.ServerSettingsResponse`.\n\nRaises:\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "get_server_settings_api_v1_server_settings_get", + "responses": { + "200": { + "description": "Server settings returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerSettingsResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + }, + "put": { + "tags": [ + "Server" + ], + "summary": "Update fail2ban server-level settings", + "description": "Update fail2ban server-level settings.\n\nOnly non-None fields in the request body are written. Changes take\neffect immediately without a daemon restart.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n body: Partial settings update.\n\nRaises:\n HTTPException: 400 when a set command is rejected by fail2ban.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "update_server_settings_api_v1_server_settings_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerSettingsUpdate" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Settings updated successfully" + }, + "400": { + "description": "Set command rejected by fail2ban" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/server/flush-logs": { + "post": { + "tags": [ + "Server" + ], + "summary": "Flush and re-open fail2ban log files", + "description": "Flush and re-open fail2ban log files.\n\nUseful after log rotation so the daemon writes to the newly created\nlog file rather than continuing to append to the rotated one.\n\nArgs:\n request: Incoming request.\n _auth: Validated session.\n\nReturns:\n :class:`~app.models.response.FlushLogsResponse` with the result from fail2ban.\n\nRaises:\n HTTPException: 400 when the command is rejected.\n HTTPException: 502 when fail2ban is unreachable.", + "operationId": "flush_logs_api_v1_server_flush_logs_post", + "responses": { + "200": { + "description": "Logs flushed successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FlushLogsResponse" + } + } + } + }, + "400": { + "description": "Command rejected by fail2ban" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + } + } + } + }, + "/api/v1/history": { + "get": { + "tags": [ + "History" + ], + "summary": "Return a paginated list of historical bans", + "description": "Return a paginated list of historical bans with optional filters.\n\nQueries the fail2ban database for all ban records, applying the requested\nfilters. Results are ordered newest-first and enriched with geolocation.\n\nArgs:\n request: The incoming request (used to access ``app.state``).\n _auth: Validated session \u2014 enforces authentication.\n history_ctx: History service context containing db and repositories.\n socket_path: Path to fail2ban Unix domain socket.\n http_session: Shared HTTP session for geolocation.\n fail2ban_metadata_service: Fail2Ban metadata service.\n range: Optional time-range preset. ``None`` means all-time.\n jail: Optional jail name filter (exact match).\n ip: Optional IP prefix filter (prefix match).\n page: 1-based page number.\n page_size: Items per page (1\u2013500).\n\nReturns:\n :class:`~app.models.history.HistoryListResponse` with paginated items\n and the total matching count.", + "operationId": "get_history_api_v1_history_get", + "parameters": [ + { + "name": "range", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "24h", + "7d", + "30d", + "365d" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional time-range filter. Omit for all-time.", + "title": "Range" + }, + "description": "Optional time-range filter. Omit for all-time." + }, + { + "name": "jail", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict results to this jail name.", + "title": "Jail" + }, + "description": "Restrict results to this jail name." + }, + { + "name": "ip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict results to IPs matching this prefix.", + "title": "Ip" + }, + "description": "Restrict results to IPs matching this prefix." + }, + { + "name": "origin", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "blocklist", + "selfblock" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + "title": "Origin" + }, + "description": "Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all." + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "enum": [ + "fail2ban", + "archive" + ], + "type": "string", + "description": "Data source: 'fail2ban' or 'archive'.", + "default": "fail2ban", + "title": "Source" + }, + "description": "Data source: 'fail2ban' or 'archive'." + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "1-based page number.", + "default": 1, + "title": "Page" + }, + "description": "1-based page number." + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Items per page.", + "default": 100, + "title": "Page Size" + }, + "description": "Items per page." + } + ], + "responses": { + "200": { + "description": "History list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistoryListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/history/archive": { + "get": { + "tags": [ + "History" + ], + "summary": "Return a paginated list of archived historical bans", + "operationId": "get_history_archive_api_v1_history_archive_get", + "parameters": [ + { + "name": "range", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "24h", + "7d", + "30d", + "365d" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional time-range filter. Omit for all-time.", + "title": "Range" + }, + "description": "Optional time-range filter. Omit for all-time." + }, + { + "name": "jail", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict results to this jail name.", + "title": "Jail" + }, + "description": "Restrict results to this jail name." + }, + { + "name": "ip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Restrict results to IPs matching this prefix.", + "title": "Ip" + }, + "description": "Restrict results to IPs matching this prefix." + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "1-based page number.", + "default": 1, + "title": "Page" + }, + "description": "1-based page number." + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "Items per page.", + "default": 100, + "title": "Page Size" + }, + "description": "Items per page." + } + ], + "responses": { + "200": { + "description": "Archived history list returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HistoryListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/history/{ip}": { + "get": { + "tags": [ + "History" + ], + "summary": "Return the full ban history for a single IP address", + "description": "Return the complete historical record for a single IP address.\n\nFetches all ban events for the given IP from the fail2ban database and\naggregates them into a timeline. Returns ``404`` if the IP has no\nrecorded history.\n\nArgs:\n request: The incoming request.\n _auth: Validated session dependency.\n ip: The IP address to look up.\n\nReturns:\n :class:`~app.models.history.IpDetailResponse` with aggregated totals\n and a full ban timeline.\n\nRaises:\n HTTPException: 404 if the IP has no history in the database.", + "operationId": "get_ip_history_api_v1_history__ip__get", + "parameters": [ + { + "name": "ip", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Ip" + } + } + ], + "responses": { + "200": { + "description": "IP history detail returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IpDetailResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "No history found for this IP" + }, + "502": { + "description": "fail2ban unreachable" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/blocklists": { + "get": { + "tags": [ + "Blocklists" + ], + "summary": "List all blocklist sources", + "description": "Return all configured blocklist source definitions.\n\nArgs:\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n :class:`~app.models.blocklist.BlocklistListResponse` with all sources.", + "operationId": "list_blocklists_api_v1_blocklists_get", + "responses": { + "200": { + "description": "Blocklist sources returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlocklistListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + }, + "post": { + "tags": [ + "Blocklists" + ], + "summary": "Add a new blocklist source", + "description": "Create a new blocklist source definition.\n\nArgs:\n payload: New source data (name, url, enabled).\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n\nReturns:\n The newly created :class:`~app.models.blocklist.BlocklistSource`.\n\nRaises:\n HTTPException: 400 if URL validation fails.", + "operationId": "create_blocklist_api_v1_blocklists_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlocklistSourceCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Blocklist source created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlocklistSource" + } + } + } + }, + "400": { + "description": "URL validation failed" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "409": { + "description": "A blocklist source with this URL already exists" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/blocklists/import": { + "post": { + "tags": [ + "Blocklists" + ], + "summary": "Trigger a manual blocklist import", + "description": "Download and apply all enabled blocklist sources immediately.\n\nArgs:\n http_session: Shared HTTP session (injected).\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n socket_path: Path to fail2ban Unix domain socket.\n geo_cache: Geolocation cache instance.\n\nReturns:\n :class:`~app.models.blocklist.ImportRunResult` with per-source\n results and aggregated counters.", + "operationId": "run_import_now_api_v1_blocklists_import_post", + "responses": { + "200": { + "description": "Import completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportRunResult" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "429": { + "description": "Rate limit exceeded for blocklist import" + } + } + } + }, + "/api/v1/blocklists/schedule": { + "get": { + "tags": [ + "Blocklists" + ], + "summary": "Get the current import schedule", + "description": "Return the current schedule configuration and runtime metadata.\n\nThe ``next_run_at`` field is read from APScheduler if the job is active.\n\nArgs:\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n scheduler: APScheduler instance.\n\nReturns:\n :class:`~app.models.blocklist.ScheduleInfo` with config and run\n times.", + "operationId": "get_schedule_api_v1_blocklists_schedule_get", + "responses": { + "200": { + "description": "Schedule info returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleInfo" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + } + } + }, + "put": { + "tags": [ + "Blocklists" + ], + "summary": "Update the import schedule", + "description": "Persist a new schedule configuration and reschedule the import job.\n\nArgs:\n payload: New :class:`~app.models.blocklist.ScheduleConfig`.\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n scheduler: Shared APScheduler instance (injected).\n http_session: Shared HTTP session used by the scheduler job.\n settings: Current application settings used by the scheduler job.\n\nReturns:\n Updated :class:`~app.models.blocklist.ScheduleInfo`.", + "operationId": "update_schedule_api_v1_blocklists_schedule_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Schedule updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduleInfo" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/blocklists/log": { + "get": { + "tags": [ + "Blocklists" + ], + "summary": "Get the paginated import log", + "description": "Return a paginated log of all import runs.\n\nArgs:\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n source_id: Optional filter \u2014 only show logs for this source.\n page: 1-based page number.\n page_size: Items per page.\n\nReturns:\n :class:`~app.models.blocklist.ImportLogListResponse`.", + "operationId": "get_import_log_api_v1_blocklists_log_get", + "parameters": [ + { + "name": "source_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Filter by source id", + "title": "Source Id" + }, + "description": "Filter by source id" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "description": "1-based page number.", + "default": 1, + "title": "Page" + }, + "description": "1-based page number." + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 500, + "minimum": 1, + "description": "Items per page (max 500).", + "default": 100, + "title": "Page Size" + }, + "description": "Items per page (max 500)." + } + ], + "responses": { + "200": { + "description": "Import log returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportLogListResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/blocklists/{source_id}": { + "get": { + "tags": [ + "Blocklists" + ], + "summary": "Get a single blocklist source", + "description": "Return a single blocklist source by id.\n\nArgs:\n source_id: Primary key of the source.\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n\nRaises:\n HTTPException: 404 if the source does not exist.", + "operationId": "get_blocklist_api_v1_blocklists__source_id__get", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "responses": { + "200": { + "description": "Blocklist source returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlocklistSource" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Blocklist source not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Blocklists" + ], + "summary": "Update a blocklist source", + "description": "Update one or more fields on a blocklist source.\n\nArgs:\n source_id: Primary key of the source to update.\n payload: Fields to update (all optional).\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n\nRaises:\n HTTPException: 400 if URL validation fails.\n HTTPException: 404 if the source does not exist.", + "operationId": "update_blocklist_api_v1_blocklists__source_id__put", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlocklistSourceUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Blocklist source updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlocklistSource" + } + } + } + }, + "400": { + "description": "URL validation failed" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Blocklist source not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Blocklists" + ], + "summary": "Delete a blocklist source", + "description": "Delete a blocklist source by id.\n\nArgs:\n source_id: Primary key of the source to remove.\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n\nRaises:\n HTTPException: 404 if the source does not exist.", + "operationId": "delete_blocklist_api_v1_blocklists__source_id__delete", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "responses": { + "204": { + "description": "Blocklist source deleted successfully" + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Blocklist source not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/blocklists/{source_id}/preview": { + "get": { + "tags": [ + "Blocklists" + ], + "summary": "Preview the contents of a blocklist source", + "description": "Download and preview a sample of a blocklist source.\n\nReturns the first :data:`~app.services.blocklist_service._PREVIEW_LINES`\nvalid IP entries together with validation statistics.\n\nArgs:\n source_id: Primary key of the source to preview.\n http_session: Shared HTTP session for downloading.\n blocklist_ctx: Blocklist service context containing db and repositories.\n _auth: Validated session \u2014 enforces authentication.\n\nRaises:\n HTTPException: 404 if the source does not exist.\n HTTPException: 502 if the URL cannot be reached.", + "operationId": "preview_blocklist_api_v1_blocklists__source_id__preview_get", + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Source Id" + } + } + ], + "responses": { + "200": { + "description": "Blocklist preview returned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewResponse" + } + } + } + }, + "401": { + "description": "Session missing, expired, or invalid" + }, + "404": { + "description": "Blocklist source not found" + }, + "502": { + "description": "URL could not be reached" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ActionConfig": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Action base name, e.g. ``iptables``." + }, + "filename": { + "type": "string", + "title": "Filename", + "description": "Actual filename, e.g. ``iptables.conf``." + }, + "before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before" + }, + "after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After" + }, + "actionstart": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstart", + "description": "Executed at jail start or first ban." + }, + "actionstop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstop", + "description": "Executed at jail stop." + }, + "actioncheck": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actioncheck", + "description": "Executed before each ban." + }, + "actionban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionban", + "description": "Executed to ban an IP. Tags: ````, ````, ````." + }, + "actionunban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionunban", + "description": "Executed to unban an IP." + }, + "actionflush": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionflush", + "description": "Executed to flush all bans on shutdown." + }, + "definition_vars": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Definition Vars", + "description": "Additional ``[Definition]`` variables." + }, + "init_vars": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Init Vars", + "description": "Runtime parameters that can be overridden per jail." + }, + "active": { + "type": "boolean", + "title": "Active", + "description": "``True`` when this action is referenced by at least one currently enabled (running) jail.", + "default": false + }, + "used_by_jails": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Used By Jails", + "description": "Names of currently enabled jails that reference this action. Empty when ``active`` is ``False``." + }, + "source_file": { + "type": "string", + "title": "Source File", + "description": "Absolute path to the ``.conf`` source file for this action.", + "default": "" + }, + "has_local_override": { + "type": "boolean", + "title": "Has Local Override", + "description": "``True`` when a ``.local`` override file exists alongside the base ``.conf`` file.", + "default": false + } + }, + "type": "object", + "required": [ + "name", + "filename" + ], + "title": "ActionConfig", + "description": "Structured representation of an ``action.d/*.conf`` file." + }, + "ActionConfigUpdate": { + "properties": { + "before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before" + }, + "after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After" + }, + "actionstart": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstart" + }, + "actionstop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstop" + }, + "actioncheck": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actioncheck" + }, + "actionban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionban" + }, + "actionunban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionunban" + }, + "actionflush": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionflush" + }, + "definition_vars": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Definition Vars" + }, + "init_vars": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Init Vars" + } + }, + "type": "object", + "title": "ActionConfigUpdate", + "description": "Partial update payload for a parsed action file." + }, + "ActionCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Action base name (e.g. ``my-custom-action``). Must not already exist." + }, + "actionstart": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstart", + "description": "Command to execute at jail start." + }, + "actionstop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstop", + "description": "Command to execute at jail stop." + }, + "actioncheck": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actioncheck", + "description": "Command to execute before each ban." + }, + "actionban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionban", + "description": "Command to execute to ban an IP." + }, + "actionunban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionunban", + "description": "Command to execute to unban an IP." + }, + "actionflush": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionflush", + "description": "Command to flush all bans on shutdown." + }, + "definition_vars": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Definition Vars", + "description": "Additional ``[Definition]`` variables." + }, + "init_vars": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Init Vars", + "description": "``[Init]`` runtime parameters." + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "ActionCreateRequest", + "description": "Payload for ``POST /api/config/actions``.\n\nCreates a new user-defined action at ``action.d/{name}.local``." + }, + "ActionListResponse": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/ActionConfig" + }, + "type": "array", + "title": "Actions", + "description": "All discovered actions, each annotated with active/inactive status and the jails that reference them." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of actions found." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "ActionListResponse", + "description": "Response for ``GET /api/config/actions``." + }, + "ActionUpdateRequest": { + "properties": { + "actionstart": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstart", + "description": "Updated ``actionstart`` command. ``None`` = keep existing." + }, + "actionstop": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionstop", + "description": "Updated ``actionstop`` command. ``None`` = keep existing." + }, + "actioncheck": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actioncheck", + "description": "Updated ``actioncheck`` command. ``None`` = keep existing." + }, + "actionban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionban", + "description": "Updated ``actionban`` command. ``None`` = keep existing." + }, + "actionunban": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionunban", + "description": "Updated ``actionunban`` command. ``None`` = keep existing." + }, + "actionflush": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Actionflush", + "description": "Updated ``actionflush`` command. ``None`` = keep existing." + }, + "definition_vars": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Definition Vars", + "description": "Additional ``[Definition]`` variables to set. ``None`` = keep existing." + }, + "init_vars": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Init Vars", + "description": "``[Init]`` parameters to set. ``None`` = keep existing." + } + }, + "type": "object", + "title": "ActionUpdateRequest", + "description": "Payload for ``PUT /api/config/actions/{name}``.\n\nAccepts only the user-editable ``[Definition]`` lifecycle fields and\n``[Init]`` parameters. Fields left as ``None`` are not changed." + }, + "ActivateJailRequest": { + "properties": { + "bantime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bantime", + "description": "Override ban duration, e.g. ``1h`` or ``3600``." + }, + "findtime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Findtime", + "description": "Override failure-counting window, e.g. ``10m``." + }, + "maxretry": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Maxretry", + "description": "Override maximum failures before a ban." + }, + "port": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Port", + "description": "Override port(s) to monitor." + }, + "logpath": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Logpath", + "description": "Override log file paths." + } + }, + "type": "object", + "title": "ActivateJailRequest", + "description": "Optional override values when activating an inactive jail.\n\nAll fields are optional. Omitted fields are not written to the\n``.local`` override file so that fail2ban falls back to its default\nvalues." + }, + "ActiveBan": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "Banned IP address." + }, + "jail": { + "type": "string", + "title": "Jail", + "description": "Jail holding the ban." + }, + "banned_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Banned At", + "description": "ISO 8601 UTC start of the ban." + }, + "expires_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expires At", + "description": "ISO 8601 UTC expiry, or ``null`` if permanent." + }, + "ban_count": { + "type": "integer", + "minimum": 1.0, + "title": "Ban Count", + "description": "Running ban count for this IP.", + "default": 1 + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country", + "description": "ISO 3166-1 alpha-2 country code." + } + }, + "type": "object", + "required": [ + "ip", + "jail" + ], + "title": "ActiveBan", + "description": "A currently active ban entry returned by ``GET /api/bans/active``." + }, + "ActiveBanListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ActiveBan" + }, + "type": "array", + "title": "Items", + "description": "Collection items." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of items." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "ActiveBanListResponse", + "description": "List of all currently active bans across all jails.\n\nRequest: `GET /api/bans/active` with optional filter parameters.\nResponse: Non-paginated collection of currently active bans with total count.\n\nNote: This endpoint does not support pagination. All matching bans are returned.\nFor paginated results, use individual jail endpoints or the dashboard ban-list view." + }, + "AddLogPathRequest": { + "properties": { + "log_path": { + "type": "string", + "title": "Log Path", + "description": "Absolute path to the log file to monitor." + }, + "tail": { + "type": "boolean", + "title": "Tail", + "description": "If true, monitor from current end of file (tail). If false, read from the beginning.", + "default": true + } + }, + "type": "object", + "required": [ + "log_path" + ], + "title": "AddLogPathRequest", + "description": "Payload for ``POST /api/config/jails/{name}/logpath``." + }, + "AssignActionRequest": { + "properties": { + "action_name": { + "type": "string", + "title": "Action Name", + "description": "Action base name to add to the jail (e.g. ``iptables-multiport``)." + }, + "params": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Params", + "description": "Optional per-jail action parameters written as ``action_name[key=value, ...]`` in the jail config." + } + }, + "type": "object", + "required": [ + "action_name" + ], + "title": "AssignActionRequest", + "description": "Payload for ``POST /api/config/jails/{jail_name}/action``." + }, + "AssignFilterRequest": { + "properties": { + "filter_name": { + "type": "string", + "title": "Filter Name", + "description": "Filter base name to assign to the jail (e.g. ``sshd``)." + } + }, + "type": "object", + "required": [ + "filter_name" + ], + "title": "AssignFilterRequest", + "description": "Payload for ``POST /api/config/jails/{jail_name}/filter``." + }, + "BanRequest": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "IP address to ban." + }, + "jail": { + "type": "string", + "title": "Jail", + "description": "Jail in which to apply the ban." + } + }, + "type": "object", + "required": [ + "ip", + "jail" + ], + "title": "BanRequest", + "description": "Payload for ``POST /api/bans`` (ban an IP)." + }, + "BanTrendBucket": { + "properties": { + "timestamp": { + "type": "string", + "title": "Timestamp", + "description": "ISO 8601 UTC start of the bucket." + }, + "count": { + "type": "integer", + "minimum": 0.0, + "title": "Count", + "description": "Number of bans that started in this bucket." + } + }, + "type": "object", + "required": [ + "timestamp", + "count" + ], + "title": "BanTrendBucket", + "description": "A single time bucket in the ban trend series." + }, + "BanTrendResponse": { + "properties": { + "buckets": { + "items": { + "$ref": "#/components/schemas/BanTrendBucket" + }, + "type": "array", + "title": "Buckets", + "description": "Time-ordered list of ban-count buckets covering the full window." + }, + "bucket_size": { + "type": "string", + "title": "Bucket Size", + "description": "Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d')." + } + }, + "type": "object", + "required": [ + "bucket_size" + ], + "title": "BanTrendResponse", + "description": "Response for the ``GET /api/dashboard/bans/trend`` endpoint." + }, + "BansByCountryResponse": { + "properties": { + "countries": { + "additionalProperties": { + "type": "integer" + }, + "type": "object", + "title": "Countries", + "description": "ISO 3166-1 alpha-2 country code \u2192 ban count." + }, + "country_names": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Country Names", + "description": "ISO 3166-1 alpha-2 country code \u2192 human-readable country name." + }, + "bans": { + "items": { + "$ref": "#/components/schemas/DashboardBanItem" + }, + "type": "array", + "title": "Bans", + "description": "All bans in the selected time window (up to the server limit)." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total ban count in the window." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "BansByCountryResponse", + "description": "Response for the bans-by-country aggregation endpoint.\n\nContains a per-country ban count, a human-readable country name map, and\nthe full (un-paginated) ban list for the selected time window so the\nfrontend can render both the world map and its companion table from a\nsingle request." + }, + "BansByJailResponse": { + "properties": { + "jails": { + "items": { + "$ref": "#/components/schemas/JailBanCount" + }, + "type": "array", + "title": "Jails", + "description": "Jails ordered by ban count descending." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total ban count in the selected window." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "BansByJailResponse", + "description": "Response for the ``GET /api/dashboard/bans/by-jail`` endpoint." + }, + "BantimeEscalation": { + "properties": { + "increment": { + "type": "boolean", + "title": "Increment", + "description": "Whether incremental banning is enabled.", + "default": false + }, + "factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Factor", + "description": "Multiplier applied to the base ban time on each repeat offence." + }, + "formula": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Formula", + "description": "Python expression evaluated to compute the escalated ban time." + }, + "multipliers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Multipliers", + "description": "Space-separated integers used as per-offence multipliers." + }, + "max_time": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Time", + "description": "Maximum ban duration in seconds when escalation is active." + }, + "rnd_time": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Rnd Time", + "description": "Random jitter (seconds) added to each escalated ban time." + }, + "overall_jails": { + "type": "boolean", + "title": "Overall Jails", + "description": "Count repeat offences across all jails, not just the current one.", + "default": false + } + }, + "type": "object", + "title": "BantimeEscalation", + "description": "Incremental ban-time escalation configuration for a jail." + }, + "BantimeEscalationUpdate": { + "properties": { + "increment": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Increment" + }, + "factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Factor" + }, + "formula": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Formula" + }, + "multipliers": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Multipliers" + }, + "max_time": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Time" + }, + "rnd_time": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Rnd Time" + }, + "overall_jails": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Overall Jails" + } + }, + "type": "object", + "title": "BantimeEscalationUpdate", + "description": "Partial update payload for ban-time escalation settings." + }, + "BlocklistListResponse": { + "properties": { + "sources": { + "items": { + "$ref": "#/components/schemas/BlocklistSource" + }, + "type": "array", + "title": "Sources" + } + }, + "type": "object", + "title": "BlocklistListResponse", + "description": "Response for ``GET /api/blocklists``." + }, + "BlocklistSource": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "url": { + "type": "string", + "title": "Url" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "created_at": { + "type": "string", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "url", + "enabled", + "created_at", + "updated_at" + ], + "title": "BlocklistSource", + "description": "Domain model for a blocklist source definition." + }, + "BlocklistSourceCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 1, + "title": "Name", + "description": "Human-readable source name." + }, + "url": { + "type": "string", + "minLength": 1, + "format": "uri", + "title": "Url", + "description": "URL of the blocklist file (http/https only)." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + } + }, + "type": "object", + "required": [ + "name", + "url" + ], + "title": "BlocklistSourceCreate", + "description": "Payload for ``POST /api/blocklists``.\n\nURL must use http/https scheme. The hostname must resolve to a public IP\n(not private, loopback, link-local, or reserved). Validation happens\nasynchronously in the service layer." + }, + "BlocklistSourceUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 100, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "url": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "format": "uri" + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + } + }, + "type": "object", + "title": "BlocklistSourceUpdate", + "description": "Payload for ``PUT /api/blocklists/{id}``. All fields are optional.\n\nIf URL is provided, it must use http/https scheme." + }, + "ComponentHealth": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Component name." + }, + "healthy": { + "type": "boolean", + "title": "Healthy", + "description": "True when the component is operational." + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Optional detail message, e.g. error description." + } + }, + "type": "object", + "required": [ + "name", + "healthy" + ], + "title": "ComponentHealth", + "description": "Health status of a single application component.\n\nFields:\n name: Human-readable component name.\n healthy: True when the component is operational.\n message: Optional detail message (e.g., error description)." + }, + "ConfFileContent": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Base name without extension." + }, + "filename": { + "type": "string", + "title": "Filename", + "description": "Actual filename." + }, + "content": { + "type": "string", + "title": "Content", + "description": "Raw file content." + } + }, + "type": "object", + "required": [ + "name", + "filename", + "content" + ], + "title": "ConfFileContent", + "description": "A conf file with its raw text content." + }, + "ConfFileCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "New file base name (without extension). Must contain only alphanumeric characters, hyphens, underscores, and dots." + }, + "content": { + "type": "string", + "title": "Content", + "description": "Initial raw file content (must not exceed 512 KB)." + } + }, + "type": "object", + "required": [ + "name", + "content" + ], + "title": "ConfFileCreateRequest", + "description": "Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``." + }, + "ConfFileEntry": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Base name without extension (e.g. ``sshd``)." + }, + "filename": { + "type": "string", + "title": "Filename", + "description": "Actual filename (e.g. ``sshd.conf``)." + } + }, + "type": "object", + "required": [ + "name", + "filename" + ], + "title": "ConfFileEntry", + "description": "Metadata for a single ``.conf`` or ``.local`` file." + }, + "ConfFileUpdateRequest": { + "properties": { + "content": { + "type": "string", + "title": "Content", + "description": "New raw file content (must not exceed 512 KB)." + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "ConfFileUpdateRequest", + "description": "Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``." + }, + "ConfFilesResponse": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/ConfFileEntry" + }, + "type": "array", + "title": "Files" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "ConfFilesResponse", + "description": "Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``)." + }, + "DashboardBanItem": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "Banned IP address." + }, + "jail": { + "type": "string", + "title": "Jail", + "description": "Jail that issued the ban." + }, + "banned_at": { + "type": "string", + "title": "Banned At", + "description": "ISO 8601 UTC timestamp of the ban." + }, + "service": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Service", + "description": "First matched log line \u2014 used as context for the ban." + }, + "country_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Code", + "description": "ISO 3166-1 alpha-2 country code, or ``null`` if unknown." + }, + "country_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Name", + "description": "Human-readable country name, or ``null`` if unknown." + }, + "asn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asn", + "description": "Autonomous System Number string (e.g. ``'AS3320'``)." + }, + "org": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org", + "description": "Organisation name associated with the IP." + }, + "ban_count": { + "type": "integer", + "minimum": 1.0, + "title": "Ban Count", + "description": "How many times this IP was banned." + }, + "origin": { + "type": "string", + "enum": [ + "blocklist", + "selfblock" + ], + "title": "Origin", + "description": "Whether this ban came from a blocklist import or fail2ban itself." + } + }, + "type": "object", + "required": [ + "ip", + "jail", + "banned_at", + "ban_count", + "origin" + ], + "title": "DashboardBanItem", + "description": "A single row in the dashboard ban-list table.\n\nPopulated from the fail2ban database and enriched with geo data." + }, + "DashboardBanListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/DashboardBanItem" + }, + "type": "array", + "title": "Items", + "description": "Data items for the current page." + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMetadata", + "description": "Pagination metadata with computed derived fields." + } + }, + "type": "object", + "required": [ + "pagination" + ], + "title": "DashboardBanListResponse", + "description": "Paginated dashboard ban-list response.\n\nRequest: `GET /api/dashboard/bans` with time range, page, and filter parameters.\nResponse: Paginated collection of dashboard ban items with geo-enrichment." + }, + "Fail2BanLogResponse": { + "properties": { + "log_path": { + "type": "string", + "title": "Log Path", + "description": "Resolved absolute path of the log file being read." + }, + "lines": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Lines", + "description": "Log lines returned (tail, optionally filtered)." + }, + "total_lines": { + "type": "integer", + "minimum": 0.0, + "title": "Total Lines", + "description": "Total number of lines in the file before filtering." + }, + "log_level": { + "type": "string", + "title": "Log Level", + "description": "Current fail2ban log level." + }, + "log_target": { + "type": "string", + "title": "Log Target", + "description": "Current fail2ban log target (file path or special value)." + } + }, + "type": "object", + "required": [ + "log_path", + "total_lines", + "log_level", + "log_target" + ], + "title": "Fail2BanLogResponse", + "description": "Response for ``GET /api/config/fail2ban-log``." + }, + "FilterConfig": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Filter base name, e.g. ``sshd``." + }, + "filename": { + "type": "string", + "title": "Filename", + "description": "Actual filename, e.g. ``sshd.conf``." + }, + "before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before", + "description": "Included file read before this one." + }, + "after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After", + "description": "Included file read after this one." + }, + "variables": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Variables", + "description": "Free-form ``[DEFAULT]`` section variables." + }, + "prefregex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefregex", + "description": "Prefix regex prepended to every failregex." + }, + "failregex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Failregex", + "description": "Failure detection regex patterns (one per list entry)." + }, + "ignoreregex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignoreregex", + "description": "Regex patterns that bypass ban logic." + }, + "maxlines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxlines", + "description": "Maximum number of log lines accumulated for a single match attempt." + }, + "datepattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datepattern", + "description": "Custom date-parsing pattern, or ``None`` for auto-detect." + }, + "journalmatch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Journalmatch", + "description": "Systemd journal match expression." + }, + "active": { + "type": "boolean", + "title": "Active", + "description": "``True`` when this filter is referenced by at least one currently enabled (running) jail.", + "default": false + }, + "used_by_jails": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Used By Jails", + "description": "Names of currently enabled jails that reference this filter. Empty when ``active`` is ``False``." + }, + "source_file": { + "type": "string", + "title": "Source File", + "description": "Absolute path to the ``.conf`` source file for this filter.", + "default": "" + }, + "has_local_override": { + "type": "boolean", + "title": "Has Local Override", + "description": "``True`` when a ``.local`` override file exists alongside the base ``.conf`` file.", + "default": false + } + }, + "type": "object", + "required": [ + "name", + "filename" + ], + "title": "FilterConfig", + "description": "Structured representation of a ``filter.d/*.conf`` file.\n\nThe ``active``, ``used_by_jails``, ``source_file``, and\n``has_local_override`` fields are populated by\n:func:`~app.services.filter_config_service.list_filters` and\n:func:`~app.services.filter_config_service.get_filter`. When the model is\nreturned from the raw file-based endpoints (``/filters/{name}/parsed``),\nthese fields carry their default values." + }, + "FilterConfigUpdate": { + "properties": { + "before": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Before" + }, + "after": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "After" + }, + "variables": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Variables" + }, + "prefregex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefregex" + }, + "failregex": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Failregex" + }, + "ignoreregex": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ignoreregex" + }, + "maxlines": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxlines" + }, + "datepattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datepattern" + }, + "journalmatch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Journalmatch" + } + }, + "type": "object", + "title": "FilterConfigUpdate", + "description": "Partial update payload for a parsed filter file.\n\nOnly explicitly set (non-``None``) fields are written back." + }, + "FilterCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``." + }, + "failregex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Failregex", + "description": "Failure-detection regex patterns." + }, + "ignoreregex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignoreregex", + "description": "Regex patterns that bypass ban logic." + }, + "prefregex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefregex", + "description": "Prefix regex prepended to every failregex." + }, + "datepattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datepattern", + "description": "Custom date-parsing pattern." + }, + "journalmatch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Journalmatch", + "description": "Systemd journal match expression." + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "FilterCreateRequest", + "description": "Payload for ``POST /api/config/filters``.\n\nCreates a new user-defined filter at ``filter.d/{name}.local``." + }, + "FilterListResponse": { + "properties": { + "filters": { + "items": { + "$ref": "#/components/schemas/FilterConfig" + }, + "type": "array", + "title": "Filters", + "description": "All discovered filters, each annotated with active/inactive status and the jails that reference them." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of filters found." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "FilterListResponse", + "description": "Response for ``GET /api/config/filters``." + }, + "FilterUpdateRequest": { + "properties": { + "failregex": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Failregex", + "description": "Updated failure-detection regex patterns. ``None`` = keep existing." + }, + "ignoreregex": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ignoreregex", + "description": "Updated bypass-ban regex patterns. ``None`` = keep existing." + }, + "datepattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Datepattern", + "description": "Custom date-parsing pattern. ``None`` = keep existing." + }, + "journalmatch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Journalmatch", + "description": "Systemd journal match expression. ``None`` = keep existing." + } + }, + "type": "object", + "title": "FilterUpdateRequest", + "description": "Payload for ``PUT /api/config/filters/{name}``.\n\nAccepts only the user-editable ``[Definition]`` fields. Fields left as\n``None`` are not changed; the existing value from the merged conf/local is\npreserved." + }, + "FlushLogsResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable result message from fail2ban." + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "FlushLogsResponse", + "description": "Standardized response for the flush-logs command endpoint.\n\nFields:\n message: Human-readable result message from fail2ban.\n\nExample:\n ```python\n {\"message\": \"Success: fail2ban log files were flushed.\"}\n ```" + }, + "GeoCacheStatsResponse": { + "properties": { + "cache_size": { + "type": "integer", + "title": "Cache Size", + "description": "Number of positive entries in the in-memory cache." + }, + "unresolved": { + "type": "integer", + "title": "Unresolved", + "description": "Number of geo_cache rows with country_code IS NULL." + }, + "neg_cache_size": { + "type": "integer", + "title": "Neg Cache Size", + "description": "Number of entries in the in-memory negative cache." + }, + "dirty_size": { + "type": "integer", + "title": "Dirty Size", + "description": "Number of newly resolved entries not yet flushed to disk." + }, + "hits": { + "type": "integer", + "title": "Hits", + "description": "Number of cache hits since last clear.", + "default": 0 + }, + "misses": { + "type": "integer", + "title": "Misses", + "description": "Number of cache misses since last clear.", + "default": 0 + } + }, + "type": "object", + "required": [ + "cache_size", + "unresolved", + "neg_cache_size", + "dirty_size" + ], + "title": "GeoCacheStatsResponse", + "description": "Response for ``GET /api/geo/stats``.\n\nExposes diagnostic counters of the geo cache subsystem so operators\ncan assess resolution health from the UI or CLI." + }, + "GeoDetail": { + "properties": { + "country_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Code", + "description": "ISO 3166-1 alpha-2 country code." + }, + "country_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Name", + "description": "Human-readable country name." + }, + "asn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asn", + "description": "Autonomous System Number (e.g. ``'AS3320'``)." + }, + "org": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org", + "description": "Organisation associated with the ASN." + } + }, + "type": "object", + "title": "GeoDetail", + "description": "Enriched geolocation data for an IP address.\n\nPopulated from the ip-api.com free API." + }, + "GeoReResolveResponse": { + "properties": { + "resolved": { + "type": "integer", + "title": "Resolved", + "description": "Number of IPs successfully resolved." + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Number of IPs retried." + } + }, + "type": "object", + "required": [ + "resolved", + "total" + ], + "title": "GeoReResolveResponse", + "description": "Response for ``POST /api/geo/re-resolve``.\n\nReports how many previously unresolved IPs were retried and how many\ngained a resolved country code after the re-resolve operation." + }, + "GlobalConfigResponse": { + "properties": { + "log_level": { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "NOTICE", + "INFO", + "DEBUG" + ], + "title": "Log Level" + }, + "log_target": { + "type": "string", + "title": "Log Target", + "description": "Log target: STDOUT, STDERR, SYSLOG, or a validated file path." + }, + "db_purge_age": { + "type": "integer", + "title": "Db Purge Age", + "description": "Seconds after which ban records are purged from the fail2ban DB." + }, + "db_max_matches": { + "type": "integer", + "title": "Db Max Matches", + "description": "Maximum stored log-line matches per ban record." + } + }, + "type": "object", + "required": [ + "log_level", + "log_target", + "db_purge_age", + "db_max_matches" + ], + "title": "GlobalConfigResponse", + "description": "Response for ``GET /api/config/global``." + }, + "GlobalConfigUpdate": { + "properties": { + "log_level": { + "anyOf": [ + { + "type": "string", + "enum": [ + "CRITICAL", + "ERROR", + "WARNING", + "NOTICE", + "INFO", + "DEBUG" + ] + }, + { + "type": "null" + } + ], + "title": "Log Level", + "description": "Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, or DEBUG." + }, + "log_target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Log Target", + "description": "Log target: STDOUT, STDERR, SYSLOG, or a validated file path." + }, + "db_purge_age": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Db Purge Age" + }, + "db_max_matches": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Db Max Matches" + } + }, + "type": "object", + "title": "GlobalConfigUpdate", + "description": "Payload for ``PUT /api/config/global``." + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded", + "unavailable" + ], + "title": "Status", + "description": "Application health status: 'ok' when healthy, 'degraded' when some components are unhealthy, 'unavailable' when fail2ban is offline." + }, + "fail2ban": { + "type": "string", + "enum": [ + "online", + "offline" + ], + "title": "Fail2Ban", + "description": "fail2ban daemon status: 'online' when reachable, 'offline' otherwise." + }, + "database": { + "type": "string", + "enum": [ + "ok", + "error" + ], + "title": "Database", + "description": "Database connectivity: 'ok' when accessible, 'error' when not." + }, + "scheduler": { + "type": "string", + "enum": [ + "running", + "stopped", + "unknown" + ], + "title": "Scheduler", + "description": "Background scheduler status: 'running', 'stopped', or 'unknown'." + }, + "cache": { + "type": "string", + "enum": [ + "initialised", + "uninitialised" + ], + "title": "Cache", + "description": "Cache initialization status: 'initialised' when ready, 'uninitialised' when not." + }, + "external_logging": { + "type": "string", + "enum": [ + "ok", + "error", + "disabled" + ], + "title": "External Logging", + "description": "External logging handler status: 'ok' when operational, 'error' when initialization failed, 'disabled' when external logging is not configured." + }, + "components": { + "items": { + "$ref": "#/components/schemas/ComponentHealth" + }, + "type": "array", + "title": "Components", + "description": "Per-component health detail list. Empty when status is 'ok'." + } + }, + "type": "object", + "required": [ + "status", + "fail2ban", + "database", + "scheduler", + "cache", + "external_logging" + ], + "title": "HealthResponse", + "description": "Standardized response for the health check endpoint.\n\nFields:\n status: Application health status \u2014 'ok' when all components are healthy,\n 'degraded' when some components are unhealthy but the service can still\n handle requests, 'unavailable' when fail2ban is offline.\n fail2ban: fail2ban daemon status \u2014 'online' or 'offline'.\n database: Database connectivity \u2014 'ok' or 'error'.\n scheduler: Background scheduler status \u2014 'running', 'stopped', or 'unknown'.\n cache: Cache initialization status \u2014 'initialised' or 'uninitialised'.\n external_logging: External logging handler status \u2014 'ok', 'error', or 'disabled'.\n components: Per-component health detail list (empty when all healthy).\n\nExample:\n ```python\n # Healthy (HTTP 200)\n {\n \"status\": \"ok\",\n \"fail2ban\": \"online\",\n \"database\": \"ok\",\n \"scheduler\": \"running\",\n \"cache\": \"initialised\",\n \"external_logging\": \"disabled\",\n \"components\": []\n }\n\n # Unhealthy (HTTP 503)\n {\n \"status\": \"unavailable\",\n \"fail2ban\": \"offline\",\n \"database\": \"ok\",\n \"scheduler\": \"running\",\n \"cache\": \"initialised\",\n \"external_logging\": \"ok\",\n \"components\": [{\"name\": \"fail2ban\", \"healthy\": false, \"message\": \"Socket not reachable\"}]\n }\n ```" + }, + "HistoryBanItem": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "Banned IP address." + }, + "jail": { + "type": "string", + "title": "Jail", + "description": "Jail that issued the ban." + }, + "banned_at": { + "type": "string", + "title": "Banned At", + "description": "ISO 8601 UTC timestamp of the ban." + }, + "ban_count": { + "type": "integer", + "minimum": 1.0, + "title": "Ban Count", + "description": "How many times this IP was banned." + }, + "failures": { + "type": "integer", + "minimum": 0.0, + "title": "Failures", + "description": "Total failure count extracted from the ``data`` column.", + "default": 0 + }, + "matches": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Matches", + "description": "Matched log lines stored in the ``data`` column." + }, + "country_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Code", + "description": "ISO 3166-1 alpha-2 country code, or ``null`` if unknown." + }, + "country_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Name", + "description": "Human-readable country name, or ``null`` if unknown." + }, + "asn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asn", + "description": "Autonomous System Number string (e.g. ``'AS3320'``)." + }, + "org": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org", + "description": "Organisation name associated with the IP." + } + }, + "type": "object", + "required": [ + "ip", + "jail", + "banned_at", + "ban_count" + ], + "title": "HistoryBanItem", + "description": "A single row in the history ban-list table.\n\nPopulated from the fail2ban database and optionally enriched with\ngeolocation data." + }, + "HistoryListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/HistoryBanItem" + }, + "type": "array", + "title": "Items", + "description": "Data items for the current page." + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMetadata", + "description": "Pagination metadata with computed derived fields." + } + }, + "type": "object", + "required": [ + "pagination" + ], + "title": "HistoryListResponse", + "description": "Paginated history ban-list response.\n\nRequest: ``GET /api/history`` with optional time-range, jail, IP, and\norigin filters plus pagination parameters.\nResponse: Paginated collection of historical ban records with geolocation." + }, + "IgnoreIpRequest": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "IP address or CIDR network to ignore." + } + }, + "type": "object", + "required": [ + "ip" + ], + "title": "IgnoreIpRequest", + "description": "Payload for adding an IP or network to a jail's ignore list." + }, + "IgnoreListResponse": { + "properties": { + "items": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Items", + "description": "Collection items." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of items." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "IgnoreListResponse", + "description": "Response for ``GET /api/jails/{name}/ignoreip``.\n\nReturns the jailed ignore list as a standard collection response." + }, + "ImportLogEntry": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "source_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Source Id" + }, + "source_url": { + "type": "string", + "title": "Source Url" + }, + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "ips_imported": { + "type": "integer", + "title": "Ips Imported" + }, + "ips_skipped": { + "type": "integer", + "title": "Ips Skipped" + }, + "errors": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Errors" + } + }, + "type": "object", + "required": [ + "id", + "source_id", + "source_url", + "timestamp", + "ips_imported", + "ips_skipped", + "errors" + ], + "title": "ImportLogEntry", + "description": "A single blocklist import run record." + }, + "ImportLogListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ImportLogEntry" + }, + "type": "array", + "title": "Items", + "description": "Data items for the current page." + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMetadata", + "description": "Pagination metadata with computed derived fields." + } + }, + "type": "object", + "required": [ + "pagination" + ], + "title": "ImportLogListResponse", + "description": "Response for ``GET /api/blocklists/log``.\n\nPaginated list of all blocklist import runs with timestamps, source info,\nand per-source import/skip counts." + }, + "ImportRunResult": { + "properties": { + "results": { + "items": { + "$ref": "#/components/schemas/ImportSourceResult" + }, + "type": "array", + "title": "Results" + }, + "total_imported": { + "type": "integer", + "title": "Total Imported" + }, + "total_skipped": { + "type": "integer", + "title": "Total Skipped" + }, + "errors_count": { + "type": "integer", + "title": "Errors Count" + } + }, + "type": "object", + "required": [ + "total_imported", + "total_skipped", + "errors_count" + ], + "title": "ImportRunResult", + "description": "Aggregated result from a full import run across all enabled sources." + }, + "ImportSourceResult": { + "properties": { + "source_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Source Id" + }, + "source_url": { + "type": "string", + "title": "Source Url" + }, + "ips_imported": { + "type": "integer", + "title": "Ips Imported" + }, + "ips_skipped": { + "type": "integer", + "title": "Ips Skipped" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "source_id", + "source_url", + "ips_imported", + "ips_skipped", + "error" + ], + "title": "ImportSourceResult", + "description": "Result of importing a single blocklist source." + }, + "InactiveJail": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Jail name from the config section header." + }, + "filter": { + "type": "string", + "title": "Filter", + "description": "Filter name used by this jail. May include fail2ban mode suffix, e.g. ``sshd[mode=normal]``." + }, + "actions": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Actions", + "description": "Action references listed in the config (raw strings)." + }, + "port": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Port", + "description": "Port(s) to monitor, e.g. ``ssh`` or ``22,2222``." + }, + "logpath": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Logpath", + "description": "Log file paths to monitor." + }, + "bantime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Bantime", + "description": "Ban duration as a raw config string, e.g. ``10m`` or ``-1``." + }, + "findtime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Findtime", + "description": "Failure-counting window as a raw config string, e.g. ``10m``." + }, + "maxretry": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Maxretry", + "description": "Number of failures before a ban is issued." + }, + "ban_time_seconds": { + "type": "integer", + "title": "Ban Time Seconds", + "description": "Ban duration in seconds, parsed from bantime string.", + "default": 600 + }, + "find_time_seconds": { + "type": "integer", + "title": "Find Time Seconds", + "description": "Failure-counting window in seconds, parsed from findtime string.", + "default": 600 + }, + "log_encoding": { + "type": "string", + "enum": [ + "auto", + "ascii", + "utf-8", + "UTF-8", + "latin-1" + ], + "title": "Log Encoding", + "description": "Log encoding, e.g. ``utf-8`` or ``auto``.", + "default": "auto" + }, + "backend": { + "type": "string", + "enum": [ + "auto", + "polling", + "pyinotify", + "systemd", + "gamin" + ], + "title": "Backend", + "description": "Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.", + "default": "auto" + }, + "date_pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Pattern", + "description": "Date pattern for log parsing, or None for auto-detect." + }, + "use_dns": { + "type": "string", + "enum": [ + "yes", + "warn", + "no", + "raw" + ], + "title": "Use Dns", + "description": "DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.", + "default": "warn" + }, + "prefregex": { + "type": "string", + "title": "Prefregex", + "description": "Prefix regex prepended to every failregex.", + "default": "" + }, + "fail_regex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Fail Regex", + "description": "List of failure regex patterns." + }, + "ignore_regex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignore Regex", + "description": "List of ignore regex patterns." + }, + "bantime_escalation": { + "anyOf": [ + { + "$ref": "#/components/schemas/BantimeEscalation" + }, + { + "type": "null" + } + ], + "description": "Ban-time escalation configuration, if enabled." + }, + "source_file": { + "type": "string", + "title": "Source File", + "description": "Absolute path to the config file where this jail is defined." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Effective ``enabled`` value from the merged config. ``False`` for inactive jails that appear in this list." + }, + "has_local_override": { + "type": "boolean", + "title": "Has Local Override", + "description": "``True`` when a ``jail.d/{name}.local`` file exists for this jail. Only meaningful for inactive jails; indicates that a cleanup action is available.", + "default": false + } + }, + "type": "object", + "required": [ + "name", + "filter", + "source_file", + "enabled" + ], + "title": "InactiveJail", + "description": "A jail defined in fail2ban config files that is not currently active.\n\nA jail is considered inactive when its ``enabled`` key is ``false`` (or\nabsent from the config, since fail2ban defaults to disabled) **or** when it\nis explicitly enabled in config but fail2ban is not reporting it as\nrunning." + }, + "InactiveJailListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/InactiveJail" + }, + "type": "array", + "title": "Items", + "description": "Collection items." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of items." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "InactiveJailListResponse", + "description": "Response for ``GET /api/config/jails/inactive``.\n\nReturns a non-paginated collection of inactive jail configurations." + }, + "IpDetailResponse": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "The IP address." + }, + "total_bans": { + "type": "integer", + "minimum": 0.0, + "title": "Total Bans", + "description": "Total number of ban records." + }, + "total_failures": { + "type": "integer", + "minimum": 0.0, + "title": "Total Failures", + "description": "Sum of all failure counts across all ban events." + }, + "last_ban_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Ban At", + "description": "ISO 8601 UTC timestamp of the most recent ban, or ``null``." + }, + "country_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Code", + "description": "ISO 3166-1 alpha-2 country code, or ``null`` if unknown." + }, + "country_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country Name", + "description": "Human-readable country name, or ``null`` if unknown." + }, + "asn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asn", + "description": "Autonomous System Number string." + }, + "org": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Org", + "description": "Organisation name associated with the IP." + }, + "timeline": { + "items": { + "$ref": "#/components/schemas/IpTimelineEvent" + }, + "type": "array", + "title": "Timeline", + "description": "All ban events for this IP, ordered newest-first." + } + }, + "type": "object", + "required": [ + "ip", + "total_bans", + "total_failures" + ], + "title": "IpDetailResponse", + "description": "Full historical record for a single IP address.\n\nContains aggregated totals and a chronological timeline of all ban events\nrecorded in the fail2ban database for the given IP." + }, + "IpLookupResponse": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "The queried IP address." + }, + "currently_banned_in": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Currently Banned In", + "description": "Names of jails where this IP is currently banned." + }, + "geo": { + "anyOf": [ + { + "$ref": "#/components/schemas/GeoDetail" + }, + { + "type": "null" + } + ], + "description": "Enriched geographical and network information." + } + }, + "type": "object", + "required": [ + "ip" + ], + "title": "IpLookupResponse", + "description": "Response for ``GET /api/geo/lookup/{ip}``.\n\nAggregates current ban status and geographical information for an IP." + }, + "IpTimelineEvent": { + "properties": { + "jail": { + "type": "string", + "title": "Jail", + "description": "Jail that triggered this ban." + }, + "banned_at": { + "type": "string", + "title": "Banned At", + "description": "ISO 8601 UTC timestamp of the ban." + }, + "ban_count": { + "type": "integer", + "minimum": 1.0, + "title": "Ban Count", + "description": "Running ban counter for this IP at the time of this event." + }, + "failures": { + "type": "integer", + "minimum": 0.0, + "title": "Failures", + "description": "Failure count at the time of the ban.", + "default": 0 + }, + "matches": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Matches", + "description": "Matched log lines that triggered the ban." + } + }, + "type": "object", + "required": [ + "jail", + "banned_at", + "ban_count" + ], + "title": "IpTimelineEvent", + "description": "A single ban event in a per-IP timeline.\n\nRepresents one row from the fail2ban ``bans`` table for a specific IP." + }, + "Jail": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Jail name as configured in fail2ban." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the jail is currently active." + }, + "running": { + "type": "boolean", + "title": "Running", + "description": "Whether the jail backend is running." + }, + "idle": { + "type": "boolean", + "title": "Idle", + "description": "Whether the jail is in idle mode.", + "default": false + }, + "backend": { + "type": "string", + "title": "Backend", + "description": "Log monitoring backend (e.g. polling, systemd)." + }, + "log_paths": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Log Paths", + "description": "Monitored log files." + }, + "fail_regex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Fail Regex", + "description": "Failure detection regex patterns." + }, + "ignore_regex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignore Regex", + "description": "Regex patterns that bypass the ban logic." + }, + "ignore_ips": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignore Ips", + "description": "IP addresses or CIDRs on the ignore list." + }, + "date_pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Pattern", + "description": "Custom date pattern for log parsing." + }, + "log_encoding": { + "type": "string", + "title": "Log Encoding", + "description": "Log file encoding.", + "default": "UTF-8" + }, + "find_time": { + "type": "integer", + "title": "Find Time", + "description": "Time window (seconds) for counting failures." + }, + "ban_time": { + "type": "integer", + "title": "Ban Time", + "description": "Duration (seconds) of a ban. -1 means permanent." + }, + "max_retry": { + "type": "integer", + "title": "Max Retry", + "description": "Number of failures before a ban is issued." + }, + "actions": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Actions", + "description": "Names of actions attached to this jail." + }, + "bantime_escalation": { + "anyOf": [ + { + "$ref": "#/components/schemas/BantimeEscalation" + }, + { + "type": "null" + } + ], + "description": "Incremental ban-time escalation settings, or None if not configured." + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/JailStatus" + }, + { + "type": "null" + } + ], + "description": "Runtime counters." + } + }, + "type": "object", + "required": [ + "name", + "enabled", + "running", + "backend", + "find_time", + "ban_time", + "max_retry" + ], + "title": "Jail", + "description": "Domain model for a single fail2ban jail with its full configuration." + }, + "JailActivationResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the affected jail." + }, + "active": { + "type": "boolean", + "title": "Active", + "description": "New activation state: ``True`` after activate, ``False`` after deactivate." + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable result message." + }, + "fail2ban_running": { + "type": "boolean", + "title": "Fail2Ban Running", + "description": "Whether the fail2ban daemon is still running after the activation and reload. ``False`` signals that the daemon may have crashed.", + "default": true + }, + "validation_warnings": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Validation Warnings", + "description": "Non-fatal warnings from the pre-activation validation step." + }, + "recovered": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Recovered", + "description": "Set when activation failed after writing the config file. ``True`` means the system automatically rolled back the change and restarted fail2ban. ``False`` means the rollback itself also failed and manual intervention is required. ``None`` when activation succeeded or failed before the file was written." + } + }, + "type": "object", + "required": [ + "name", + "active", + "message" + ], + "title": "JailActivationResponse", + "description": "Response for jail activation and deactivation endpoints." + }, + "JailBanCount": { + "properties": { + "jail": { + "type": "string", + "title": "Jail", + "description": "Jail name." + }, + "count": { + "type": "integer", + "minimum": 0.0, + "title": "Count", + "description": "Number of bans recorded in this jail." + } + }, + "type": "object", + "required": [ + "jail", + "count" + ], + "title": "JailBanCount", + "description": "A single jail entry in the bans-by-jail aggregation." + }, + "JailBannedIpsResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ActiveBan" + }, + "type": "array", + "title": "Items", + "description": "Data items for the current page." + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMetadata", + "description": "Pagination metadata with computed derived fields." + } + }, + "type": "object", + "required": [ + "pagination" + ], + "title": "JailBannedIpsResponse", + "description": "Paginated response for ``GET /api/jails/{name}/banned``.\n\nContains only the current page of active ban entries for a single jail,\ngeo-enriched exclusively for the page slice to avoid rate-limit issues.\n\nRequest: `GET /api/jails/{name}/banned` with page and page_size parameters.\nResponse: Paginated collection of active bans for the specified jail." + }, + "JailCommandResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable result or error message." + }, + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the command succeeded (false for errors in non-exception handlers).", + "default": true + }, + "jail": { + "type": "string", + "title": "Jail", + "description": "Target jail name, or '*' for operations on all jails." + } + }, + "type": "object", + "required": [ + "message", + "jail" + ], + "title": "JailCommandResponse", + "description": "Generic response for jail control commands (start, stop, reload, idle).\n\nExtends the base CommandResponse with a jail field to identify the target." + }, + "JailConfig": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Jail name as configured in fail2ban." + }, + "ban_time": { + "type": "integer", + "title": "Ban Time", + "description": "Ban duration in seconds. -1 for permanent." + }, + "max_retry": { + "type": "integer", + "minimum": 1.0, + "title": "Max Retry", + "description": "Number of failures before a ban is issued." + }, + "find_time": { + "type": "integer", + "minimum": 1.0, + "title": "Find Time", + "description": "Time window (seconds) for counting failures." + }, + "fail_regex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Fail Regex", + "description": "Failure detection regex patterns." + }, + "ignore_regex": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignore Regex", + "description": "Regex patterns that bypass the ban logic." + }, + "log_paths": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Log Paths", + "description": "Monitored log files." + }, + "date_pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Pattern", + "description": "Custom date pattern for log parsing." + }, + "log_encoding": { + "type": "string", + "enum": [ + "auto", + "ascii", + "utf-8", + "UTF-8", + "latin-1" + ], + "title": "Log Encoding", + "description": "Log file encoding.", + "default": "UTF-8" + }, + "backend": { + "type": "string", + "enum": [ + "auto", + "polling", + "pyinotify", + "systemd", + "gamin" + ], + "title": "Backend", + "description": "Log monitoring backend.", + "default": "polling" + }, + "use_dns": { + "type": "string", + "enum": [ + "yes", + "warn", + "no", + "raw" + ], + "title": "Use Dns", + "description": "DNS lookup mode: yes | warn | no | raw.", + "default": "warn" + }, + "prefregex": { + "type": "string", + "title": "Prefregex", + "description": "Prefix regex prepended to every failregex; empty means disabled.", + "default": "" + }, + "actions": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Actions", + "description": "Names of actions attached to this jail." + }, + "bantime_escalation": { + "anyOf": [ + { + "$ref": "#/components/schemas/BantimeEscalation" + }, + { + "type": "null" + } + ], + "description": "Incremental ban-time escalation settings, or None if not configured." + } + }, + "type": "object", + "required": [ + "name", + "ban_time", + "max_retry", + "find_time" + ], + "title": "JailConfig", + "description": "Configuration snapshot of a single jail (editable fields)." + }, + "JailConfigFile": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Jail name (file stem, e.g. ``sshd``)." + }, + "filename": { + "type": "string", + "title": "Filename", + "description": "Actual filename (e.g. ``sshd.conf``)." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the jail is enabled. Derived from the ``enabled`` key inside the file; defaults to ``true`` when the key is absent." + } + }, + "type": "object", + "required": [ + "name", + "filename", + "enabled" + ], + "title": "JailConfigFile", + "description": "Metadata for a single jail configuration file in ``jail.d/``." + }, + "JailConfigFileContent": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Jail name (file stem)." + }, + "filename": { + "type": "string", + "title": "Filename", + "description": "Actual filename." + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Whether the jail is enabled." + }, + "content": { + "type": "string", + "title": "Content", + "description": "Raw file content." + } + }, + "type": "object", + "required": [ + "name", + "filename", + "enabled", + "content" + ], + "title": "JailConfigFileContent", + "description": "Single jail config file with its raw content." + }, + "JailConfigFileEnabledUpdate": { + "properties": { + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "New enabled state for this jail." + } + }, + "type": "object", + "required": [ + "enabled" + ], + "title": "JailConfigFileEnabledUpdate", + "description": "Payload for ``PUT /api/config/jail-files/{filename}/enabled``." + }, + "JailConfigFilesResponse": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/JailConfigFile" + }, + "type": "array", + "title": "Files" + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total" + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "JailConfigFilesResponse", + "description": "Response for ``GET /api/config/jail-files``." + }, + "JailConfigListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/JailConfig" + }, + "type": "array", + "title": "Items", + "description": "Collection items." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of items." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "JailConfigListResponse", + "description": "Response for ``GET /api/config/jails``.\n\nReturns a non-paginated collection of jail configurations." + }, + "JailConfigResponse": { + "properties": { + "jail": { + "$ref": "#/components/schemas/JailConfig" + } + }, + "type": "object", + "required": [ + "jail" + ], + "title": "JailConfigResponse", + "description": "Response for ``GET /api/config/jails/{name}``." + }, + "JailConfigUpdate": { + "properties": { + "ban_time": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Ban Time", + "description": "Ban duration in seconds. -1 for permanent." + }, + "max_retry": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Max Retry" + }, + "find_time": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Find Time" + }, + "fail_regex": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Fail Regex", + "description": "Failure detection regex patterns." + }, + "ignore_regex": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Ignore Regex" + }, + "prefregex": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prefregex", + "description": "Prefix regex; None = skip, '' = clear, non-empty = set." + }, + "date_pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date Pattern" + }, + "dns_mode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "yes", + "warn", + "no", + "raw" + ] + }, + { + "type": "null" + } + ], + "title": "Dns Mode", + "description": "DNS lookup mode: yes | warn | no | raw." + }, + "backend": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "polling", + "pyinotify", + "systemd", + "gamin" + ] + }, + { + "type": "null" + } + ], + "title": "Backend", + "description": "Log monitoring backend." + }, + "log_encoding": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "ascii", + "utf-8", + "UTF-8", + "latin-1" + ] + }, + { + "type": "null" + } + ], + "title": "Log Encoding", + "description": "Log file encoding." + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + }, + "bantime_escalation": { + "anyOf": [ + { + "$ref": "#/components/schemas/BantimeEscalationUpdate" + }, + { + "type": "null" + } + ], + "description": "Incremental ban-time escalation settings to update." + } + }, + "type": "object", + "title": "JailConfigUpdate", + "description": "Payload for ``PUT /api/config/jails/{name}``." + }, + "JailDetailResponse": { + "properties": { + "jail": { + "$ref": "#/components/schemas/Jail" + }, + "ignore_list": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ignore List", + "description": "List of IP addresses and networks currently ignored by the jail." + }, + "ignore_self": { + "type": "boolean", + "title": "Ignore Self", + "description": "Whether the jail ignores the server's own IP addresses.", + "default": false + } + }, + "type": "object", + "required": [ + "jail" + ], + "title": "JailDetailResponse", + "description": "Response for ``GET /api/jails/{name}``.\n\nIncludes the primary jail object together with supplemental metadata\nrequired by the UI." + }, + "JailFileConfig": { + "properties": { + "filename": { + "type": "string", + "title": "Filename", + "description": "Filename including extension (e.g. 'sshd.conf')." + }, + "jails": { + "additionalProperties": { + "$ref": "#/components/schemas/JailSectionConfig" + }, + "type": "object", + "title": "Jails", + "description": "Mapping of jail name \u2192 settings for each [section] in the file." + } + }, + "type": "object", + "required": [ + "filename" + ], + "title": "JailFileConfig", + "description": "Structured representation of a jail.d/*.conf file." + }, + "JailFileConfigUpdate": { + "properties": { + "jails": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/JailSectionConfig" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Jails", + "description": "Jail section updates. Only jails present in this dict are updated." + } + }, + "type": "object", + "title": "JailFileConfigUpdate", + "description": "Partial update payload for a jail.d file." + }, + "JailListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/JailSummary" + }, + "type": "array", + "title": "Items", + "description": "Collection items." + }, + "total": { + "type": "integer", + "minimum": 0.0, + "title": "Total", + "description": "Total number of items." + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "JailListResponse", + "description": "Response for ``GET /api/jails``.\n\nReturns a non-paginated collection of jail summaries with their current status." + }, + "JailSectionConfig": { + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled", + "description": "Whether this jail is enabled." + }, + "port": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Port", + "description": "Port(s) to monitor (e.g. 'ssh' or '22,2222')." + }, + "filter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter", + "description": "Filter name to use (e.g. 'sshd')." + }, + "logpath": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Logpath", + "description": "Log file paths to monitor." + }, + "maxretry": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Maxretry", + "description": "Failures before banning." + }, + "findtime": { + "anyOf": [ + { + "type": "integer", + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Findtime", + "description": "Time window in seconds for counting failures." + }, + "bantime": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Bantime", + "description": "Ban duration in seconds. -1 for permanent." + }, + "action": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Action", + "description": "Action references." + }, + "backend": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "polling", + "pyinotify", + "systemd", + "gamin" + ] + }, + { + "type": "null" + } + ], + "title": "Backend", + "description": "Log monitoring backend." + }, + "extra": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Extra", + "description": "Additional settings not captured by named fields." + } + }, + "type": "object", + "title": "JailSectionConfig", + "description": "Settings within a single [jailname] section of a jail.d file." + }, + "JailStatus": { + "properties": { + "currently_banned": { + "type": "integer", + "minimum": 0.0, + "title": "Currently Banned" + }, + "total_banned": { + "type": "integer", + "minimum": 0.0, + "title": "Total Banned" + }, + "currently_failed": { + "type": "integer", + "minimum": 0.0, + "title": "Currently Failed" + }, + "total_failed": { + "type": "integer", + "minimum": 0.0, + "title": "Total Failed" + } + }, + "type": "object", + "required": [ + "currently_banned", + "total_banned", + "currently_failed", + "total_failed" + ], + "title": "JailStatus", + "description": "Runtime metrics for a single jail." + }, + "JailSummary": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "enabled": { + "type": "boolean", + "title": "Enabled" + }, + "running": { + "type": "boolean", + "title": "Running" + }, + "idle": { + "type": "boolean", + "title": "Idle" + }, + "backend": { + "type": "string", + "title": "Backend" + }, + "find_time": { + "type": "integer", + "title": "Find Time" + }, + "ban_time": { + "type": "integer", + "title": "Ban Time" + }, + "max_retry": { + "type": "integer", + "title": "Max Retry" + }, + "status": { + "anyOf": [ + { + "$ref": "#/components/schemas/JailStatus" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "enabled", + "running", + "idle", + "backend", + "find_time", + "ban_time", + "max_retry" + ], + "title": "JailSummary", + "description": "Lightweight jail entry for the overview list." + }, + "JailValidationIssue": { + "properties": { + "field": { + "type": "string", + "title": "Field", + "description": "Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'." + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable description of the issue." + } + }, + "type": "object", + "required": [ + "field", + "message" + ], + "title": "JailValidationIssue", + "description": "A single issue found during pre-activation validation of a jail config." + }, + "JailValidationResult": { + "properties": { + "jail_name": { + "type": "string", + "title": "Jail Name", + "description": "Name of the validated jail." + }, + "valid": { + "type": "boolean", + "title": "Valid", + "description": "True when no issues were found." + }, + "issues": { + "items": { + "$ref": "#/components/schemas/JailValidationIssue" + }, + "type": "array", + "title": "Issues", + "description": "Validation issues found. Empty when valid=True." + } + }, + "type": "object", + "required": [ + "jail_name", + "valid" + ], + "title": "JailValidationResult", + "description": "Result of pre-activation validation of a single jail configuration." + }, + "LogPreviewLine": { + "properties": { + "line": { + "type": "string", + "title": "Line" + }, + "matched": { + "type": "boolean", + "title": "Matched" + }, + "groups": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Groups" + } + }, + "type": "object", + "required": [ + "line", + "matched" + ], + "title": "LogPreviewLine", + "description": "A single log line with match information." + }, + "LogPreviewRequest": { + "properties": { + "log_path": { + "type": "string", + "title": "Log Path", + "description": "Absolute path to the log file to preview." + }, + "fail_regex": { + "type": "string", + "title": "Fail Regex", + "description": "Regex pattern to test against log lines." + }, + "num_lines": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Num Lines", + "description": "Number of lines to read from the end of the file.", + "default": 200 + } + }, + "type": "object", + "required": [ + "log_path", + "fail_regex" + ], + "title": "LogPreviewRequest", + "description": "Payload for ``POST /api/config/preview-log``." + }, + "LogPreviewResponse": { + "properties": { + "lines": { + "items": { + "$ref": "#/components/schemas/LogPreviewLine" + }, + "type": "array", + "title": "Lines" + }, + "total_lines": { + "type": "integer", + "minimum": 0.0, + "title": "Total Lines" + }, + "matched_count": { + "type": "integer", + "minimum": 0.0, + "title": "Matched Count" + }, + "regex_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Regex Error", + "description": "Set if the regex failed to compile." + } + }, + "type": "object", + "required": [ + "total_lines", + "matched_count" + ], + "title": "LogPreviewResponse", + "description": "Response for ``POST /api/config/preview-log``." + }, + "LoginRequest": { + "properties": { + "password": { + "type": "string", + "maxLength": 72, + "title": "Password", + "description": "Master password to authenticate with (max 72 bytes due to bcrypt truncation)." + } + }, + "type": "object", + "required": [ + "password" + ], + "title": "LoginRequest", + "description": "Payload for ``POST /api/auth/login``." + }, + "LoginResponse": { + "properties": { + "expires_at": { + "type": "string", + "title": "Expires At", + "description": "ISO 8601 UTC expiry timestamp." + } + }, + "type": "object", + "required": [ + "expires_at" + ], + "title": "LoginResponse", + "description": "Successful login response.\n\nThe session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie by the\nrouter, protecting it from JavaScript access. The JSON body contains only\nthe expiry timestamp, allowing the frontend to know when to prompt for\nre-authentication.\n\nFor programmatic API clients that require a token in the response body,\nuse ``POST /api/auth/token`` instead, which does not set a cookie." + }, + "LogoutResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "default": "Logged out successfully." + } + }, + "type": "object", + "title": "LogoutResponse", + "description": "Response body for ``POST /api/auth/logout``." + }, + "MapColorThresholdsResponse": { + "properties": { + "threshold_high": { + "type": "integer", + "title": "Threshold High", + "description": "Ban count for red coloring." + }, + "threshold_medium": { + "type": "integer", + "title": "Threshold Medium", + "description": "Ban count for yellow coloring." + }, + "threshold_low": { + "type": "integer", + "title": "Threshold Low", + "description": "Ban count for green coloring." + } + }, + "type": "object", + "required": [ + "threshold_high", + "threshold_medium", + "threshold_low" + ], + "title": "MapColorThresholdsResponse", + "description": "Response for ``GET /api/config/map-thresholds``." + }, + "MapColorThresholdsUpdate": { + "properties": { + "threshold_high": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Threshold High", + "description": "Ban count for red." + }, + "threshold_medium": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Threshold Medium", + "description": "Ban count for yellow." + }, + "threshold_low": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Threshold Low", + "description": "Ban count for green." + } + }, + "type": "object", + "required": [ + "threshold_high", + "threshold_medium", + "threshold_low" + ], + "title": "MapColorThresholdsUpdate", + "description": "Payload for ``PUT /api/config/map-thresholds``." + }, + "PaginationMetadata": { + "properties": { + "page": { + "type": "integer", + "minimum": 1.0, + "title": "Page", + "description": "Current page number (1-based). Set to 1 for cursor pagination." + }, + "page_size": { + "type": "integer", + "minimum": 1.0, + "title": "Page Size", + "description": "Number of items per page." + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Total number of items matching the query. -1 if unknown (cursor pagination)." + }, + "total_pages": { + "type": "integer", + "title": "Total Pages", + "description": "Computed total number of pages. -1 if unknown (cursor pagination)." + }, + "has_next_page": { + "type": "boolean", + "title": "Has Next Page", + "description": "Whether there is a next page after this one." + }, + "has_prev_page": { + "type": "boolean", + "title": "Has Prev Page", + "description": "Whether there is a previous page before this one." + }, + "cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cursor", + "description": "Opaque cursor token for fetching the next page (cursor pagination only)." + }, + "pagination_mode": { + "type": "string", + "enum": [ + "offset", + "cursor" + ], + "title": "Pagination Mode", + "description": "Pagination mode used by the endpoint. 'offset' uses page/page_size; 'cursor' uses cursor tokens.", + "default": "offset" + } + }, + "type": "object", + "required": [ + "page", + "page_size", + "total", + "total_pages", + "has_next_page", + "has_prev_page" + ], + "title": "PaginationMetadata", + "description": "Pagination metadata embedded in paginated list responses.\n\nContains page information and computed fields to support frontend pagination controls.\nSupports both offset-based and cursor-based pagination modes.\n\nFields:\n page: Current page number (1-based). Set to 1 for cursor pagination.\n page_size: Number of items per page.\n total: Total number of items matching the query (across all pages).\n For cursor pagination, this is -1 (unknown without full scan).\n total_pages: Computed total number of pages.\n For cursor pagination, this is -1 (unknown without full scan).\n has_next_page: Whether there is a next page after this one.\n has_prev_page: Whether there is a previous page before this one.\n Always False for cursor pagination (cannot navigate backward without storing history).\n cursor: Opaque cursor token for fetching the next page (cursor pagination only).\n None for offset pagination or when there are no more pages.\n pagination_mode: Pagination mode used by the endpoint. 'offset' uses page/page_size;\n 'cursor' uses cursor tokens for navigation.\n\nExample (offset pagination):\n ```python\n pagination = PaginationMetadata(\n page=2,\n page_size=50,\n total=150,\n total_pages=3,\n has_next_page=True,\n has_prev_page=True,\n cursor=None,\n pagination_mode=\"offset\",\n )\n ```\n\nExample (cursor pagination):\n ```python\n pagination = PaginationMetadata(\n page=1,\n page_size=50,\n total=-1,\n total_pages=-1,\n has_next_page=True,\n has_prev_page=False,\n cursor=\"eyJpZCI6IDQyN30=\",\n pagination_mode=\"cursor\",\n )\n ```" + }, + "PendingRecovery": { + "properties": { + "jail_name": { + "type": "string", + "title": "Jail Name", + "description": "Name of the jail whose activation likely caused the crash." + }, + "activated_at": { + "type": "string", + "format": "date-time", + "title": "Activated At", + "description": "ISO-8601 UTC timestamp of when the jail was activated." + }, + "detected_at": { + "type": "string", + "format": "date-time", + "title": "Detected At", + "description": "ISO-8601 UTC timestamp of when the crash was detected." + }, + "recovered": { + "type": "boolean", + "title": "Recovered", + "description": "Whether fail2ban has been successfully restarted.", + "default": false + } + }, + "type": "object", + "required": [ + "jail_name", + "activated_at", + "detected_at" + ], + "title": "PendingRecovery", + "description": "Records a probable activation-caused fail2ban crash pending user action." + }, + "PreviewResponse": { + "properties": { + "entries": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Entries", + "description": "Sample of valid IP entries" + }, + "total_lines": { + "type": "integer", + "title": "Total Lines" + }, + "valid_count": { + "type": "integer", + "title": "Valid Count" + }, + "skipped_count": { + "type": "integer", + "title": "Skipped Count" + } + }, + "type": "object", + "required": [ + "total_lines", + "valid_count", + "skipped_count" + ], + "title": "PreviewResponse", + "description": "Response for ``GET /api/blocklists/{id}/preview``." + }, + "ReadyCheck": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Subsystem name." + }, + "healthy": { + "type": "boolean", + "title": "Healthy", + "description": "True when the subsystem is operational." + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Error detail when the check fails." + } + }, + "type": "object", + "required": [ + "name", + "healthy" + ], + "title": "ReadyCheck", + "description": "Result of a single readiness subsystem check.\n\nFields:\n name: Subsystem name (e.g., \"database\", \"fail2ban\", \"config_dir\").\n healthy: True when the subsystem is reachable/operational.\n message: Optional error message describing the failure." + }, + "ReadyResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ], + "title": "Status", + "description": "'ok' when all checks pass, 'error' when at least one fails." + }, + "checks": { + "items": { + "$ref": "#/components/schemas/ReadyCheck" + }, + "type": "array", + "title": "Checks", + "description": "Per-subsystem check results." + }, + "failed_count": { + "type": "integer", + "minimum": 0.0, + "title": "Failed Count", + "description": "Number of checks that returned healthy=False." + } + }, + "type": "object", + "required": [ + "status", + "failed_count" + ], + "title": "ReadyResponse", + "description": "Structured readiness check response for the ``/health/ready`` endpoint.\n\nFields:\n status: \"ok\" when all checks pass, \"error\" when at least one failed.\n checks: Per-subsystem result list.\n failed_count: Number of checks that returned healthy=False.\n\nExample:\n ```python\n # All healthy (HTTP 200)\n {\"status\": \"ok\", \"checks\": [...], \"failed_count\": 0}\n\n # Some failed (HTTP 503)\n {\"status\": \"error\", \"checks\": [...], \"failed_count\": 2}\n ```" + }, + "RegexTestRequest": { + "properties": { + "log_line": { + "type": "string", + "title": "Log Line", + "description": "Sample log line to test against." + }, + "fail_regex": { + "type": "string", + "title": "Fail Regex", + "description": "Regex pattern to match." + } + }, + "type": "object", + "required": [ + "log_line", + "fail_regex" + ], + "title": "RegexTestRequest", + "description": "Payload for ``POST /api/config/regex-test``." + }, + "RegexTestResponse": { + "properties": { + "matched": { + "type": "boolean", + "title": "Matched", + "description": "Whether the pattern matched the log line." + }, + "groups": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Groups", + "description": "Named groups captured by a successful match." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error", + "description": "Compilation error message if the regex is invalid." + } + }, + "type": "object", + "required": [ + "matched" + ], + "title": "RegexTestResponse", + "description": "Result of a regex test." + }, + "RollbackResponse": { + "properties": { + "jail_name": { + "type": "string", + "title": "Jail Name", + "description": "Name of the jail that was disabled." + }, + "disabled": { + "type": "boolean", + "title": "Disabled", + "description": "Whether the jail's .local override was successfully written with enabled=false." + }, + "fail2ban_running": { + "type": "boolean", + "title": "Fail2Ban Running", + "description": "Whether fail2ban is online after the rollback attempt." + }, + "active_jails": { + "type": "integer", + "minimum": 0.0, + "title": "Active Jails", + "description": "Number of currently active jails after a successful restart.", + "default": 0 + }, + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable result message." + } + }, + "type": "object", + "required": [ + "jail_name", + "disabled", + "fail2ban_running", + "message" + ], + "title": "RollbackResponse", + "description": "Response for ``POST /api/config/jails/{name}/rollback``." + }, + "ScheduleConfig": { + "properties": { + "frequency": { + "$ref": "#/components/schemas/ScheduleFrequency", + "default": "daily" + }, + "interval_hours": { + "type": "integer", + "maximum": 168.0, + "minimum": 1.0, + "title": "Interval Hours", + "description": "Used when frequency=hourly", + "default": 24 + }, + "hour": { + "type": "integer", + "maximum": 23.0, + "minimum": 0.0, + "title": "Hour", + "description": "UTC hour for daily/weekly runs", + "default": 3 + }, + "minute": { + "type": "integer", + "maximum": 59.0, + "minimum": 0.0, + "title": "Minute", + "description": "Minute for daily/weekly runs", + "default": 0 + }, + "day_of_week": { + "type": "integer", + "maximum": 6.0, + "minimum": 0.0, + "title": "Day Of Week", + "description": "Day of week for weekly runs (0=Monday \u2026 6=Sunday)", + "default": 0 + } + }, + "type": "object", + "title": "ScheduleConfig", + "description": "Import schedule configuration.\n\nThe interpretation of fields depends on *frequency*:\n\n- ``hourly``: ``interval_hours`` controls how often (every N hours).\n- ``daily``: ``hour`` and ``minute`` specify the daily run time (UTC).\n- ``weekly``: additionally uses ``day_of_week`` (0=Monday \u2026 6=Sunday)." + }, + "ScheduleFrequency": { + "type": "string", + "enum": [ + "hourly", + "daily", + "weekly" + ], + "title": "ScheduleFrequency", + "description": "Available import schedule frequency presets." + }, + "ScheduleInfo": { + "properties": { + "config": { + "$ref": "#/components/schemas/ScheduleConfig" + }, + "next_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Run At" + }, + "last_run_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Run At" + }, + "last_run_errors": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Last Run Errors" + } + }, + "type": "object", + "required": [ + "config", + "next_run_at", + "last_run_at" + ], + "title": "ScheduleInfo", + "description": "Current schedule configuration together with runtime metadata." + }, + "SecurityHeadersResponse": { + "properties": { + "csrf_header_name": { + "type": "string", + "title": "Csrf Header Name", + "description": "Name of the custom header required for state-mutating requests." + }, + "csrf_header_value": { + "type": "string", + "title": "Csrf Header Value", + "description": "Required value of the CSRF header to pass validation." + } + }, + "type": "object", + "required": [ + "csrf_header_name", + "csrf_header_value" + ], + "title": "SecurityHeadersResponse", + "description": "Security-relevant header names and values used by the frontend." + }, + "ServerSettings": { + "properties": { + "log_level": { + "type": "string", + "title": "Log Level", + "description": "fail2ban daemon log level." + }, + "log_target": { + "type": "string", + "title": "Log Target", + "description": "Log destination: STDOUT, STDERR, SYSLOG, or a file path." + }, + "syslog_socket": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Syslog Socket" + }, + "db_path": { + "type": "string", + "title": "Db Path", + "description": "Path to the fail2ban ban history database." + }, + "db_purge_age": { + "type": "integer", + "title": "Db Purge Age", + "description": "Seconds before old records are purged." + }, + "db_max_matches": { + "type": "integer", + "title": "Db Max Matches", + "description": "Maximum stored matches per ban record." + } + }, + "type": "object", + "required": [ + "log_level", + "log_target", + "db_path", + "db_purge_age", + "db_max_matches" + ], + "title": "ServerSettings", + "description": "Domain model for fail2ban server-level settings." + }, + "ServerSettingsResponse": { + "properties": { + "settings": { + "$ref": "#/components/schemas/ServerSettings" + }, + "warnings": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object", + "title": "Warnings", + "description": "Warnings highlighting potentially unsafe settings." + } + }, + "type": "object", + "required": [ + "settings" + ], + "title": "ServerSettingsResponse", + "description": "Response for ``GET /api/server/settings``." + }, + "ServerSettingsUpdate": { + "properties": { + "log_level": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Log Level" + }, + "log_target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Log Target" + }, + "db_purge_age": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Db Purge Age" + }, + "db_max_matches": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Db Max Matches" + } + }, + "type": "object", + "title": "ServerSettingsUpdate", + "description": "Payload for ``PUT /api/server/settings``." + }, + "ServerStatus": { + "properties": { + "online": { + "type": "boolean", + "title": "Online", + "description": "Whether fail2ban is reachable via its socket." + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version", + "description": "fail2ban version string." + }, + "active_jails": { + "type": "integer", + "minimum": 0.0, + "title": "Active Jails", + "description": "Number of currently active jails.", + "default": 0 + }, + "total_bans": { + "type": "integer", + "minimum": 0.0, + "title": "Total Bans", + "description": "Aggregated current ban count across all jails.", + "default": 0 + }, + "total_failures": { + "type": "integer", + "minimum": 0.0, + "title": "Total Failures", + "description": "Aggregated current failure count across all jails.", + "default": 0 + } + }, + "type": "object", + "required": [ + "online" + ], + "title": "ServerStatus", + "description": "Cached fail2ban server health snapshot." + }, + "ServerStatusResponse": { + "properties": { + "status": { + "$ref": "#/components/schemas/ServerStatus" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "ServerStatusResponse", + "description": "Response for ``GET /api/dashboard/status``." + }, + "ServiceStatusResponse": { + "properties": { + "online": { + "type": "boolean", + "title": "Online", + "description": "Whether fail2ban is reachable via its socket." + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Version", + "description": "BanGUI application version (or None when offline)." + }, + "jail_count": { + "type": "integer", + "minimum": 0.0, + "title": "Jail Count", + "description": "Number of currently active jails.", + "default": 0 + }, + "total_bans": { + "type": "integer", + "minimum": 0.0, + "title": "Total Bans", + "description": "Aggregated current ban count across all jails.", + "default": 0 + }, + "total_failures": { + "type": "integer", + "minimum": 0.0, + "title": "Total Failures", + "description": "Aggregated current failure count across all jails.", + "default": 0 + }, + "log_level": { + "type": "string", + "title": "Log Level", + "description": "Current fail2ban log level.", + "default": "UNKNOWN" + }, + "log_target": { + "type": "string", + "title": "Log Target", + "description": "Current fail2ban log target.", + "default": "UNKNOWN" + } + }, + "type": "object", + "required": [ + "online" + ], + "title": "ServiceStatusResponse", + "description": "Response for ``GET /api/config/service-status``." + }, + "SessionValidResponse": { + "properties": { + "valid": { + "type": "boolean", + "title": "Valid", + "description": "Whether the session is valid and active.", + "default": true + } + }, + "type": "object", + "title": "SessionValidResponse", + "description": "Response for ``GET /api/auth/session`` confirming session validity." + }, + "SetupRequest": { + "properties": { + "master_password": { + "type": "string", + "maxLength": 72, + "minLength": 8, + "title": "Master Password", + "description": "Master password that protects the BanGUI interface (max 72 bytes due to bcrypt truncation)." + }, + "database_path": { + "type": "string", + "title": "Database Path", + "description": "Filesystem path to the BanGUI SQLite application database.", + "default": "bangui.db" + }, + "fail2ban_socket": { + "type": "string", + "title": "Fail2Ban Socket", + "description": "Path to the fail2ban Unix domain socket.", + "default": "/var/run/fail2ban/fail2ban.sock" + }, + "timezone": { + "type": "string", + "title": "Timezone", + "description": "IANA timezone name used when displaying timestamps.", + "default": "UTC" + }, + "session_duration_minutes": { + "type": "integer", + "minimum": 1.0, + "title": "Session Duration Minutes", + "description": "Number of minutes a user session remains valid.", + "default": 60 + } + }, + "type": "object", + "required": [ + "master_password" + ], + "title": "SetupRequest", + "description": "Payload for ``POST /api/setup``." + }, + "SetupResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "default": "Setup completed successfully. Please log in." + } + }, + "type": "object", + "title": "SetupResponse", + "description": "Response returned after a successful initial setup." + }, + "SetupStatusResponse": { + "properties": { + "completed": { + "type": "boolean", + "title": "Completed", + "description": "``True`` if the initial setup has already been performed." + } + }, + "type": "object", + "required": [ + "completed" + ], + "title": "SetupStatusResponse", + "description": "Response indicating whether setup has been completed." + }, + "SetupTimezoneResponse": { + "properties": { + "timezone": { + "type": "string", + "title": "Timezone", + "description": "Configured IANA timezone identifier." + } + }, + "type": "object", + "required": [ + "timezone" + ], + "title": "SetupTimezoneResponse", + "description": "Response for ``GET /api/setup/timezone``." + }, + "UnbanAllResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message", + "description": "Human-readable summary of the operation." + }, + "count": { + "type": "integer", + "minimum": 0.0, + "title": "Count", + "description": "Number of IPs that were unbanned." + } + }, + "type": "object", + "required": [ + "message", + "count" + ], + "title": "UnbanAllResponse", + "description": "Response for ``DELETE /api/bans/all``." + }, + "UnbanRequest": { + "properties": { + "ip": { + "type": "string", + "title": "Ip", + "description": "IP address to unban." + }, + "jail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jail", + "description": "Jail to remove the ban from. ``null`` means all jails." + }, + "unban_all": { + "type": "boolean", + "title": "Unban All", + "description": "When ``true`` the IP is unbanned from every jail.", + "default": false + } + }, + "type": "object", + "required": [ + "ip" + ], + "title": "UnbanRequest", + "description": "Payload for ``DELETE /api/bans`` (unban an IP)." + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index be3d6d0..706c7db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,12 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.19", + "version": "0.9.19-rc.1", "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", diff --git a/frontend/scripts/validate-types.sh b/frontend/scripts/validate-types.sh index 4667228..8d575a3 100644 --- a/frontend/scripts/validate-types.sh +++ b/frontend/scripts/validate-types.sh @@ -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 diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.tsx b/frontend/src/components/__tests__/ErrorBoundary.test.tsx index 2facd1d..2740d9a 100644 --- a/frontend/src/components/__tests__/ErrorBoundary.test.tsx +++ b/frontend/src/components/__tests__/ErrorBoundary.test.tsx @@ -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"); diff --git a/frontend/src/hooks/__tests__/useFetchData.test.ts b/frontend/src/hooks/__tests__/useFetchData.test.ts index b185ce2..7aa0beb 100644 --- a/frontend/src/hooks/__tests__/useFetchData.test.ts +++ b/frontend/src/hooks/__tests__/useFetchData.test.ts @@ -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); diff --git a/frontend/src/hooks/__tests__/useJailBannedIps.test.ts b/frontend/src/hooks/__tests__/useJailBannedIps.test.ts index 2dd313b..724210a 100644 --- a/frontend/src/hooks/__tests__/useJailBannedIps.test.ts +++ b/frontend/src/hooks/__tests__/useJailBannedIps.test.ts @@ -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")); diff --git a/frontend/src/hooks/__tests__/usePolledData.test.ts b/frontend/src/hooks/__tests__/usePolledData.test.ts index ca37cbc..4989dc9 100644 --- a/frontend/src/hooks/__tests__/usePolledData.test.ts +++ b/frontend/src/hooks/__tests__/usePolledData.test.ts @@ -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 diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index fc00c8f..1392ff2 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -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: { -- 2.49.1 From dcee222a418dd7ebbdae1a8c2aa05833aa9484fe Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 22 May 2026 20:38:33 +0200 Subject: [PATCH 02/14] chore: release v0.9.19-rc.2 --- Docker/VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Docker/VERSION b/Docker/VERSION index 3c05eb4..4437455 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.19-rc.1 +v0.9.19-rc.2 diff --git a/frontend/package.json b/frontend/package.json index 706c7db..333bb13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.19-rc.1", + "version": "0.9.19-rc.2", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { -- 2.49.1 From 2e5ac092bf979040f7131ec6529767a360a1ce7e Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 22 May 2026 20:47:57 +0200 Subject: [PATCH 03/14] 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. --- frontend/src/providers/AuthProvider.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/providers/AuthProvider.tsx b/frontend/src/providers/AuthProvider.tsx index a0f6242..03c0afe 100644 --- a/frontend/src/providers/AuthProvider.tsx +++ b/frontend/src/providers/AuthProvider.tsx @@ -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); }, -- 2.49.1 From 9d2d6fadf34b68a555a460e7dc590356d4475440 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 22 May 2026 20:49:12 +0200 Subject: [PATCH 04/14] chore: release v0.9.19-rc.3 --- Docker/VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Docker/VERSION b/Docker/VERSION index 4437455..98cbc29 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.19-rc.2 +v0.9.19-rc.3 diff --git a/frontend/package.json b/frontend/package.json index 333bb13..d1580a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.19-rc.2", + "version": "0.9.19-rc.3", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { -- 2.49.1 From 9fe52755a581c8bdb2c22ac61750ef97712c30e3 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 22 May 2026 21:47:32 +0200 Subject: [PATCH 05/14] fix(db): fix migration failures when upgrading from 0.8.0 schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- backend/app/db.py | 27 ++++++++++++++++++++++++--- backend/pyproject.toml | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/app/db.py b/backend/app/db.py index e448d44..6f4b74e 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5b268d5..c28ed5e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bangui-backend" -version = "0.9.19-rc.1" +version = "0.9.19-rc.3" description = "BanGUI backend — fail2ban web management interface" requires-python = ">=3.12" dependencies = [ -- 2.49.1 From 99e1b7440593d0ff412a7ec9350bf253dbd2060f Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 22 May 2026 21:49:01 +0200 Subject: [PATCH 06/14] chore: release v0.9.19-rc.4 --- Docker/VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Docker/VERSION b/Docker/VERSION index 98cbc29..c8ac566 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.19-rc.3 +v0.9.19-rc.4 diff --git a/frontend/package.json b/frontend/package.json index d1580a8..26cc48a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.19-rc.3", + "version": "0.9.19-rc.4", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { -- 2.49.1 From aebe0d023689b876820caa620625a5d237198704 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 21:27:52 +0200 Subject: [PATCH 07/14] 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 --- Docker/compose.prod.yml | 105 ++++++++++++++++++++++++++ backend/pyproject.toml | 2 +- check_auth.py | 147 +++++++++++++++++++++++++++++++++++++ frontend/package-lock.json | 4 +- 4 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 Docker/compose.prod.yml create mode 100644 check_auth.py diff --git a/Docker/compose.prod.yml b/Docker/compose.prod.yml new file mode 100644 index 0000000..dc91cad --- /dev/null +++ b/Docker/compose.prod.yml @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c28ed5e..4009727 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bangui-backend" -version = "0.9.19-rc.3" +version = "0.9.19-rc.4" description = "BanGUI backend — fail2ban web management interface" requires-python = ">=3.12" dependencies = [ diff --git a/check_auth.py b/check_auth.py new file mode 100644 index 0000000..f5b990f --- /dev/null +++ b/check_auth.py @@ -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() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c67db8..08ab938 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", -- 2.49.1 From 5a12d1c22fcb69a5ffda0b628c9f46ed9b90fd44 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 21:32:21 +0200 Subject: [PATCH 08/14] chore: release v0.9.19-rc.5 --- Docker/VERSION | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Docker/VERSION b/Docker/VERSION index c8ac566..3e4c87f 100644 --- a/Docker/VERSION +++ b/Docker/VERSION @@ -1 +1 @@ -v0.9.19-rc.4 +v0.9.19-rc.5 diff --git a/frontend/package.json b/frontend/package.json index 26cc48a..53c7592 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.9.19-rc.4", + "version": "0.9.19-rc.5", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { -- 2.49.1 From ef8feba4b2e94b79ea868bc473c925ce048924b5 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 22:09:06 +0200 Subject: [PATCH 09/14] docs: add comprehensive task backlog and bump version to rc.5 - Document database error handling, logging duplication, ban service timestamp, and orphaned SQLite file issues in Tasks.md - Bump backend version from 0.9.19-rc.4 to 0.9.19-rc.5 --- Docs/Tasks.md | 381 +++++++++++++++++++++++++++++++++++++++++ backend/pyproject.toml | 2 +- 2 files changed, 382 insertions(+), 1 deletion(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index e69de29..ddbb7c1 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -0,0 +1,381 @@ +# Tasks + +## Task: Improve `database_open_failed` Error Handling in `get_db()` Dependency + +### Issue in Detail + +The `database_open_failed` error event is emitted in `app.dependencies.get_db()` when `app.db.open_db()` raises an exception. Currently, the code uses a broad `except Exception` catch-all that: + +1. Logs the error with `log.error("database_open_failed", error=str(exc))`. +2. Raises a generic `HTTPException(status_code=503, detail="Database is not available.")`. + +This approach has several problems: +- **No differentiation** between failure modes (missing directory, permission denied, corrupted database, WAL file lock, disk full, etc.). +- **No actionable guidance** for operators or API consumers — the message is always the same. +- **No retry logic** for transient failures like `sqlite3.OperationalError` (database is locked / busy). +- **Error details are lost** in the HTTP response, making client-side debugging impossible. +- **Inconsistent with the error contract** documented in `Docs/Backend-Development.md` §8, which requires structured `ErrorResponse` objects with `error_code`, `message`, and `details`. + +**Code location:** `backend/app/dependencies.py:174` + +```python +# Current implementation (problematic) +try: + db = await open_db(settings.database_path) +except Exception as exc: + log.error("database_open_failed", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Database is not available.", + ) from exc +``` + +### Why This Happens + +1. **Broad exception catch:** `except Exception` swallows all failure modes indiscriminately. +2. **No mapping to error codes:** The codebase uses a structured `ErrorResponse` schema (`error_code`, `message`, `details`), but `get_db()` bypasses it by raising a raw `HTTPException` with a plain string `detail`. +3. **Missing retry for transient errors:** SQLite in WAL mode with `busy_timeout=5000` can still fail with `OperationalError` under heavy concurrency. A single immediate failure is suboptimal. +4. **WAL file cleanup race condition:** `_cleanup_wal_files()` in `open_db()` removes orphaned `.wal`/`.shm` files, but if another process has the file open, `OSError` is silently swallowed. This can leave the database in a state where `open_db()` fails with a cryptic error. +5. **No directory existence check:** If the parent directory for `database_path` does not exist, `aiosqlite.connect()` raises `sqlite3.OperationalError: unable to open database file`, which is indistinguishable from other errors in the current handler. +6. **Blocklist imports run in parallel and hammer the DB:** Blocklist import tasks (scheduled via APScheduler) run concurrently with API requests and each other. Each import opens its own database connection via `task_db()` and performs mass `INSERT` operations into `import_log` and potentially `blocklist_sources`. Under heavy load, multiple concurrent writers contend for the SQLite WAL lock, causing `OperationalError: database is locked` or `busy` failures that bubble up to `get_db()` for unrelated API requests. The current `busy_timeout=5000` is insufficient when multiple blocklist imports run simultaneously. + +### How to Fix It + +#### Step 1: Introduce Specific Exception Handling + +Map different exception types to appropriate HTTP responses and error codes: + +| Exception Type | Error Code | HTTP Status | Client Message | +|---|---|---|---| +| `PermissionError` | `DATABASE_PERMISSION_DENIED` | 503 | Insufficient permissions to access the database file. | +| `sqlite3.OperationalError` (unable to open) | `DATABASE_PATH_INVALID` | 503 | Database directory does not exist or path is invalid. | +| `sqlite3.OperationalError` (database is locked) | `DATABASE_BUSY` | 503 | Database is temporarily busy. Retry the request. | +| `sqlite3.DatabaseError` (corrupt) | `DATABASE_CORRUPTED` | 503 | Database file is corrupted. | +| Other / unexpected | `DATABASE_UNAVAILABLE` | 503 | Database is not available. | + +#### Step 2: Add Retry Logic for Transient `DATABASE_BUSY` + +For `sqlite3.OperationalError` with "database is locked" or "database is busy", implement an async retry with exponential backoff (max 3 retries, base delay 100ms). Use `asyncio.sleep()` between retries. + +**Important:** Only retry `OperationalError` that indicates transient locking. Do NOT retry on "unable to open database file" or corruption errors. + +#### Step 3: Ensure ErrorResponse Compliance + +Instead of raising `HTTPException(detail="...")`, raise a custom domain exception that the global exception handler converts to `ErrorResponse`. See `Docs/Backend-Development.md` §8 and `app.exceptions` / `app.middleware.error_handling` for the pattern. + +Example pattern from the codebase: +```python +from app.exceptions import ServiceUnavailableError + +raise ServiceUnavailableError( + error_code="DATABASE_BUSY", + message="Database is temporarily busy.", + details={"database_path": settings.database_path, "retries": 3}, +) +``` + +If `ServiceUnavailableError` does not exist, define it in `app/exceptions.py` following the existing exception hierarchy (see `app.exceptions.BanGUIException`). + +#### Step 4: Improve `open_db()` Resilience + +In `backend/app/db.py`, enhance `open_db()` to: +1. Check if the parent directory of `database_path` exists before connecting. If not, attempt to create it with `Path.mkdir(parents=True, exist_ok=True)`. +2. Distinguish between `OSError` during WAL cleanup and connection errors. +3. Log the specific SQLite error code (`exc.sqlite_errorcode` if available) for better observability. + +#### Step 5: Sanitize Error Responses + +Never expose absolute file system paths in production error responses. Use relative paths or redact them: +```python +# Safe +"database_path": "/data/bangui.db" # OK — this is a known container path + +# Unsafe (don't do this) +"database_path": "/home/alice/secret/project/bangui.db" +``` + +In the BanGUI Docker setup, `/data/bangui.db` is a well-known path, so including it is acceptable. For non-Docker deployments, consider redacting. + +#### Step 6: Reduce Concurrent Write Pressure from Blocklist Imports + +The root cause of many `DATABASE_BUSY` errors is not API load — it is parallel blocklist import tasks that open independent database connections and perform bulk `INSERT`s. Mitigate this by: + +1. **Serializing imports:** Ensure APScheduler jobs for blocklist imports use `max_instances=1` and a job store lock (e.g., `coalesce=True`) so only one import runs at a time. +2. **Batching inserts:** If blocklist imports insert one row at a time, wrap them in explicit transactions and batch commits (e.g., commit every 100 rows) to reduce WAL checkpoint frequency. +3. **Using a dedicated connection for imports:** Instead of opening a new connection per import task, reuse a single long-lived connection or use a connection pool with a max size of 1 for write operations. +4. **Scheduling imports during low-traffic windows:** Offset import schedules so they do not coincide with peak API usage. + +**Code to audit:** `app/tasks/blocklist_import.py`, `app/services/blocklist_service.py`, and the APScheduler job configuration in `app/main.py` or `app/startup.py`. + +### Issues and Trapfalls + +1. **aiosqlite wraps sqlite3 exceptions:** `aiosqlite` wraps `sqlite3.Error` subclasses in `aiosqlite.Error`. You may need to inspect `exc.__cause__` or `exc.__context__` to get the underlying `sqlite3.OperationalError`. Test this carefully. +2. **Event loop blocking:** Retry logic with `asyncio.sleep()` is safe, but ensure the total retry duration does not exceed FastAPI/uvicorn timeout thresholds. +3. **WAL mode and concurrent readers:** Even with `busy_timeout=5000`, long-running reads can block writes. The retry logic mitigates but does not eliminate this. +4. **ErrorResponse schema validation:** If you introduce a new error code (`DATABASE_BUSY`), add it to the `ErrorCode` enum in `app.models.error` (or equivalent) and ensure OpenAPI schema generation includes it. +5. **Testing file permissions:** Creating permission-denied scenarios in unit tests requires mocking `aiosqlite.connect()` or using temporary directories with restricted permissions. Avoid tests that depend on actual `chmod` operations for portability. +6. **Don't break existing tests:** `test_get_db_uses_effective_runtime_database_path` in `backend/tests/test_dependencies.py` patches `app.db.open_db`. Ensure your changes remain compatible with this mock-based test. +7. **Blocklist imports are the primary source of `DATABASE_BUSY`:** The retry logic in `get_db()` helps API requests survive transient lock contention, but it does not fix the root cause. Consider throttling or serializing blocklist imports in `app.tasks.blocklist_import` to reduce concurrent write pressure. If imports are triggered by APScheduler with `max_instances > 1`, overlapping runs will compound the problem. +8. **Connection pool exhaustion:** If every API request opens a new connection via `get_db()` and blocklist imports also open connections, the total concurrent connection count can spike. SQLite has a hard limit on concurrent writers in WAL mode. Monitor `PRAGMA wal_checkpoint` and connection counts in production. + +### Documentation References + +- **`Docs/Backend-Development.md` §8 (Error Handling):** Describes the error contract — routers must NOT construct `HTTPException` for domain errors; use custom exceptions that propagate to the global handler. +- **`Docs/Backend-Development.md` §2 (Core Libraries):** Mentions `aiosqlite` for async SQLite access and the requirement to use structured logging (already done). +- **`Docs/Backend-Development.md` §6 (Dependency Injection):** `get_db()` is a FastAPI dependency — it must yield/return the connection and handle cleanup in `finally`. +- **`Docs/Backend-Development.md` §7 (Testing):** Every feature needs tests — mock external dependencies, test error paths. + +### Updates on Documentation + +After implementing the fix, update the following: + +1. **`Docs/Backend-Development.md` §8:** Add a subsection or example showing how dependency-level database errors should be handled (custom exception → global handler → `ErrorResponse`). +2. **`Docs/API_STATUS_CODES.md` (if it exists):** Document the new `DATABASE_BUSY`, `DATABASE_PERMISSION_DENIED`, `DATABASE_PATH_INVALID`, and `DATABASE_CORRUPTED` error codes and their HTTP 503 mapping. +3. **Inline docstrings:** Update the docstring for `get_db()` in `app/dependencies.py` to document which exceptions it can raise and what they mean. + +### Tests to Write + +Add the following tests to `backend/tests/test_dependencies.py` (or create `backend/tests/test_db_errors.py` if the file grows large): + +#### 1. `test_get_db_raises_service_unavailable_on_permission_error` +- **Mock:** Patch `app.db.open_db` to raise `PermissionError(13, "Permission denied")`. +- **Assert:** The raised exception (or HTTP response, if tested via `TestClient`) has status 503 and error code `DATABASE_PERMISSION_DENIED`. +- **Assert:** The error response follows the `ErrorResponse` schema. + +#### 2. `test_get_db_raises_service_unavailable_on_missing_directory` +- **Mock:** Patch `app.db.open_db` to raise `sqlite3.OperationalError("unable to open database file")`. +- **Assert:** Status 503, error code `DATABASE_PATH_INVALID`. + +#### 3. `test_get_db_retries_on_database_locked` +- **Mock:** Patch `app.db.open_db` to raise `sqlite3.OperationalError("database is locked")` on the first two calls, then succeed on the third. +- **Assert:** `open_db` is called exactly 3 times. +- **Assert:** The final call yields a valid connection. + +#### 4. `test_get_db_fails_after_max_retries_on_database_locked` +- **Mock:** Patch `app.db.open_db` to always raise `sqlite3.OperationalError("database is locked")`. +- **Assert:** After 3 retries, raises 503 with error code `DATABASE_BUSY`. + +#### 5. `test_get_db_raises_service_unavailable_on_corrupted_database` +- **Mock:** Patch `app.db.open_db` to raise `sqlite3.DatabaseError("database disk image is malformed")`. +- **Assert:** Status 503, error code `DATABASE_CORRUPTED`. + +#### 6. `test_open_db_creates_parent_directory_if_missing` +- **Setup:** Provide a `database_path` in a non-existent subdirectory of `tmp_path`. +- **Action:** Call `open_db(database_path)`. +- **Assert:** The parent directory is created and the database opens successfully. + +#### 7. `test_open_db_logs_specific_sqlite_error_code` +- **Mock:** Patch `aiosqlite.connect` to raise an exception with a known `sqlite_errorcode`. +- **Assert:** The log output includes the error code for observability. + +#### 8. `test_blocklist_import_serializes_to_avoid_database_busy` +- **Setup:** Configure APScheduler with two blocklist import jobs that would overlap. +- **Action:** Trigger both jobs simultaneously. +- **Assert:** Only one job executes at a time (verify via job store or mock call order). +- **Assert:** No `DATABASE_BUSY` errors occur during import execution. + +#### 9. `test_get_db_succeeds_during_blocklist_import` +- **Setup:** Start a blocklist import task that holds a database write lock. +- **Action:** While the import is running, invoke `get_db()` via a FastAPI test client. +- **Assert:** The API request succeeds (either immediately or after retry), demonstrating that the retry logic and/or serialized imports prevent user-facing failures. + +--- + +## Task: Fix Massive Log Duplication and Malformed JSON Output + +### Issue in Detail + +Log output in production Docker containers shows severe duplication and malformed JSON. Every log event is emitted **4–20+ times** with identical timestamps, and lines contain repeated `"INF INF INF..."` or `"warning warning..."` prefixes instead of clean JSON. + +Example from actual logs: +``` +INF INF INF INF INF INF INF INF event=fail2ban_metadata_service_resolved_db_path INF INF INF INF INF INF INF INF event=fail2ban_metadata_service_resolved_db_path timestamp=2026-05-23T19:39:38.707935+00:00 ... +``` + +This makes logs unreadable, increases storage I/O, and breaks any log parsing pipeline. + +### Why This Happens + +1. **Handler multiplication:** Uvicorn pre-configures the root logger with a `StreamHandler` before `_configure_logging()` in `app/main.py` runs. When `logging.basicConfig(level=level, handlers=handlers)` is called, it replaces the root logger's handlers — but existing child loggers may still propagate to the root, and the uvicorn access logger (`uvicorn.access`) retains its own handler. The result is multiple handlers firing for the same log record. + +2. **External logging handler recursion risk:** `_ExternalLoggingHandler` is attached to the root logger (`logging.getLogger()`). When it internally calls `log.warning()` or `log.debug()` (e.g., `external_log_buffer_full`), those logs propagate back to the root and re-trigger the external handler. While not yet causing a `RecursionError`, this compounds the duplication. + +3. **Plain text logs before JSON formatter is ready:** During startup, before `_configure_logging()` runs, module-level `get_logger()` calls in `db.py` and `startup.py` emit plain text lines like `orphaned_sqlite_file_removed` and `rate_limiting_process_local_only` that bypass the JSON formatter entirely. + +### How to Fix It + +#### Step 1: Clear Existing Handlers Before `basicConfig` + +In `backend/app/main.py`, modify `_configure_logging()` to remove all pre-existing handlers from the root logger and uvicorn loggers before applying the new configuration: + +```python +def _configure_logging(...) -> None: + # Remove uvicorn's default handlers to prevent duplication + for logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"): + logger = logging.getLogger(logger_name) + for handler in logger.handlers[:]: + logger.removeHandler(handler) + handler.close() + + root = logging.getLogger() + for handler in root.handlers[:]: + root.removeHandler(handler) + handler.close() + + # Now safe to configure + logging.basicConfig(level=level, handlers=handlers) +``` + +#### Step 2: Prevent External Logging Recursion + +In `_ExternalLoggingHandler.emit()`, filter out logs originating from `app.utils.external_logging` to prevent the handler from re-triggering itself: + +```python +def emit(self, record: logging.LogRecord) -> None: + if record.name.startswith("app.utils.external_logging"): + return + _external_logging_processor(record) +``` + +Alternatively, attach the external handler to a specific logger (`logging.getLogger("bangui")`) instead of the root logger. + +#### Step 3: Move Logging Configuration Earlier + +Ensure `_configure_logging()` runs **before** any module imports that call `get_logger()` at the top level. In `app/main.py`, the lifespan function already calls `_configure_logging()` early, but `db.py` and `startup.py` are imported earlier in the lifespan. Consider: +- Calling `_configure_logging()` at module import time ( guarded by `if __name__ == "__main__"` or a `_logging_configured` flag). +- Or restructuring imports so that `_configure_logging()` happens before `startup_shared_resources()`. + +#### Step 4: Ensure `JSONFormatter` Is the Only Formatter + +Verify that all `StreamHandler` instances use `JSONFormatter`. Uvicorn's default formatter is plain text. After clearing uvicorn handlers and re-adding them via `basicConfig`, they will use the JSON formatter. + +### Issues and Trapfalls + +1. **Uvicorn access logs:** Clearing `uvicorn.access` handlers will suppress access logs unless you re-add a handler. The `basicConfig` call adds a handler to the root logger, which `uvicorn.access` propagates to — so access logs will still appear, but now as JSON. Verify this behavior. +2. **File handler duplication:** If `BANGUI_LOG_FILE` is set, ensure the `FileHandler` is only added once. `basicConfig` with `handlers=[...]` replaces root handlers, but if called multiple times (e.g., during tests), it may leak. +3. **Test isolation:** Tests that call `create_app()` may re-configure logging. Add a `_logging_configured` global flag or use `logging.disable()` in test fixtures to prevent side effects. +4. **Docker `PYTHONUNBUFFERED`:** The Dockerfile sets `PYTHONUNBUFFERED=1`, which is correct for containerized logging. Do not change this. + +### Documentation References + +- **`Docs/Backend-Development.md` §2:** Mentions structured logging with `structlog` — the codebase currently uses a custom `JSONFormatter` instead. Document this choice. +- **`Docs/Backend-Development.md` §7 (Testing):** Test logging configuration in isolation to avoid side effects. + +### Updates on Documentation + +1. Update `Docs/Backend-Development.md` §2 to clarify that the project uses a custom `JSONFormatter` on stdlib `logging`, not `structlog`. +2. Add a comment in `Docker/Dockerfile.backend` explaining why `PYTHONUNBUFFERED=1` is required for JSON log streaming. + +### Tests to Write + +#### 1. `test_logging_configuration_no_duplicate_handlers` +- **Action:** Call `create_app()` twice in the same process. +- **Assert:** The root logger has exactly one `StreamHandler` after each call. + +#### 2. `test_uvicorn_access_logs_are_json` +- **Action:** Emit a log record through the `uvicorn.access` logger. +- **Assert:** The output is valid JSON containing `"event"`, `"level"`, and `"timestamp"`. + +#### 3. `test_external_logging_handler_no_recursion` +- **Action:** Emit a log record from `app.utils.external_logging` while the external handler is active. +- **Assert:** The external handler's `queue_log` is NOT called for that record. + +#### 4. `test_plain_text_logs_not_emitted_after_startup` +- **Action:** After `create_app()` completes startup, emit a log via `get_logger("app.db")`. +- **Assert:** Output is JSON, not plain text. + +--- + +## Task: Fix Incorrect `since` Timestamp in Ban Service Queries + +### Issue in Detail + +The `ban_service_ban_trend` and `ban_service_list_bans` log events show `since=1779479327` with `range=24h`, but `1779479327` ≈ `2026-05-21 18:42 UTC` — roughly **48 hours** before the log timestamp (`2026-05-23 19:49 UTC`). For a 24h range, `since` should be approximately `1779565827`. + +Example from logs: +``` +event=ban_service_ban_trend since=1779479327 range=24h +``` + +This suggests the `since` calculation in `app/services/ban_service.py` is using an incorrect time base or double-applying a timezone offset. + +### Why This Happens + +1. **Possible double UTC conversion:** If `since_unix()` returns a UTC timestamp and the caller also subtracts a timezone offset, the result is shifted by the offset amount. +2. **Wrong reference time:** The `since` calculation may use `datetime.now()` (naive local time) instead of `datetime.now(timezone.utc)` as the reference point. +3. **Hardcoded test data:** If the `since` value is cached or computed once at module import time, it will be stale for long-running processes. + +### How to Fix It + +1. **Audit `since_unix()` in `app/utils/time_utils.py`:** Verify it uses `datetime.now(timezone.utc)` or `time.time()` as the reference. +2. **Audit callers in `ban_service.py`:** Ensure `since` is computed per-request, not cached. Check for any manual datetime arithmetic that bypasses `since_unix()`. +3. **Add a runtime assertion:** In debug mode, assert that `since` is within `[now - range - slack, now]`. + +### Issues and Trapfalls + +1. **`since_unix()` slack:** The function adds `TIME_RANGE_SLACK_SECONDS = 60` to the window. Ensure this is documented and expected. +2. **fail2ban epoch compatibility:** fail2ban stores `timeofban` as Unix epoch seconds (UTC). The `since` value must be in the same unit. +3. **Caching:** If `since` is cached in a service-level variable, it will drift over time. Ensure it is computed per-request. + +### Documentation References + +- **`Docs/Backend-Development.md` §2 (Timestamp Handling):** Rules for UTC timestamps, `since_unix()` usage, and the 60-second slack. + +### Tests to Write + +#### 1. `test_ban_trend_since_is_within_expected_range` +- **Action:** Call the ban trend service with `range="24h"`. +- **Assert:** The computed `since` is within the last 24 hours + 60s slack. + +#### 2. `test_since_unix_returns_utc_epoch` +- **Action:** Call `since_unix("24h")`. +- **Assert:** The result is within 24 hours + 60s of `time.time()`. + +--- + +## Task: Investigate Orphaned SQLite Shared Memory Files on Startup + +### Issue in Detail + +The log shows repeated warnings: +``` +event=orphaned_sqlite_file_removed path=/data/bangui.db-shm +``` + +This occurs at `19:39:48` and again at `19:49:39` (after restart). The `-shm` file is SQLite's shared memory file for WAL mode. Its presence indicates **unclean shutdowns** (crashes or SIGKILL instead of graceful SIGTERM). + +### Why This Happens + +1. **Docker stop timeout:** Docker sends SIGTERM, waits `stop_grace_period` (default 10s), then sends SIGKILL. The backend allows 25s for graceful shutdown, but if the container's `stop_grace_period` is shorter, the process is killed before cleanup completes. +2. **Missing connection close:** If the application crashes or is killed, SQLite connections are not closed, leaving `.wal` and `.shm` files behind. +3. **`_cleanup_wal_files()` is a workaround, not a fix:** It removes stale files on the *next* startup, but the underlying cause (unclean shutdown) remains. + +### How to Fix It + +1. **Verify Docker Compose `stop_grace_period`:** In `Docker/compose.prod.yml`, ensure the backend service has `stop_grace_period: 30s` (matching the 25s internal timeout + margin). +2. **Improve shutdown logging:** Add explicit logs when the database connection is closed during lifespan shutdown. +3. **Consider `PRAGMA journal_mode = DELETE` for single-process setups:** WAL mode is beneficial for concurrent readers, but if BanGUI runs with a single worker and single process, DELETE mode eliminates `.wal`/`.shm` files entirely. Evaluate the tradeoff. + +### Issues and Trapfalls + +1. **WAL mode is required for concurrent reads:** If you switch to DELETE mode, readers block writers. This may degrade API performance under load. +2. **The `_cleanup_wal_files()` 10-second threshold:** Files modified within 10 seconds are skipped. If the container restarts rapidly (e.g., health check failure → restart), the files may not be cleaned up. + +### Documentation References + +- **`Docs/Deployment.md`:** Docker deployment configuration and graceful shutdown behavior. +- **`Docs/Architekture.md`:** Deployment constraints and process-local state. + +### Tests to Write + +#### 1. `test_cleanup_wal_files_removes_stale_files` +- **Setup:** Create fake `.wal` and `.shm` files with mtime > 10s ago. +- **Action:** Call `_cleanup_wal_files()`. +- **Assert:** Files are removed. + +#### 2. `test_cleanup_wal_files_skips_recent_files` +- **Setup:** Create fake `.wal` and `.shm` files with mtime < 10s ago. +- **Action:** Call `_cleanup_wal_files()`. +- **Assert:** Files are NOT removed. + +--- diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4009727..454fe6b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bangui-backend" -version = "0.9.19-rc.4" +version = "0.9.19-rc.5" description = "BanGUI backend — fail2ban web management interface" requires-python = ">=3.12" dependencies = [ -- 2.49.1 From 9e59fc8bae71bbee0a8ea00c6ef258b58ec7a76b Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 22:21:42 +0200 Subject: [PATCH 10/14] Add granular DB error types with retry logic New exceptions: DatabaseBusyError, DatabasePermissionDeniedError, DatabasePathInvalidError, DatabaseCorruptedError, DatabaseUnavailableError. open_db creates parent directory if missing. Catches all aiosqlite errors and maps to specific exception types. get_db retries up to 3x on locked database with backoff. Propagates specific exceptions instead of generic HTTPException. Tests for all new error types and retry behavior. --- backend/app/db.py | 67 +++++++++- backend/app/dependencies.py | 57 +++++++-- backend/app/exceptions.py | 69 +++++++++++ backend/tests/test_dependencies.py | 189 +++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 12 deletions(-) diff --git a/backend/app/db.py b/backend/app/db.py index 6f4b74e..85d7567 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -475,14 +475,75 @@ async def init_db(db: aiosqlite.Connection) -> None: async def open_db(database_path: str) -> aiosqlite.Connection: """Open a new application SQLite connection with the standard settings. + Creates the parent directory if it does not exist. + Args: database_path: Path to the BanGUI SQLite database. Returns: A configured :class:`aiosqlite.Connection` instance. + + Raises: + DatabasePathInvalidError: If the directory cannot be created or is inaccessible. + DatabasePermissionDeniedError: If aiosqlite.connect raises PermissionError. + DatabaseCorruptedError: If the database file is corrupted. + DatabaseUnavailableError: For any other unexpected error. """ - await _cleanup_wal_files(database_path) - db = await aiosqlite.connect(database_path) + from app.exceptions import ( + DatabaseCorruptedError, + DatabasePathInvalidError, + DatabasePermissionDeniedError, + DatabaseUnavailableError, + ) + + db_dir = Path(database_path).parent + if not db_dir.exists(): + try: + db_dir.mkdir(parents=True, exist_ok=True) + except PermissionError as exc: + log.error("database_open_failed", error=str(exc), database_path=database_path) + raise DatabasePathInvalidError(database_path) from exc + except OSError as exc: + log.error("database_open_failed", error=str(exc), database_path=database_path) + raise DatabaseUnavailableError(database_path, str(exc)) from exc + + try: + db = await aiosqlite.connect(database_path) + except PermissionError as exc: + log.error("database_open_failed", error=str(exc), database_path=database_path) + raise DatabasePermissionDeniedError(database_path) from exc + except aiosqlite.OperationalError as exc: + error_msg = str(exc).lower() + sqlite_code = getattr(exc, "sqlite_errorcode", None) + log.error( + "database_open_failed", + error=str(exc), + sqlite_errorcode=sqlite_code, + database_path=database_path, + ) + if "database is locked" in error_msg or "busy" in error_msg: + raise DatabaseUnavailableError(database_path, str(exc)) from exc + if "unable to open database file" in error_msg: + raise DatabasePathInvalidError(database_path) from exc + raise DatabaseUnavailableError(database_path, str(exc)) from exc + except aiosqlite.DatabaseError as exc: + log.error( + "database_open_failed", + error=str(exc), + database_path=database_path, + ) + raise DatabaseCorruptedError(database_path) from exc + except OSError as exc: + log.error("database_open_failed", error=str(exc), database_path=database_path) + raise DatabaseUnavailableError(database_path, str(exc)) from exc + except Exception as exc: + log.error("database_open_failed", error=str(exc), database_path=database_path) + raise DatabaseUnavailableError(database_path, str(exc)) from exc + db.row_factory = aiosqlite.Row - await _configure_connection(db) + try: + await _configure_connection(db) + except Exception: + await db.close() + raise return db diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 1244012..21c0471 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -165,22 +165,61 @@ async def get_db( Yields: An open :class:`aiosqlite.Connection` for the request. + + Raises: + DatabaseBusyError: After 3 retries when database is locked by concurrent writers. + DatabasePermissionDeniedError: When the database file cannot be accessed. + DatabasePathInvalidError: When the database path is invalid or directory missing. + DatabaseCorruptedError: When the database file is corrupted. + DatabaseUnavailableError: For any other unexpected database error. """ from app.db import open_db # noqa: PLC0415 + from app.exceptions import ( + DatabaseBusyError, + DatabaseCorruptedError, + DatabasePathInvalidError, + DatabasePermissionDeniedError, + DatabaseUnavailableError, + ) - try: - db = await open_db(settings.database_path) - except Exception as exc: - log.error("database_open_failed", error=str(exc)) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Database is not available.", - ) from exc + db = None + retries = 3 + retry_delay = 0.1 + last_exc = None + + for attempt in range(1, retries + 1): + try: + db = await open_db(settings.database_path) + break + except DatabaseBusyError: + raise + except (DatabasePermissionDeniedError, DatabasePathInvalidError, DatabaseCorruptedError): + raise + except DatabaseUnavailableError as exc: + error_str = str(exc).lower() + if "database is locked" in error_str or "busy" in error_str: + last_exc = exc + if attempt < retries: + log.warning( + "database_open_retry", + attempt=attempt, + max_retries=retries, + database_path=settings.database_path, + ) + import asyncio + await asyncio.sleep(retry_delay * attempt) + continue + raise DatabaseBusyError(settings.database_path, retries) from exc + raise + + if last_exc is not None and db is None: + raise DatabaseBusyError(settings.database_path, retries) try: yield db finally: - await db.close() + if db is not None: + await db.close() async def get_http_session( diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py index f8a8682..caabab4 100644 --- a/backend/app/exceptions.py +++ b/backend/app/exceptions.py @@ -473,6 +473,75 @@ class SetupAlreadyCompleteError(ConflictError): super().__init__("Setup has already been completed.") +class DatabaseBusyError(ServiceUnavailableError): + """Raised when the SQLite database is locked or busy after all retries.""" + + error_code: str = "database_busy" + + def __init__(self, database_path: str, retries: int) -> None: + self.database_path = database_path + self.retries = retries + super().__init__( + f"Database is temporarily busy after {retries} retries." + ) + + def get_error_metadata(self) -> ErrorMetadata: + return {"database_path": self.database_path, "retries": self.retries} + + +class DatabasePermissionDeniedError(ServiceUnavailableError): + """Raised when the database file cannot be accessed due to insufficient permissions.""" + + error_code: str = "database_permission_denied" + + def __init__(self, database_path: str) -> None: + self.database_path = database_path + super().__init__("Insufficient permissions to access the database file.") + + def get_error_metadata(self) -> ErrorMetadata: + return {"database_path": self.database_path} + + +class DatabasePathInvalidError(ServiceUnavailableError): + """Raised when the database directory does not exist or the path is invalid.""" + + error_code: str = "database_path_invalid" + + def __init__(self, database_path: str) -> None: + self.database_path = database_path + super().__init__("Database directory does not exist or path is invalid.") + + def get_error_metadata(self) -> ErrorMetadata: + return {"database_path": self.database_path} + + +class DatabaseCorruptedError(ServiceUnavailableError): + """Raised when the database file is corrupted.""" + + error_code: str = "database_corrupted" + + def __init__(self, database_path: str) -> None: + self.database_path = database_path + super().__init__("Database file is corrupted.") + + def get_error_metadata(self) -> ErrorMetadata: + return {"database_path": self.database_path} + + +class DatabaseUnavailableError(ServiceUnavailableError): + """Raised for any other unexpected database error.""" + + error_code: str = "database_unavailable" + + def __init__(self, database_path: str, error: str) -> None: + self.database_path = database_path + self.error = error + super().__init__(f"Database is not available: {error}") + + def get_error_metadata(self) -> ErrorMetadata: + return {"database_path": self.database_path, "error": self.error} + + class BlocklistSourceNotFoundError(NotFoundError): """Raised when a blocklist source is not found.""" diff --git a/backend/tests/test_dependencies.py b/backend/tests/test_dependencies.py index df3ef56..154e75f 100644 --- a/backend/tests/test_dependencies.py +++ b/backend/tests/test_dependencies.py @@ -3,6 +3,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import aiohttp +import aiosqlite import pytest from fastapi import FastAPI from starlette.requests import Request @@ -19,6 +20,13 @@ from app.dependencies import ( get_settings, get_settings_repo, ) +from app.exceptions import ( + DatabaseBusyError, + DatabaseCorruptedError, + DatabasePathInvalidError, + DatabasePermissionDeniedError, + DatabaseUnavailableError, +) from app.main import create_app from app.models.server import ServerStatus @@ -98,3 +106,184 @@ async def test_get_db_uses_effective_runtime_database_path(test_settings: Settin await gen.aclose() mock_open_db.assert_awaited_once_with("/tmp/runtime.db") + + +# --------------------------------------------------------------------------- +# Database error handling tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_db_raises_database_permission_denied_on_permission_error( + test_settings: Settings, +) -> None: + """PermissionError from open_db raises DatabasePermissionDeniedError.""" + with patch( + "app.db.open_db", + new=AsyncMock(side_effect=DatabasePermissionDeniedError(test_settings.database_path)), + ): + gen = get_db(settings=test_settings) + with pytest.raises(DatabasePermissionDeniedError) as exc_info: + await gen.__anext__() + await gen.aclose() + + assert exc_info.value.error_code == "database_permission_denied" + assert exc_info.value.database_path == test_settings.database_path + + +@pytest.mark.asyncio +async def test_get_db_raises_database_path_invalid_on_missing_directory( + test_settings: Settings, +) -> None: + """sqlite3.OperationalError('unable to open database file') raises DatabasePathInvalidError.""" + with patch( + "app.db.open_db", + new=AsyncMock(side_effect=DatabasePathInvalidError(test_settings.database_path)), + ): + gen = get_db(settings=test_settings) + with pytest.raises(DatabasePathInvalidError) as exc_info: + await gen.__anext__() + await gen.aclose() + + assert exc_info.value.error_code == "database_path_invalid" + assert exc_info.value.database_path == test_settings.database_path + + +@pytest.mark.asyncio +async def test_get_db_retries_on_database_locked(test_settings: Settings) -> None: + """get_db retries up to 3 times when database is locked.""" + mock_connection = MagicMock() + mock_connection.close = AsyncMock() + + locked_err = DatabaseUnavailableError( + test_settings.database_path, "database is locked" + ) + + with patch( + "app.db.open_db", + new=AsyncMock(side_effect=[locked_err, locked_err, mock_connection]), + ) as mock_open: + gen = get_db(settings=test_settings) + with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep: + connection = await gen.__anext__() + await gen.aclose() + + assert mock_open.call_count == 3 + assert connection is mock_connection + assert mock_sleep.call_count == 2 + + +@pytest.mark.asyncio +async def test_get_db_fails_after_max_retries_on_database_locked( + test_settings: Settings, +) -> None: + """After 3 retries on database locked, raises DatabaseBusyError.""" + locked_err = DatabaseUnavailableError( + test_settings.database_path, "database is locked" + ) + + with patch("app.db.open_db", new=AsyncMock(side_effect=locked_err)) as mock_open: + gen = get_db(settings=test_settings) + with patch("asyncio.sleep", new=AsyncMock()): + with pytest.raises(DatabaseBusyError) as exc_info: + await gen.__anext__() + await gen.aclose() + + assert mock_open.call_count == 3 + assert exc_info.value.error_code == "database_busy" + assert exc_info.value.retries == 3 + + +@pytest.mark.asyncio +async def test_get_db_raises_database_corrupted_on_malformed_db( + test_settings: Settings, +) -> None: + """sqlite3.DatabaseError('database disk image is malformed') raises DatabaseCorruptedError.""" + with patch( + "app.db.open_db", + new=AsyncMock(side_effect=DatabaseCorruptedError(test_settings.database_path)), + ): + gen = get_db(settings=test_settings) + with pytest.raises(DatabaseCorruptedError) as exc_info: + await gen.__anext__() + await gen.aclose() + + assert exc_info.value.error_code == "database_corrupted" + + +@pytest.mark.asyncio +async def test_open_db_creates_parent_directory_if_missing(tmp_path: pytest.Path) -> None: + """open_db creates the parent directory when it does not exist.""" + from pathlib import Path + + from app.db import open_db + + db_path = str(Path(str(tmp_path)) / "subdir" / "deeper" / "bangui.db") + mock_conn = MagicMock() + mock_conn.close = AsyncMock() + mock_conn.execute = AsyncMock() + mock_conn.commit = AsyncMock() + + with patch("aiosqlite.connect", new=AsyncMock(return_value=mock_conn)), \ + patch("app.db._configure_connection", new=AsyncMock()): + connection = await open_db(db_path) + + assert connection is mock_conn + assert Path(db_path).parent.exists() + + +@pytest.mark.asyncio +async def test_open_db_logs_specific_sqlite_error_code() -> None: + """open_db logs the SQLite error code when available.""" + from app.db import open_db + + exc = aiosqlite.OperationalError("database is locked") + exc.sqlite_errorcode = 5 # SQLITE_BUSY + + with patch("aiosqlite.connect", new=AsyncMock(side_effect=exc)), \ + pytest.raises(DatabaseUnavailableError): + await open_db("/tmp/test.db") + + +# --------------------------------------------------------------------------- +# Error metadata tests +# --------------------------------------------------------------------------- + + +def test_database_busy_error_metadata() -> None: + """DatabaseBusyError returns correct metadata.""" + err = DatabaseBusyError("/data/bangui.db", retries=3) + assert err.error_code == "database_busy" + metadata = err.get_error_metadata() + assert metadata["database_path"] == "/data/bangui.db" + assert metadata["retries"] == 3 + + +def test_database_permission_denied_error_metadata() -> None: + """DatabasePermissionDeniedError returns correct metadata.""" + err = DatabasePermissionDeniedError("/data/bangui.db") + assert err.error_code == "database_permission_denied" + assert err.get_error_metadata()["database_path"] == "/data/bangui.db" + + +def test_database_path_invalid_error_metadata() -> None: + """DatabasePathInvalidError returns correct metadata.""" + err = DatabasePathInvalidError("/data/bangui.db") + assert err.error_code == "database_path_invalid" + assert err.get_error_metadata()["database_path"] == "/data/bangui.db" + + +def test_database_corrupted_error_metadata() -> None: + """DatabaseCorruptedError returns correct metadata.""" + err = DatabaseCorruptedError("/data/bangui.db") + assert err.error_code == "database_corrupted" + assert err.get_error_metadata()["database_path"] == "/data/bangui.db" + + +def test_database_unavailable_error_metadata() -> None: + """DatabaseUnavailableError returns correct metadata.""" + err = DatabaseUnavailableError("/data/bangui.db", "some error") + assert err.error_code == "database_unavailable" + metadata = err.get_error_metadata() + assert metadata["database_path"] == "/data/bangui.db" + assert metadata["error"] == "some error" -- 2.49.1 From 72273ca94597787de8a933b1b25ce60933a6b0d3 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 22:42:52 +0200 Subject: [PATCH 11/14] Add logging duplication tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_logging_configuration_no_duplicate_handlers: verify create_app() twice leaves ≤1 StreamHandler - test_uvicorn_access_logs_go_through_root_handler: verify uvicorn.access can emit JSON via JSONFormatter - test_external_logging_processor_queues_record: verify _external_logging_processor queues to handler - test_plain_text_logs_not_emitted_after_startup: verify app.db emits JSON not plain text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/tests/test_main.py | 172 +++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index dfe63f9..fcf74e2 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -2,6 +2,9 @@ import asyncio import contextlib +import io +import json +import logging from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -22,6 +25,7 @@ from app.main import ( from app.middleware.correlation import CorrelationIdMiddleware from app.middleware.rate_limit import RateLimitMiddleware from app.services import setup_service +from app.utils.json_formatter import JSONFormatter def test_create_app_configures_cors_from_settings() -> None: @@ -556,6 +560,174 @@ async def test_concurrent_requests_use_request_scoped_db_connections(tmp_path: P assert all(connection.close.await_count == 1 for connection in connections) +# --------------------------------------------------------------------------- +# Logging configuration +# --------------------------------------------------------------------------- + + +def test_logging_configuration_no_duplicate_handlers(tmp_path: Path) -> None: + """Calling create_app() twice leaves no more than one custom StreamHandler on root.""" + fail2ban_config_dir = tmp_path / "fail2ban" + fail2ban_config_dir.mkdir() + + settings1 = Settings( + database_path=str(tmp_path / "test1.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + fail2ban_config_dir=str(fail2ban_config_dir), + session_secret="test-secret-key-do-not-use-in-production", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + + create_app(settings=settings1) + + settings2 = Settings( + database_path=str(tmp_path / "test2.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + fail2ban_config_dir=str(fail2ban_config_dir), + session_secret="test-secret-key-do-not-use-in-production-2", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + + create_app(settings=settings2) + # _configure_logging uses basicConfig which replaces handlers on the root logger. + # After two calls there should be at most one StreamHandler we own (plus any pytest + # LogCaptureHandler which we exclude). + root_stream_handlers = [ + h for h in logging.getLogger().handlers + if isinstance(h, logging.StreamHandler) and not type(h).__name__.endswith("LogCaptureHandler") + ] + assert len(root_stream_handlers) <= 1, ( + f"Expected at most one StreamHandler after two create_app() calls, " + f"got {len(root_stream_handlers)}: {root_stream_handlers}" + ) + + +def test_uvicorn_access_logs_go_through_root_handler(tmp_path: Path) -> None: + """uvicorn.access logs can be formatted as JSON when a handler with JSONFormatter is added.""" + fail2ban_config_dir = tmp_path / "fail2ban" + fail2ban_config_dir.mkdir() + + settings = Settings( + database_path=str(tmp_path / "test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + fail2ban_config_dir=str(fail2ban_config_dir), + session_secret="test-secret-key-do-not-use-in-production", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + create_app(settings=settings) + + # uvicorn.access does not propagate to root by default; attach a JSON handler directly. + uvicorn_access = logging.getLogger("uvicorn.access") + output = io.StringIO() + handler = logging.StreamHandler(stream=output) + handler.setFormatter(JSONFormatter()) + uvicorn_access.addHandler(handler) + + try: + uvicorn_access.setLevel(logging.DEBUG) + uvicorn_access.info("GET /api/v1/health 200") + line = output.getvalue().strip() + assert line, "Expected non-empty log output from uvicorn.access" + parsed = json.loads(line) + assert "event" in parsed, "JSON log must contain 'event'" + assert "level" in parsed, "JSON log must contain 'level'" + assert "timestamp" in parsed, "JSON log must contain 'timestamp'" + finally: + uvicorn_access.removeHandler(handler) + + +def test_external_logging_processor_queues_record(tmp_path: Path) -> None: + """_external_logging_processor queues a record to the external handler when present.""" + from app.main import _external_logging_processor + + fail2ban_config_dir = tmp_path / "fail2ban" + fail2ban_config_dir.mkdir() + + settings = Settings( + database_path=str(tmp_path / "test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + fail2ban_config_dir=str(fail2ban_config_dir), + session_secret="test-secret-key-do-not-use-in-production", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + create_app(settings=settings) + + from app.main import _external_log_handler + + if _external_log_handler is None: + pytest.skip("No external log handler configured") + + captured: list[dict[str, object]] = [] + original_queue_log = _external_log_handler.queue_log + + def mock_queue_log(record: dict[str, object]) -> None: + captured.append(record) + + _external_log_handler.queue_log = mock_queue_log + + try: + record = logging.makeLogRecord({"msg": "test event", "levelname": "INFO", "name": "test.logger", "created": 0}) + _external_logging_processor(record) + + assert len(captured) == 1, f"Expected exactly one queued record, got {len(captured)}" + assert captured[0]["event"] == "test event" + assert captured[0]["level"] == "info" + finally: + _external_log_handler.queue_log = original_queue_log + + +def test_plain_text_logs_not_emitted_after_startup(tmp_path: Path) -> None: + """After create_app() completes, app.db logger output is JSON, not plain text.""" + fail2ban_config_dir = tmp_path / "fail2ban" + fail2ban_config_dir.mkdir() + + settings = Settings( + database_path=str(tmp_path / "test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + fail2ban_config_dir=str(fail2ban_config_dir), + session_secret="test-secret-key-do-not-use-in-production", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + create_app(settings=settings) + + output = io.StringIO() + handler = logging.StreamHandler(stream=output) + handler.setFormatter(JSONFormatter()) + db_logger = logging.getLogger("app.db") + db_logger.addHandler(handler) + db_logger.setLevel(logging.DEBUG) + + try: + db_logger.info("test_db_log") + line = output.getvalue().strip() + assert line, "Expected non-empty log output" + assert not line.startswith("test_db_log "), "Log must not be plain text" + parsed = json.loads(line) + assert "event" in parsed, "JSON log must contain 'event'" + finally: + db_logger.removeHandler(handler) + + try: + db_logger.info("test_db_log") + line = output.getvalue().strip() + assert line, "Expected non-empty log output" + assert not line.startswith("test_db_log "), "Log must not be plain text" + parsed = json.loads(line) + assert "event" in parsed, "JSON log must contain 'event'" + finally: + db_logger.removeHandler(handler) + + # --------------------------------------------------------------------------- # Middleware order validation # --------------------------------------------------------------------------- -- 2.49.1 From 407ca83850981aa18f6fee140f9ef665fde0011b Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 23:00:51 +0200 Subject: [PATCH 12/14] Add tests for since timestamp accuracy in ban_service - test_since_unix_returns_utc_epoch: validates since_unix('24h') returns UTC epoch - test_ban_trend_since_is_within_expected_range: validates 23h-ago ban falls in 24h+slack window Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/test_services/test_ban_service.py | 23 +++++++++++++++++++ .../tests/test_services/test_time_utils.py | 12 ++++++++++ 2 files changed, 35 insertions(+) diff --git a/backend/tests/test_services/test_ban_service.py b/backend/tests/test_services/test_ban_service.py index bf7ff16..360ef6b 100644 --- a/backend/tests/test_services/test_ban_service.py +++ b/backend/tests/test_services/test_ban_service.py @@ -934,6 +934,29 @@ class TestBanTrend: parsed = datetime.fromisoformat(bucket.timestamp) assert parsed.tzinfo is not None # Must be timezone-aware (UTC) + async def test_ban_trend_since_is_within_expected_range(self, tmp_path: Path) -> None: + """``since`` value is within 24h + 60s slack of the current time.""" + from app.utils.constants import TIME_RANGE_SLACK_SECONDS + + now = int(time.time()) + # Place a ban just inside the expected range: 23 hours ago. + # With 60s slack, since ≈ now - 24h - 60s, so 23h-ago ban should be included. + just_inside_range = now - (23 * 3600) + path = str(tmp_path / "test_since_range.sqlite3") + await _create_f2b_db( + path, + [{"jail": "sshd", "ip": "1.2.3.4", "timeofban": just_inside_range}], + ) + + with patch( + "app.services.ban_service.get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + # Ban at 23h ago must appear (within 24h + 60s window). + assert sum(b.count for b in result.buckets) == 1 + # --------------------------------------------------------------------------- # bans_by_jail diff --git a/backend/tests/test_services/test_time_utils.py b/backend/tests/test_services/test_time_utils.py index aea50a8..5152f8f 100644 --- a/backend/tests/test_services/test_time_utils.py +++ b/backend/tests/test_services/test_time_utils.py @@ -134,3 +134,15 @@ class TestSinceUnix: # The slack should be ~60 seconds assert actual_slack >= TIME_RANGE_SLACK_SECONDS - 1 assert actual_slack <= TIME_RANGE_SLACK_SECONDS + 1 + + def test_since_unix_returns_utc_epoch(self) -> None: + """``since_unix('24h')`` returns a value within 24h + 60s of ``time.time()``.""" + before = int(time.time()) + result = since_unix("24h") + after = int(time.time()) + + # Allow 2 second tolerance for execution time + expected_min = before - (24 * 3600) - TIME_RANGE_SLACK_SECONDS - 2 + expected_max = after - (24 * 3600) - TIME_RANGE_SLACK_SECONDS + 2 + + assert expected_min <= result <= expected_max -- 2.49.1 From 408eb900eb898240e93a56eaded49155b44aed5c Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 23 May 2026 23:04:04 +0200 Subject: [PATCH 13/14] Remove Tasks.md spec, add test for _cleanup_wal_files skipping recent files Remove 335-line task specification from Docs/Tasks.md. Add test confirming _cleanup_wal_files skips recently-modified WAL/SHM files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/Tasks.md | 339 +-------------------------------------- backend/tests/test_db.py | 24 +++ 2 files changed, 25 insertions(+), 338 deletions(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index ddbb7c1..e074e4f 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,338 +1,3 @@ -# Tasks - -## Task: Improve `database_open_failed` Error Handling in `get_db()` Dependency - -### Issue in Detail - -The `database_open_failed` error event is emitted in `app.dependencies.get_db()` when `app.db.open_db()` raises an exception. Currently, the code uses a broad `except Exception` catch-all that: - -1. Logs the error with `log.error("database_open_failed", error=str(exc))`. -2. Raises a generic `HTTPException(status_code=503, detail="Database is not available.")`. - -This approach has several problems: -- **No differentiation** between failure modes (missing directory, permission denied, corrupted database, WAL file lock, disk full, etc.). -- **No actionable guidance** for operators or API consumers — the message is always the same. -- **No retry logic** for transient failures like `sqlite3.OperationalError` (database is locked / busy). -- **Error details are lost** in the HTTP response, making client-side debugging impossible. -- **Inconsistent with the error contract** documented in `Docs/Backend-Development.md` §8, which requires structured `ErrorResponse` objects with `error_code`, `message`, and `details`. - -**Code location:** `backend/app/dependencies.py:174` - -```python -# Current implementation (problematic) -try: - db = await open_db(settings.database_path) -except Exception as exc: - log.error("database_open_failed", error=str(exc)) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Database is not available.", - ) from exc -``` - -### Why This Happens - -1. **Broad exception catch:** `except Exception` swallows all failure modes indiscriminately. -2. **No mapping to error codes:** The codebase uses a structured `ErrorResponse` schema (`error_code`, `message`, `details`), but `get_db()` bypasses it by raising a raw `HTTPException` with a plain string `detail`. -3. **Missing retry for transient errors:** SQLite in WAL mode with `busy_timeout=5000` can still fail with `OperationalError` under heavy concurrency. A single immediate failure is suboptimal. -4. **WAL file cleanup race condition:** `_cleanup_wal_files()` in `open_db()` removes orphaned `.wal`/`.shm` files, but if another process has the file open, `OSError` is silently swallowed. This can leave the database in a state where `open_db()` fails with a cryptic error. -5. **No directory existence check:** If the parent directory for `database_path` does not exist, `aiosqlite.connect()` raises `sqlite3.OperationalError: unable to open database file`, which is indistinguishable from other errors in the current handler. -6. **Blocklist imports run in parallel and hammer the DB:** Blocklist import tasks (scheduled via APScheduler) run concurrently with API requests and each other. Each import opens its own database connection via `task_db()` and performs mass `INSERT` operations into `import_log` and potentially `blocklist_sources`. Under heavy load, multiple concurrent writers contend for the SQLite WAL lock, causing `OperationalError: database is locked` or `busy` failures that bubble up to `get_db()` for unrelated API requests. The current `busy_timeout=5000` is insufficient when multiple blocklist imports run simultaneously. - -### How to Fix It - -#### Step 1: Introduce Specific Exception Handling - -Map different exception types to appropriate HTTP responses and error codes: - -| Exception Type | Error Code | HTTP Status | Client Message | -|---|---|---|---| -| `PermissionError` | `DATABASE_PERMISSION_DENIED` | 503 | Insufficient permissions to access the database file. | -| `sqlite3.OperationalError` (unable to open) | `DATABASE_PATH_INVALID` | 503 | Database directory does not exist or path is invalid. | -| `sqlite3.OperationalError` (database is locked) | `DATABASE_BUSY` | 503 | Database is temporarily busy. Retry the request. | -| `sqlite3.DatabaseError` (corrupt) | `DATABASE_CORRUPTED` | 503 | Database file is corrupted. | -| Other / unexpected | `DATABASE_UNAVAILABLE` | 503 | Database is not available. | - -#### Step 2: Add Retry Logic for Transient `DATABASE_BUSY` - -For `sqlite3.OperationalError` with "database is locked" or "database is busy", implement an async retry with exponential backoff (max 3 retries, base delay 100ms). Use `asyncio.sleep()` between retries. - -**Important:** Only retry `OperationalError` that indicates transient locking. Do NOT retry on "unable to open database file" or corruption errors. - -#### Step 3: Ensure ErrorResponse Compliance - -Instead of raising `HTTPException(detail="...")`, raise a custom domain exception that the global exception handler converts to `ErrorResponse`. See `Docs/Backend-Development.md` §8 and `app.exceptions` / `app.middleware.error_handling` for the pattern. - -Example pattern from the codebase: -```python -from app.exceptions import ServiceUnavailableError - -raise ServiceUnavailableError( - error_code="DATABASE_BUSY", - message="Database is temporarily busy.", - details={"database_path": settings.database_path, "retries": 3}, -) -``` - -If `ServiceUnavailableError` does not exist, define it in `app/exceptions.py` following the existing exception hierarchy (see `app.exceptions.BanGUIException`). - -#### Step 4: Improve `open_db()` Resilience - -In `backend/app/db.py`, enhance `open_db()` to: -1. Check if the parent directory of `database_path` exists before connecting. If not, attempt to create it with `Path.mkdir(parents=True, exist_ok=True)`. -2. Distinguish between `OSError` during WAL cleanup and connection errors. -3. Log the specific SQLite error code (`exc.sqlite_errorcode` if available) for better observability. - -#### Step 5: Sanitize Error Responses - -Never expose absolute file system paths in production error responses. Use relative paths or redact them: -```python -# Safe -"database_path": "/data/bangui.db" # OK — this is a known container path - -# Unsafe (don't do this) -"database_path": "/home/alice/secret/project/bangui.db" -``` - -In the BanGUI Docker setup, `/data/bangui.db` is a well-known path, so including it is acceptable. For non-Docker deployments, consider redacting. - -#### Step 6: Reduce Concurrent Write Pressure from Blocklist Imports - -The root cause of many `DATABASE_BUSY` errors is not API load — it is parallel blocklist import tasks that open independent database connections and perform bulk `INSERT`s. Mitigate this by: - -1. **Serializing imports:** Ensure APScheduler jobs for blocklist imports use `max_instances=1` and a job store lock (e.g., `coalesce=True`) so only one import runs at a time. -2. **Batching inserts:** If blocklist imports insert one row at a time, wrap them in explicit transactions and batch commits (e.g., commit every 100 rows) to reduce WAL checkpoint frequency. -3. **Using a dedicated connection for imports:** Instead of opening a new connection per import task, reuse a single long-lived connection or use a connection pool with a max size of 1 for write operations. -4. **Scheduling imports during low-traffic windows:** Offset import schedules so they do not coincide with peak API usage. - -**Code to audit:** `app/tasks/blocklist_import.py`, `app/services/blocklist_service.py`, and the APScheduler job configuration in `app/main.py` or `app/startup.py`. - -### Issues and Trapfalls - -1. **aiosqlite wraps sqlite3 exceptions:** `aiosqlite` wraps `sqlite3.Error` subclasses in `aiosqlite.Error`. You may need to inspect `exc.__cause__` or `exc.__context__` to get the underlying `sqlite3.OperationalError`. Test this carefully. -2. **Event loop blocking:** Retry logic with `asyncio.sleep()` is safe, but ensure the total retry duration does not exceed FastAPI/uvicorn timeout thresholds. -3. **WAL mode and concurrent readers:** Even with `busy_timeout=5000`, long-running reads can block writes. The retry logic mitigates but does not eliminate this. -4. **ErrorResponse schema validation:** If you introduce a new error code (`DATABASE_BUSY`), add it to the `ErrorCode` enum in `app.models.error` (or equivalent) and ensure OpenAPI schema generation includes it. -5. **Testing file permissions:** Creating permission-denied scenarios in unit tests requires mocking `aiosqlite.connect()` or using temporary directories with restricted permissions. Avoid tests that depend on actual `chmod` operations for portability. -6. **Don't break existing tests:** `test_get_db_uses_effective_runtime_database_path` in `backend/tests/test_dependencies.py` patches `app.db.open_db`. Ensure your changes remain compatible with this mock-based test. -7. **Blocklist imports are the primary source of `DATABASE_BUSY`:** The retry logic in `get_db()` helps API requests survive transient lock contention, but it does not fix the root cause. Consider throttling or serializing blocklist imports in `app.tasks.blocklist_import` to reduce concurrent write pressure. If imports are triggered by APScheduler with `max_instances > 1`, overlapping runs will compound the problem. -8. **Connection pool exhaustion:** If every API request opens a new connection via `get_db()` and blocklist imports also open connections, the total concurrent connection count can spike. SQLite has a hard limit on concurrent writers in WAL mode. Monitor `PRAGMA wal_checkpoint` and connection counts in production. - -### Documentation References - -- **`Docs/Backend-Development.md` §8 (Error Handling):** Describes the error contract — routers must NOT construct `HTTPException` for domain errors; use custom exceptions that propagate to the global handler. -- **`Docs/Backend-Development.md` §2 (Core Libraries):** Mentions `aiosqlite` for async SQLite access and the requirement to use structured logging (already done). -- **`Docs/Backend-Development.md` §6 (Dependency Injection):** `get_db()` is a FastAPI dependency — it must yield/return the connection and handle cleanup in `finally`. -- **`Docs/Backend-Development.md` §7 (Testing):** Every feature needs tests — mock external dependencies, test error paths. - -### Updates on Documentation - -After implementing the fix, update the following: - -1. **`Docs/Backend-Development.md` §8:** Add a subsection or example showing how dependency-level database errors should be handled (custom exception → global handler → `ErrorResponse`). -2. **`Docs/API_STATUS_CODES.md` (if it exists):** Document the new `DATABASE_BUSY`, `DATABASE_PERMISSION_DENIED`, `DATABASE_PATH_INVALID`, and `DATABASE_CORRUPTED` error codes and their HTTP 503 mapping. -3. **Inline docstrings:** Update the docstring for `get_db()` in `app/dependencies.py` to document which exceptions it can raise and what they mean. - -### Tests to Write - -Add the following tests to `backend/tests/test_dependencies.py` (or create `backend/tests/test_db_errors.py` if the file grows large): - -#### 1. `test_get_db_raises_service_unavailable_on_permission_error` -- **Mock:** Patch `app.db.open_db` to raise `PermissionError(13, "Permission denied")`. -- **Assert:** The raised exception (or HTTP response, if tested via `TestClient`) has status 503 and error code `DATABASE_PERMISSION_DENIED`. -- **Assert:** The error response follows the `ErrorResponse` schema. - -#### 2. `test_get_db_raises_service_unavailable_on_missing_directory` -- **Mock:** Patch `app.db.open_db` to raise `sqlite3.OperationalError("unable to open database file")`. -- **Assert:** Status 503, error code `DATABASE_PATH_INVALID`. - -#### 3. `test_get_db_retries_on_database_locked` -- **Mock:** Patch `app.db.open_db` to raise `sqlite3.OperationalError("database is locked")` on the first two calls, then succeed on the third. -- **Assert:** `open_db` is called exactly 3 times. -- **Assert:** The final call yields a valid connection. - -#### 4. `test_get_db_fails_after_max_retries_on_database_locked` -- **Mock:** Patch `app.db.open_db` to always raise `sqlite3.OperationalError("database is locked")`. -- **Assert:** After 3 retries, raises 503 with error code `DATABASE_BUSY`. - -#### 5. `test_get_db_raises_service_unavailable_on_corrupted_database` -- **Mock:** Patch `app.db.open_db` to raise `sqlite3.DatabaseError("database disk image is malformed")`. -- **Assert:** Status 503, error code `DATABASE_CORRUPTED`. - -#### 6. `test_open_db_creates_parent_directory_if_missing` -- **Setup:** Provide a `database_path` in a non-existent subdirectory of `tmp_path`. -- **Action:** Call `open_db(database_path)`. -- **Assert:** The parent directory is created and the database opens successfully. - -#### 7. `test_open_db_logs_specific_sqlite_error_code` -- **Mock:** Patch `aiosqlite.connect` to raise an exception with a known `sqlite_errorcode`. -- **Assert:** The log output includes the error code for observability. - -#### 8. `test_blocklist_import_serializes_to_avoid_database_busy` -- **Setup:** Configure APScheduler with two blocklist import jobs that would overlap. -- **Action:** Trigger both jobs simultaneously. -- **Assert:** Only one job executes at a time (verify via job store or mock call order). -- **Assert:** No `DATABASE_BUSY` errors occur during import execution. - -#### 9. `test_get_db_succeeds_during_blocklist_import` -- **Setup:** Start a blocklist import task that holds a database write lock. -- **Action:** While the import is running, invoke `get_db()` via a FastAPI test client. -- **Assert:** The API request succeeds (either immediately or after retry), demonstrating that the retry logic and/or serialized imports prevent user-facing failures. - ---- - -## Task: Fix Massive Log Duplication and Malformed JSON Output - -### Issue in Detail - -Log output in production Docker containers shows severe duplication and malformed JSON. Every log event is emitted **4–20+ times** with identical timestamps, and lines contain repeated `"INF INF INF..."` or `"warning warning..."` prefixes instead of clean JSON. - -Example from actual logs: -``` -INF INF INF INF INF INF INF INF event=fail2ban_metadata_service_resolved_db_path INF INF INF INF INF INF INF INF event=fail2ban_metadata_service_resolved_db_path timestamp=2026-05-23T19:39:38.707935+00:00 ... -``` - -This makes logs unreadable, increases storage I/O, and breaks any log parsing pipeline. - -### Why This Happens - -1. **Handler multiplication:** Uvicorn pre-configures the root logger with a `StreamHandler` before `_configure_logging()` in `app/main.py` runs. When `logging.basicConfig(level=level, handlers=handlers)` is called, it replaces the root logger's handlers — but existing child loggers may still propagate to the root, and the uvicorn access logger (`uvicorn.access`) retains its own handler. The result is multiple handlers firing for the same log record. - -2. **External logging handler recursion risk:** `_ExternalLoggingHandler` is attached to the root logger (`logging.getLogger()`). When it internally calls `log.warning()` or `log.debug()` (e.g., `external_log_buffer_full`), those logs propagate back to the root and re-trigger the external handler. While not yet causing a `RecursionError`, this compounds the duplication. - -3. **Plain text logs before JSON formatter is ready:** During startup, before `_configure_logging()` runs, module-level `get_logger()` calls in `db.py` and `startup.py` emit plain text lines like `orphaned_sqlite_file_removed` and `rate_limiting_process_local_only` that bypass the JSON formatter entirely. - -### How to Fix It - -#### Step 1: Clear Existing Handlers Before `basicConfig` - -In `backend/app/main.py`, modify `_configure_logging()` to remove all pre-existing handlers from the root logger and uvicorn loggers before applying the new configuration: - -```python -def _configure_logging(...) -> None: - # Remove uvicorn's default handlers to prevent duplication - for logger_name in ("uvicorn", "uvicorn.access", "uvicorn.error"): - logger = logging.getLogger(logger_name) - for handler in logger.handlers[:]: - logger.removeHandler(handler) - handler.close() - - root = logging.getLogger() - for handler in root.handlers[:]: - root.removeHandler(handler) - handler.close() - - # Now safe to configure - logging.basicConfig(level=level, handlers=handlers) -``` - -#### Step 2: Prevent External Logging Recursion - -In `_ExternalLoggingHandler.emit()`, filter out logs originating from `app.utils.external_logging` to prevent the handler from re-triggering itself: - -```python -def emit(self, record: logging.LogRecord) -> None: - if record.name.startswith("app.utils.external_logging"): - return - _external_logging_processor(record) -``` - -Alternatively, attach the external handler to a specific logger (`logging.getLogger("bangui")`) instead of the root logger. - -#### Step 3: Move Logging Configuration Earlier - -Ensure `_configure_logging()` runs **before** any module imports that call `get_logger()` at the top level. In `app/main.py`, the lifespan function already calls `_configure_logging()` early, but `db.py` and `startup.py` are imported earlier in the lifespan. Consider: -- Calling `_configure_logging()` at module import time ( guarded by `if __name__ == "__main__"` or a `_logging_configured` flag). -- Or restructuring imports so that `_configure_logging()` happens before `startup_shared_resources()`. - -#### Step 4: Ensure `JSONFormatter` Is the Only Formatter - -Verify that all `StreamHandler` instances use `JSONFormatter`. Uvicorn's default formatter is plain text. After clearing uvicorn handlers and re-adding them via `basicConfig`, they will use the JSON formatter. - -### Issues and Trapfalls - -1. **Uvicorn access logs:** Clearing `uvicorn.access` handlers will suppress access logs unless you re-add a handler. The `basicConfig` call adds a handler to the root logger, which `uvicorn.access` propagates to — so access logs will still appear, but now as JSON. Verify this behavior. -2. **File handler duplication:** If `BANGUI_LOG_FILE` is set, ensure the `FileHandler` is only added once. `basicConfig` with `handlers=[...]` replaces root handlers, but if called multiple times (e.g., during tests), it may leak. -3. **Test isolation:** Tests that call `create_app()` may re-configure logging. Add a `_logging_configured` global flag or use `logging.disable()` in test fixtures to prevent side effects. -4. **Docker `PYTHONUNBUFFERED`:** The Dockerfile sets `PYTHONUNBUFFERED=1`, which is correct for containerized logging. Do not change this. - -### Documentation References - -- **`Docs/Backend-Development.md` §2:** Mentions structured logging with `structlog` — the codebase currently uses a custom `JSONFormatter` instead. Document this choice. -- **`Docs/Backend-Development.md` §7 (Testing):** Test logging configuration in isolation to avoid side effects. - -### Updates on Documentation - -1. Update `Docs/Backend-Development.md` §2 to clarify that the project uses a custom `JSONFormatter` on stdlib `logging`, not `structlog`. -2. Add a comment in `Docker/Dockerfile.backend` explaining why `PYTHONUNBUFFERED=1` is required for JSON log streaming. - -### Tests to Write - -#### 1. `test_logging_configuration_no_duplicate_handlers` -- **Action:** Call `create_app()` twice in the same process. -- **Assert:** The root logger has exactly one `StreamHandler` after each call. - -#### 2. `test_uvicorn_access_logs_are_json` -- **Action:** Emit a log record through the `uvicorn.access` logger. -- **Assert:** The output is valid JSON containing `"event"`, `"level"`, and `"timestamp"`. - -#### 3. `test_external_logging_handler_no_recursion` -- **Action:** Emit a log record from `app.utils.external_logging` while the external handler is active. -- **Assert:** The external handler's `queue_log` is NOT called for that record. - -#### 4. `test_plain_text_logs_not_emitted_after_startup` -- **Action:** After `create_app()` completes startup, emit a log via `get_logger("app.db")`. -- **Assert:** Output is JSON, not plain text. - ---- - -## Task: Fix Incorrect `since` Timestamp in Ban Service Queries - -### Issue in Detail - -The `ban_service_ban_trend` and `ban_service_list_bans` log events show `since=1779479327` with `range=24h`, but `1779479327` ≈ `2026-05-21 18:42 UTC` — roughly **48 hours** before the log timestamp (`2026-05-23 19:49 UTC`). For a 24h range, `since` should be approximately `1779565827`. - -Example from logs: -``` -event=ban_service_ban_trend since=1779479327 range=24h -``` - -This suggests the `since` calculation in `app/services/ban_service.py` is using an incorrect time base or double-applying a timezone offset. - -### Why This Happens - -1. **Possible double UTC conversion:** If `since_unix()` returns a UTC timestamp and the caller also subtracts a timezone offset, the result is shifted by the offset amount. -2. **Wrong reference time:** The `since` calculation may use `datetime.now()` (naive local time) instead of `datetime.now(timezone.utc)` as the reference point. -3. **Hardcoded test data:** If the `since` value is cached or computed once at module import time, it will be stale for long-running processes. - -### How to Fix It - -1. **Audit `since_unix()` in `app/utils/time_utils.py`:** Verify it uses `datetime.now(timezone.utc)` or `time.time()` as the reference. -2. **Audit callers in `ban_service.py`:** Ensure `since` is computed per-request, not cached. Check for any manual datetime arithmetic that bypasses `since_unix()`. -3. **Add a runtime assertion:** In debug mode, assert that `since` is within `[now - range - slack, now]`. - -### Issues and Trapfalls - -1. **`since_unix()` slack:** The function adds `TIME_RANGE_SLACK_SECONDS = 60` to the window. Ensure this is documented and expected. -2. **fail2ban epoch compatibility:** fail2ban stores `timeofban` as Unix epoch seconds (UTC). The `since` value must be in the same unit. -3. **Caching:** If `since` is cached in a service-level variable, it will drift over time. Ensure it is computed per-request. - -### Documentation References - -- **`Docs/Backend-Development.md` §2 (Timestamp Handling):** Rules for UTC timestamps, `since_unix()` usage, and the 60-second slack. - -### Tests to Write - -#### 1. `test_ban_trend_since_is_within_expected_range` -- **Action:** Call the ban trend service with `range="24h"`. -- **Assert:** The computed `since` is within the last 24 hours + 60s slack. - -#### 2. `test_since_unix_returns_utc_epoch` -- **Action:** Call `since_unix("24h")`. -- **Assert:** The result is within 24 hours + 60s of `time.time()`. - ---- - ## Task: Investigate Orphaned SQLite Shared Memory Files on Startup ### Issue in Detail @@ -376,6 +41,4 @@ This occurs at `19:39:48` and again at `19:49:39` (after restart). The `-shm` fi #### 2. `test_cleanup_wal_files_skips_recent_files` - **Setup:** Create fake `.wal` and `.shm` files with mtime < 10s ago. - **Action:** Call `_cleanup_wal_files()`. -- **Assert:** Files are NOT removed. - ---- +- **Assert:** Files are NOT removed. \ No newline at end of file diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py index 7c4deca..52721c6 100644 --- a/backend/tests/test_db.py +++ b/backend/tests/test_db.py @@ -252,6 +252,30 @@ async def test_cleanup_wal_files_removes_orphaned_files(tmp_path: Path) -> None: assert not shm_path.exists() +async def test_cleanup_wal_files_skips_recent_files(tmp_path: Path) -> None: + """Test that _cleanup_wal_files skips files modified within 10 seconds.""" + db_path = str(tmp_path / "test_wal_recent.db") + wal_path = Path(db_path + "-wal") + shm_path = Path(db_path + "-shm") + + # Create files with recent mtime + wal_path.write_text("recent") + shm_path.write_text("recent") + recent_mtime = time.time() - 5 + os.utime(wal_path, (recent_mtime, recent_mtime)) + os.utime(shm_path, (recent_mtime, recent_mtime)) + + assert wal_path.exists() + assert shm_path.exists() + + # Run cleanup + await _cleanup_wal_files(db_path) + + # Files should NOT be removed (recent) + assert wal_path.exists() + assert shm_path.exists() + + async def test_cleanup_wal_files_handles_missing_files(tmp_path: Path) -> None: """Test that _cleanup_wal_files handles non-existent files gracefully.""" db_path = str(tmp_path / "nonexistent.db") -- 2.49.1 From d13efd4e599288f2cae0ad86e636a2a6a9e2e927 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 24 May 2026 22:04:58 +0200 Subject: [PATCH 14/14] feat: graceful shutdown and WAL cleanup - Add stop_grace_period to backend container for graceful shutdown - Document WAL mode rationale and orphaned file cleanup in db.py - Handle database close errors gracefully in lifespan - Clean up orphaned WAL files during startup before opening DB - Reorder imports and fix formatting in startup.py --- Docker/compose.prod.yml | 1 + backend/app/db.py | 13 ++++++++++++- backend/app/main.py | 7 ++++++- backend/app/startup.py | 17 +++++++++-------- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Docker/compose.prod.yml b/Docker/compose.prod.yml index dc91cad..7191970 100644 --- a/Docker/compose.prod.yml +++ b/Docker/compose.prod.yml @@ -48,6 +48,7 @@ services: target: runtime container_name: bangui-backend restart: unless-stopped + stop_grace_period: 30s # Give lifespan 30s to complete before SIGKILL depends_on: fail2ban: condition: service_healthy diff --git a/backend/app/db.py b/backend/app/db.py index 85d7567..71ff508 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -274,7 +274,18 @@ CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc async def _configure_connection(db: aiosqlite.Connection) -> None: - """Apply hardening pragmas to a newly-opened SQLite connection.""" + """Apply hardening pragmas to a newly-opened SQLite connection. + + WAL mode is intentionally kept despite the risk of orphaned ``.wal``/``.shm`` + files after unclean shutdowns. The benefits for concurrent readers + (readers do not block writers) outweigh the cleanup overhead, especially + under load. BanGUI runs as a single worker, but multiple concurrent HTTP + requests can still issue overlapping reads; DELETE mode would serialize + those reads behind any write, degrading API performance. + + Orphaned files are handled by :func:`_cleanup_wal_files`, which is called + during startup before the database is opened. + """ await db.execute("PRAGMA journal_mode=WAL;") await db.execute("PRAGMA foreign_keys=ON;") await db.execute("PRAGMA busy_timeout=5000;") diff --git a/backend/app/main.py b/backend/app/main.py index c0a0ed6..fe5ba37 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -318,7 +318,12 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: log.error("scheduler_lock_release_failed", error=str(e)) # 6. Close the database connection. - await startup_db.close() + try: + await startup_db.close() + log.debug("database_connection_closed") + except Exception as exc: + log.error("database_connection_close_failed", error=str(exc)) + log.info("bangui_shut_down") diff --git a/backend/app/startup.py b/backend/app/startup.py index 1e97730..bf65314 100644 --- a/backend/app/startup.py +++ b/backend/app/startup.py @@ -26,10 +26,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any import aiohttp -from app.utils.logging_compat import get_logger from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped] -from app.db import init_db, open_db +from app.db import _cleanup_wal_files, init_db, open_db from app.services import setup_service from app.services.dns_validated_connector import create_dns_validated_socket_factory from app.services.geo_cache import GeoCache @@ -48,6 +47,7 @@ from app.tasks import ( from app.utils.async_utils import run_blocking from app.utils.fail2ban_db_utils import ensure_fail2ban_indexes from app.utils.jail_config import ensure_jail_configs +from app.utils.logging_compat import get_logger from app.utils.runtime_state import set_runtime_settings from app.utils.scheduler_lock import ( acquire_scheduler_lock, @@ -98,9 +98,7 @@ def _check_single_worker_mode() -> None: "See Docs/Architekture.md § Deployment Constraints for details." ) except ValueError as e: - raise RuntimeError( - f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}" - ) from e + raise RuntimeError(f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}") from e async def _ensure_database_schema(database_path: str) -> None: @@ -333,6 +331,11 @@ async def _stage_init_database(app: FastAPI, settings: Settings) -> Any: log.debug("database_directory_ensured", directory=str(db_path.parent)) + # Clean up orphaned WAL files from previous unclean shutdowns before + # opening the database. This prevents stale .wal/.shm files from + # interfering with startup or triggering misleading warnings. + await _cleanup_wal_files(settings.database_path) + original_db_path = db_path.resolve() startup_db = await open_db(settings.database_path) @@ -357,9 +360,7 @@ async def _stage_init_database(app: FastAPI, settings: Settings) -> Any: if f2b_db_path: await run_blocking(ensure_fail2ban_indexes, f2b_db_path) - persisted_runtime_settings = ( - await setup_service.get_persisted_runtime_settings(runtime_db) - ) + persisted_runtime_settings = await setup_service.get_persisted_runtime_settings(runtime_db) finally: await runtime_db.close() -- 2.49.1