Merge pull request 'refactoring-backend' (#3) from refactoring-backend into main
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Import Boundary (push) Has been cancelled
CI / OpenAPI Breaking Changes (push) Has been cancelled
CI / OpenAPI Baseline Commit (push) Has been cancelled

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-05-20 20:23:46 +02:00
549 changed files with 89743 additions and 20200 deletions

33
.editorconfig Normal file
View File

@@ -0,0 +1,33 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
[*.py]
indent_style = space
indent_size = 4
[*.{js,ts,tsx,jsx}]
indent_style = space
indent_size = 2
[*.md]
indent_style = space
indent_size = 2
[Dockerfile]
indent_style = space
indent_size = 4
[*.yml]
indent_style = space
indent_size = 2
[*.yaml]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab

60
.env.example Normal file
View File

@@ -0,0 +1,60 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Environment Variables Template
# Copy this file to .env and fill in the values below
# ──────────────────────────────────────────────────────────────
# Session Secret (REQUIRED)
# Generate a secure random secret for session tokens.
# WARNING: Do not use the same secret across different environments.
# Generate with: python -c 'import secrets; print(secrets.token_hex(32))'
# Example value: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
BANGUI_SESSION_SECRET=
# Previous Session Secret (optional)
# Used during secret rotation to accept tokens signed with the old secret.
# Set this to the previous secret when rotating secrets, then unset it once
# all old tokens have expired. This enables gradual rotation without forcing logout.
# Leave empty unless performing a rotation.
BANGUI_SESSION_SECRET_PREVIOUS=
# Timezone (optional, defaults to UTC)
# Use standard timezone names from the IANA Time Zone Database
# Examples: America/New_York, Europe/London, Asia/Tokyo, UTC
BANGUI_TIMEZONE=UTC
# Backend port (optional, defaults to 8000)
# When using docker-compose, this is the port on your host machine
BANGUI_BACKEND_PORT=8000
# Frontend port (optional, defaults to 5173)
# When using docker-compose, this is the port on your host machine
BANGUI_FRONTEND_PORT=5173
# Public port (optional, defaults to 8080)
# When using production compose, this is the public-facing port
BANGUI_PORT=8080
# IP Geolocation (optional)
# Path to MaxMind GeoLite2-Country MMDB database file (primary resolver).
# Download from: https://www.maxmind.com/en/geolite2/signup
# If not set, geolocation is disabled (or falls back to HTTP if enabled below).
# Example: /data/GeoLite2-Country.mmdb
BANGUI_GEOIP_DB_PATH=
# IP Geolocation HTTP Fallback (optional, defaults to false)
# ⚠️ SECURITY WARNING: Only enable if you cannot mount the MaxMind database.
# When enabled, unresolved IP addresses are sent unencrypted to ip-api.com.
# This is a privacy and GDPR/CCPA concern. Do NOT enable in production unless necessary.
# Set to "true" to enable (default is "false" for security).
BANGUI_GEOIP_ALLOW_HTTP_FALLBACK=false
# CORS Configuration (optional)
# Comma-separated list of allowed origins for cross-origin requests.
# Defaults to common localhost development origins (http://localhost:5173, http://127.0.0.1:5173, etc).
# Set this in production to your frontend domain(s).
# Examples:
# BANGUI_CORS_ALLOWED_ORIGINS=https://example.com,https://app.example.com
# BANGUI_CORS_ALLOWED_ORIGINS= (empty to disable CORS)
# WARNING: Do NOT use wildcard "*" — it defeats CORS security when credentials are enabled.
BANGUI_CORS_ALLOWED_ORIGINS=

174
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,174 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests with coverage
run: pytest --cov=app --cov-report=term-missing --cov-fail-under=80
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: backend/htmlcov/
retention-days: 7
ruff:
name: Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install ruff
- name: Run ruff
run: ruff check .
mypy:
name: Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run mypy
run: mypy app
import-linter:
name: Import Boundary
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run import-linter
run: linter
openapi-breaking-changes:
name: OpenAPI Breaking Changes
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
# Only run on PRs — main branch push is covered by the baseline-commit step.
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Generate current OpenAPI spec
run: python scripts/generate_openapi.py current-openapi.json
- name: Fetch baseline spec from main
run: |
git fetch origin main:main
git show main:backend/openapi.json > baseline-openapi.json 2>/dev/null || \
echo "{}" > baseline-openapi.json
- name: Install openapi-diff
run: npm install -g openapi-diff
- name: Check for breaking changes
run: |
set +e
openapi-diff baseline-openapi.json current-openapi.json --format stylish 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "BREAKING CHANGE DETECTED — see output above"
exit 1
fi
echo "No breaking changes found."
openapi-baseline-commit:
name: OpenAPI Baseline Commit
runs-on: ubuntu-latest
# Only run on push to main (not PRs).
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Generate and commit OpenAPI baseline
run: |
python scripts/generate_openapi.py backend/openapi.json
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add backend/openapi.json
git diff --cached --quiet && echo "No changes to openapi.json" || \
git commit -m "chore: update OpenAPI baseline spec [skip ci]
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>"

18
.gitignore vendored
View File

@@ -95,20 +95,16 @@ Thumbs.db
# ── Docker dev config ─────────────────────────
# Ignore auto-generated linuxserver/fail2ban config files,
# but track our custom filter, jail, and documentation.
Docker/fail2ban-dev-config/**
!Docker/fail2ban-dev-config/README.md
!Docker/fail2ban-dev-config/fail2ban/
!Docker/fail2ban-dev-config/fail2ban/filter.d/
!Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf
!Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-access.conf
!Docker/fail2ban-dev-config/fail2ban/jail.d/
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
!Docker/fail2ban-dev-config/fail2ban/jail.local
data/*
# ── Misc ──────────────────────────────────────
*.log
*.tmp
*.bak
*.orig
# ── E2E test results ───────────────────────────
e2e/results/
e2e/Instructions.md
playwright-log.txt

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
cd frontend && npm run validate:types

23
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-merge-conflict
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-prettier
hooks:
- id: prettier
args: [--check]
name: prettier (frontend)
files: ^frontend/
entry: prettier --check
language: system

157
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,157 @@
# Contributing to BanGUI
Welcome! This guide covers everything you need to know to set up your dev environment, understand the codebase, and submit changes.
---
## Dev Setup
### 1 — Clone and init
```bash
git clone <repo-url>
cd BanGUI
cp .env.example .env
python -c 'import secrets; print(secrets.token_hex(32))'
# paste output as BANGUI_SESSION_SECRET in .env
```
### 2 — Start the stack
```bash
make up
```
Backend: http://127.0.0.1:8000 · Frontend (Vite proxy): http://127.0.0.1:5173
### 3 — Editor Setup
Install **EditorConfig** plugin for your IDE. Ensures consistent formatting (indent style, line endings) across all editors.
| IDE | Plugin |
|-----|--------|
| VS Code | EditorConfig (ms-vscode.editorconfig) |
| PyCharm / IntelliJ | Built-in (enable in Settings → Editor → Code Style) |
| Vim / Neovim | editorconfig-vim |
| Sublime Text | EditorConfig |
### 4 — Pre-commit hooks
**Backend** (pre-commit, all languages):
```bash
pip install pre-commit
pre-commit install
```
**Frontend** (husky, TypeScript validation):
```bash
cd frontend && npm install
npx husky install
```
Hooks run automatically on every `git commit`. To run manually:
```bash
pre-commit run --all-files # backend hooks
cd frontend && npm run validate:types # frontend type check
```
---
## Project Structure
```
BanGUI/
├── backend/ Python FastAPI app
│ └── app/
│ ├── routers/ HTTP endpoint handlers
│ ├── services/ Business logic
│ ├── repos/ Data access
│ ├── models/ Pydantic request/response/domain models
│ └── utils/ Shared helpers
├── frontend/ React + TypeScript + Fluent UI v9
│ └── src/
│ ├── pages/ Route-level page components
│ ├── components/ Reusable UI components
│ ├── hooks/ Custom React hooks
│ └── types/ Shared TypeScript types
├── Docs/ Architecture, design, and feature documentation
└── Docker/ Container compose files
```
---
## Code Quality
| Tool | Scope | Command |
|---|---|---|
| `ruff` | Backend linting | `cd backend && ruff check .` |
| `ruff-format` | Backend formatting | `cd backend && ruff format .` |
| `mypy --strict` | Backend type checking | `cd backend && mypy --strict app` |
| `tsc --noEmit` | Frontend type checking | `cd frontend && tsc --noEmit` |
| `eslint` | Frontend linting | `cd frontend && eslint src` |
| `prettier --check` | Frontend formatting | `cd frontend && prettier --check src` |
| `import-linter` | Layer boundary enforcement | `cd backend && linter` |
**All checks must pass before committing.** CI runs the same suite.
---
## Testing
```bash
# Backend
cd backend && pytest --cov=app --cov-report=term-missing
# Coverage threshold: 80%. Build fails if coverage drops below.
```
The CI pipeline enforces the same 80% minimum coverage threshold.
---
## Security Rules
### Never echo raw user input in error messages
User-supplied values (jail names, filter names, action names, IPs, filenames, etc.)
MUST be sanitized before interpolation into any string that may be rendered in an
HTML context (error messages, admin UI, email notifications).
Use the `sanitize_for_display()` helper from `app.utils.display_sanitizer`:
```python
from app.utils.display_sanitizer import sanitize_for_display
# Good: sanitized before display
super().__init__(f"Jail not found: {sanitize_for_display(name)!r}")
# Bad: raw user input echoed — XSS vector if rendered as HTML
super().__init__(f"Jail not found: {name!r}")
```
This rule applies even when the value has been validated: validation checks the
format, not the rendering context. JSON API responses do NOT need sanitization
(JSON is not HTML); apply it only at HTML render boundaries.
---
## Stack
| Layer | Stack |
|---|---|
| Backend | Python 3.12+, FastAPI, Pydantic v2, aiosqlite, structlog |
| Frontend | TypeScript, React, Fluent UI v9, Vite |
| Container | Docker Compose (development + production) |
---
## Key Docs
- [Instructions.md](Docs/Instructions.md) — Agent operating rules
- [Backend-Development.md](Docs/Backend-Development.md) — Backend conventions
- [Web-Development.md](Docs/Web-Development.md) — Frontend conventions
- [Features.md](Docs/Features.md) — Complete feature list
- [Architekture.md](Docs/Architekture.md) — System architecture

View File

@@ -7,6 +7,11 @@
# Usage:
# docker build -t bangui-backend -f Docker/Dockerfile.backend .
# podman build -t bangui-backend -f Docker/Dockerfile.backend .
#
# Signal handling:
# - STOPSIGNAL defaults to SIGTERM (handled by uvicorn → lifespan shutdown)
# - stop_grace_period in docker-compose.yml controls Docker's kill timeout
# - Python code allows 25s for in-flight tasks to drain before hard kill
# ──────────────────────────────────────────────────────────────
# ── Stage 1: build dependencies ──────────────────────────────
@@ -33,6 +38,11 @@ FROM docker.io/library/python:3.12-slim AS runtime
LABEL maintainer="BanGUI" \
description="BanGUI backend — fail2ban web management API"
# Install curl for healthcheck (used by Docker HEALTHCHECK and Compose healthcheck)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user for security
RUN groupadd --gid 1000 bangui \
&& useradd --uid 1000 --gid bangui --shell /bin/bash --create-home bangui
@@ -56,14 +66,32 @@ VOLUME ["/data"]
# Default environment values (override at runtime)
ENV BANGUI_DATABASE_PATH="/data/bangui.db" \
BANGUI_FAIL2BAN_SOCKET="/var/run/fail2ban/fail2ban.sock" \
BANGUI_LOG_LEVEL="info"
BANGUI_LOG_LEVEL="info" \
BANGUI_WORKERS="1"
EXPOSE 8000
USER bangui
# Health-check using the built-in health endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
# Returns exit 0 (success) for HTTP 200 (fail2ban online)
# Returns exit 1 (failure) for HTTP 503 (fail2ban offline)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
# ⚠️ IMPORTANT: Single-Worker Requirement
# BanGUI must always run as a single worker process:
# - Do NOT pass --workers or --worker-class to uvicorn
# - Do NOT use gunicorn with -w 4 or similar
# - Do NOT override BANGUI_WORKERS to > 1
#
# Why? The session cache is process-local. Multiple workers would cause:
# - Random user logouts (sessions not shared between workers)
# - Duplicate background jobs (each worker runs the scheduler)
# - SQLite lock contention and timeouts
#
# For high availability, use container orchestration (Kubernetes, Docker Swarm)
# to run multiple instances, not multiple workers in a single process.
#
# See Docs/Architekture.md § Deployment Constraints for details.
CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -31,10 +31,10 @@ services:
PUID: 0
PGID: 0
volumes:
- ./fail2ban-dev-config:/config
- ../data/fail2ban-dev-config:/config
- fail2ban-dev-run:/var/run/fail2ban
- /var/log:/var/log:ro
- ./logs:/remotelogs/bangui
- ../data/log:/remotelogs/bangui
healthcheck:
test: ["CMD", "fail2ban-client", "ping"]
interval: 15s
@@ -58,17 +58,22 @@ services:
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: "debug"
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:-dev-secret-do-not-use-in-production}"
BANGUI_ENABLE_DOCS: "true"
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}"
# Secure=false is intentional for local HTTP development.
# In production, Secure=true prevents session cookies over unencrypted HTTP.
BANGUI_SESSION_COOKIE_SECURE: "false"
# BANGUI_WORKERS should not be set (defaults to 1).
# Never set it to > 1; the session cache is process-local.
volumes:
- ../backend/app:/app/app:z
- ../fail2ban-master:/app/fail2ban-master:ro,z
- bangui-dev-data:/data
- ../data:/data
- fail2ban-dev-run:/var/run/fail2ban:ro
- ./fail2ban-dev-config:/config:rw
ports:
- "${BANGUI_BACKEND_PORT:-8000}:8000"
- ../data/fail2ban-dev-config:/config:rw
command:
[
"uvicorn", "app.main:create_app", "--factory",
@@ -76,13 +81,12 @@ services:
"--reload", "--reload-dir", "/app/app"
]
healthcheck:
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/health\", timeout=4)'"]
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/v1/health/live\", timeout=4)'"]
interval: 15s
timeout: 5s
start_period: 45s
retries: 5
networks:
- bangui-dev-net
network_mode: host
# ── Frontend (Vite dev server with HMR) ─────────────────────
frontend:
@@ -92,23 +96,15 @@ services:
working_dir: /app
environment:
NODE_ENV: development
VITE_BACKEND_URL: "http://localhost:8000"
volumes:
- ../frontend:/app:z
- frontend-node-modules:/app/node_modules
ports:
- "${BANGUI_FRONTEND_PORT:-5173}:5173"
command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"]
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:5173/"]
interval: 15s
timeout: 5s
start_period: 30s
retries: 5
networks:
- bangui-dev-net
network_mode: host
volumes:
bangui-dev-data:

View File

@@ -1,109 +0,0 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Production Compose
#
# Compatible with:
# docker compose -f Docker/compose.prod.yml up -d
# podman compose -f Docker/compose.prod.yml up -d
# podman-compose -f Docker/compose.prod.yml up -d
#
# Prerequisites:
# Create a .env file at the project root (or pass --env-file):
# BANGUI_SESSION_SECRET=<random-secret>
# ──────────────────────────────────────────────────────────────
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:
- fail2ban-config:/config
- fail2ban-run:/var/run/fail2ban
- /var/log:/var/log:ro
healthcheck:
test: ["CMD", "fail2ban-client", "ping"]
interval: 30s
timeout: 5s
start_period: 15s
retries: 3
# NOTE: The fail2ban-config volume must be pre-populated with the following files:
# • fail2ban/jail.conf (or jail.d/*.conf) with the DEFAULT section containing:
# banaction = iptables-allports[lockingopt="-w 5"]
# This prevents xtables lock contention errors when multiple jails start in parallel.
# See https://fail2ban.readthedocs.io/en/latest/development/environment.html
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
backend:
build:
context: ..
dockerfile: Docker/Dockerfile.backend
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_LEVEL: "info"
BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}"
BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}"
volumes:
- bangui-data:/data
- fail2ban-run:/var/run/fail2ban:ro
- fail2ban-config:/config:rw
expose:
- "8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
networks:
- bangui-net
# ── Frontend (nginx serving built SPA + API proxy) ──────────
frontend:
build:
context: ..
dockerfile: Docker/Dockerfile.frontend
container_name: bangui-frontend
restart: unless-stopped
ports:
- "${BANGUI_PORT:-8080}:80"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:80/"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3
networks:
- bangui-net
volumes:
bangui-data:
driver: local
fail2ban-config:
driver: local
fail2ban-run:
driver: local
networks:
bangui-net:
driver: bridge

View File

@@ -1,73 +0,0 @@
version: '3.8'
services:
fail2ban:
image: lscr.io/linuxserver/fail2ban:latest
container_name: fail2ban
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: host
environment:
- PUID=1011
- PGID=1001
- TZ=Etc/UTC
- VERBOSITY=-vv #optional
volumes:
- /server/server_fail2ban/config:/config
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
- /var/log:/var/log
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
restart: unless-stopped
backend:
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
container_name: bangui-backend
restart: unless-stopped
depends_on:
fail2ban:
condition: service_started
environment:
- PUID=1011
- PGID=1001
- BANGUI_DATABASE_PATH=/data/bangui.db
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
- BANGUI_LOG_LEVEL=info
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
volumes:
- /server/server_fail2ban/bangui-data:/data
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
- /server/server_fail2ban/config:/config:rw
expose:
- "8000"
networks:
- bangui-net
# ── Frontend (nginx serving built SPA + API proxy) ──────────
frontend:
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
container_name: bangui-frontend
restart: unless-stopped
environment:
- PUID=1011
- PGID=1001
ports:
- "${BANGUI_PORT:-8080}:80"
depends_on:
backend:
condition: service_started
networks:
- bangui-net
networks:
bangui-net:
name: bangui-net

View File

@@ -1,147 +0,0 @@
# BanGUI — Fail2ban Dev Test Environment
This directory contains the fail2ban configuration and supporting scripts for a
self-contained development test environment. A simulation script writes fake
authentication-failure log lines, fail2ban detects them via the `manual-Jail`
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
without a real service.
---
## Prerequisites
- Docker or Podman installed and running.
- `docker compose` (v2) or `podman-compose` available on the `PATH`.
- The repo checked out; all commands run from the **repo root**.
---
## Quick Start
### 1 — Start the fail2ban container
```bash
docker compose -f Docker/compose.debug.yml up -d fail2ban
# or: make up (starts the full dev stack)
```
Wait ~15 s for the health-check to pass (`docker ps` shows `healthy`).
### 2 — Run the login-failure simulation
```bash
bash Docker/simulate_failed_logins.sh
```
Default: writes **5** failure lines for IP `192.168.100.99` to
`Docker/logs/auth.log`.
Optional overrides:
```bash
bash Docker/simulate_failed_logins.sh <COUNT> <SOURCE_IP> <LOG_FILE>
# e.g. bash Docker/simulate_failed_logins.sh 10 203.0.113.42
```
### 3 — Verify the IP was banned
```bash
bash Docker/check_ban_status.sh
```
The output shows the current jail counters and the list of banned IPs with their
ban expiry timestamps.
### 4 — Unban and re-test
```bash
bash Docker/check_ban_status.sh --unban 192.168.100.99
```
### One-command smoke test (Makefile shortcut)
```bash
make dev-ban-test
```
Chains steps 13 automatically with appropriate sleep intervals.
---
## Configuration Reference
| File | Purpose |
|------|---------|
| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines |
| `fail2ban/jail.d/manual-Jail.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
(see `fail2ban/paths-lsio.conf``remote_logs_path = /remotelogs`).
BanGUI also extends fail2ban history retention for archive backfill. In
the development config `fail2ban/fail2ban.conf` the database purge age is
set to `648000` seconds (7.5 days) so the first archive sync can recover a
full 7-day window before fail2ban purges old rows.
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
```ini
maxretry = 3 # failures before a ban
findtime = 120 # look-back window in seconds
bantime = 60 # ban duration in seconds
```
---
## Troubleshooting
### Log file not detected
The jail uses `backend = polling` for reliability inside Docker containers.
If fail2ban still does not pick up new lines, verify the volume mount in
`Docker/compose.debug.yml`:
```yaml
- ./logs:/remotelogs/bangui
```
and confirm `Docker/logs/auth.log` exists after running the simulation script.
### Filter regex mismatch
Test the regex manually:
```bash
docker exec bangui-fail2ban-dev \
fail2ban-regex /remotelogs/bangui/auth.log manual-Jail
```
The output should show matched lines. If nothing matches, check that the log
lines match the corresponding `failregex` pattern:
```
# manual-Jail (auth log):
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
```
### iptables / permission errors
The fail2ban container requires `NET_ADMIN` and `NET_RAW` capabilities and
`network_mode: host`. Both are already set in `Docker/compose.debug.yml`. If
you see iptables errors, check that the host kernel has iptables loaded:
```bash
sudo modprobe ip_tables
```
### IP not banned despite enough failures
Check whether the source IP falls inside the `ignoreip` range defined in
`fail2ban/jail.d/manual-Jail.conf`:
```ini
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
```
The default simulation IP `192.168.100.99` is outside these ranges and will be
banned normally.

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,15 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
# ── Security headers ─────────────────────────────────────
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Uncomment when HTTPS is fully configured:
# add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# ── API reverse proxy → backend container ─────────────────
location /api/ {
proxy_pass http://backend:8000;

View File

@@ -11,7 +11,7 @@
# Defaults:
# COUNT : 5
# SOURCE_IP: 192.168.100.99
# LOG_FILE : Docker/logs/auth.log (relative to repo root)
# LOG_FILE : data/log/auth.log (relative to repo root)
#
# Log line format (must match manual-Jail failregex exactly):
# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
@@ -25,7 +25,7 @@ readonly DEFAULT_IP="192.168.100.99"
# Resolve script location so defaults work regardless of cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DEFAULT_LOG_FILE="${SCRIPT_DIR}/logs/auth.log"
readonly DEFAULT_LOG_FILE="${SCRIPT_DIR}/../data/log/auth.log"
# ── Arguments ─────────────────────────────────────────────────
COUNT="${1:-${DEFAULT_COUNT}}"

1338
Docs/API-Reference.md Normal file

File diff suppressed because it is too large Load Diff

730
Docs/API_STATUS_CODES.md Normal file
View File

@@ -0,0 +1,730 @@
# API Status Codes Reference
Complete reference of all HTTP status codes returned by the BanGUI API v1.
Use this document to handle every possible response from every endpoint.
---
## Status Code Taxonomy
| Code | Meaning | When Used |
|------|---------|-----------|
| **200** | OK | Successful GET, PUT, POST (no creation) |
| **201** | Created | Successful POST that created a resource |
| **204** | No Content | Successful DELETE or PUT with no response body |
| **400** | Bad Request | Invalid input, validation failure, bad IP, URL validation |
| **401** | Unauthorized | Missing, expired, or invalid session |
| **404** | Not Found | Entity does not exist |
| **409** | Conflict | State conflict (already exists, already done, operation failed) |
| **422** | Unprocessable Entity | Request body validation failed (Pydantic) |
| **429** | Too Many Requests | Rate limit exceeded |
| **500** | Internal Server Error | Unexpected server failure |
| **502** | Bad Gateway | fail2ban socket unreachable |
| **503** | Service Unavailable | Setup incomplete or component degraded |
---
## /api/v1/auth
### POST /api/v1/auth/login
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Login successful | `LoginResponse` |
| 401 | Invalid password | Error body |
| 422 | Validation error — invalid request body | Error body |
| 429 | Too many login attempts, retry after delay | Error body |
| 503 | Setup not complete | Error body |
### GET /api/v1/auth/session
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Session valid | `SessionValidResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/auth/logout
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Logout successful | `LogoutResponse` |
| 401 | Session missing or invalid (silently successful) | Error body |
---
## /api/v1/setup
### GET /api/v1/setup
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Setup status returned | `SetupStatusResponse` |
### POST /api/v1/setup
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Setup completed successfully | `SetupResponse` |
| 400 | Validation error in request body | Error body |
| 409 | Setup already completed | Error body |
### GET /api/v1/setup/timezone
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Timezone returned | `SetupTimezoneResponse` |
---
## /api/v1/health
### GET /api/v1/health
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | All components healthy | `HealthResponse` |
| 503 | fail2ban offline or component degraded | `HealthResponse` |
---
## /api/v1/dashboard
### GET /api/v1/dashboard/status
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Server status returned | `ServerStatusResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban list returned | `DashboardBanListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans/by-country
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban counts by country returned | `BansByCountryResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans/trend
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban trend data returned | `BanTrendResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/dashboard/bans/by-jail
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ban counts by jail returned | `BansByJailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/bans
### GET /api/v1/bans/active
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Active ban list returned | `ActiveBanListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/bans
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | IP banned successfully | `JailCommandResponse` |
| 400 | Invalid IP address | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | Ban command failed in fail2ban | Error body |
| 429 | Rate limit exceeded for ban operations | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/bans
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP unbanned successfully | `JailCommandResponse` |
| 400 | Invalid IP address | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | Unban command failed in fail2ban | Error body |
| 429 | Rate limit exceeded for unban operations | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/bans/all
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | All bans cleared | `UnbanAllResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/jails
### GET /api/v1/jails
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jails list returned | `JailListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail detail returned | `JailDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/reload-all
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | All jails reloaded | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/start
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail started | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/stop
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail stopped | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/idle
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Idle mode toggled | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/reload
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail reloaded | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/jails/{name}/ignoreip
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Ignore list returned | `IgnoreListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/ignoreip
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | IP added to ignore list | `JailCommandResponse` |
| 400 | IP or network invalid | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/jails/{name}/ignoreip
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP removed from ignore list | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/jails/{name}/ignoreself
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | ignoreself toggled | `JailCommandResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 409 | fail2ban reports operation failed | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/jails/{name}/banned
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Banned IPs returned | `JailBannedIpsResponse` |
| 400 | page or page_size out of range | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/history
### GET /api/v1/history
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | History list returned | `HistoryListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/history/archive
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Archived history list returned | `HistoryListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/history/{ip}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP history detail returned | `IpDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | No history found for this IP | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/geo
### GET /api/v1/geo/lookup/{ip}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | IP lookup result returned | `IpLookupResponse` |
| 400 | Invalid IP address | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/geo/stats
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Geo cache stats returned | `GeoCacheStatsResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/geo/re-resolve
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Re-resolve result | `GeoReResolveResponse` |
| 401 | Session missing, expired, or invalid | Error body |
---
## /api/v1/server
### GET /api/v1/server/settings
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Server settings returned | `ServerSettingsResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/server/settings
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Settings updated successfully | No body |
| 400 | Set command rejected by fail2ban | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/server/flush-logs
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Logs flushed successfully | `FlushLogsResponse` |
| 400 | Command rejected by fail2ban | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/config
### GET /api/v1/config/global
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Global config returned | `GlobalConfigResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/global
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Global config updated successfully | No body |
| 400 | Set command rejected or log_target invalid | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 429 | Rate limit exceeded for config update operations | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/config/reload
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Fail2ban reloaded successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Reload command failed in fail2ban | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/config/restart
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Fail2ban restarted successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Stop command failed in fail2ban | Error body |
| 502 | fail2ban unreachable for stop command | Error body |
| 503 | fail2ban did not come back online within 10s | Error body |
### POST /api/v1/config/regex-test
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Regex test result | `RegexTestResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 422 | Invalid regex pattern | Error body |
### POST /api/v1/config/preview-log
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Log preview result | `LogPreviewResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 422 | Invalid regex pattern | Error body |
### GET /api/v1/config/map-color-thresholds
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Color thresholds returned | `MapColorThresholdsResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### PUT /api/v1/config/map-color-thresholds
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Color thresholds updated | `MapColorThresholdsResponse` |
| 400 | Validation error (thresholds not properly ordered) | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 429 | Rate limit exceeded for config update operations | Error body |
### GET /api/v1/config/fail2ban-log
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Log file lines returned | `Fail2BanLogResponse` |
| 400 | Log target not a file or path outside allowed directory | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/service-status
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Service status returned | `ServiceStatusResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
---
## /api/v1/config/jails (jail_config router)
### GET /api/v1/config/jails
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jails config list returned | `JailConfigListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail config detail returned | `JailConfigDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Jail config updated | `JailConfigDetailResponse` |
| 400 | Invalid value for a property | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 422 | Validation error | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
| 502 | fail2ban unreachable | Error body |
### POST /api/v1/config/jails/{name}/commit
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Changes committed successfully | `JailConfigDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 409 | Commit failed (fail2ban rejected the new config) | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/jails/{name}/rollback
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Rollback successful | `JailConfigDetailResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 502 | fail2ban unreachable | Error body |
### DELETE /api/v1/config/jails/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Jail deleted successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
| 409 | Jail is a shipped default (conf-only) | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
### POST /api/v1/config/jails
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Jail created | `JailConfigDetailResponse` |
| 400 | Invalid jail name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Jail already exists | Error body |
| 429 | Rate limit exceeded for jail config operations | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/jails/{name}/files
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Config files returned | `ConfigFileListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Jail not found in config | Error body |
---
## /api/v1/config/filters (filter_config router)
### GET /api/v1/config/filters
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Filter list returned | `FilterListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/filters/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Filter config returned | `FilterConfig` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Filter not found in filter.d/ | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/filters/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Filter updated | `FilterConfig` |
| 400 | Invalid filter name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Filter not found | Error body |
| 422 | Regex pattern failed to compile | Error body |
| 429 | Rate limit exceeded for filter update operations | Error body |
| 500 | Failed to write .local file | Error body |
### POST /api/v1/config/filters
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Filter created | `FilterConfig` |
| 400 | Invalid filter name or regex too long | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Filter already exists | Error body |
| 422 | Regex pattern failed to compile | Error body |
| 429 | Rate limit exceeded for filter create operations | Error body |
| 500 | Failed to write .local file | Error body |
### DELETE /api/v1/config/filters/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Filter deleted successfully | No body |
| 400 | Invalid filter name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Filter not found | Error body |
| 409 | Filter is a shipped default (conf-only) | Error body |
| 429 | Rate limit exceeded for filter delete operations | Error body |
| 500 | Failed to delete .local file | Error body |
---
## /api/v1/config/actions (action_config router)
### GET /api/v1/config/actions
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Action list returned | `ActionListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 502 | fail2ban unreachable | Error body |
### GET /api/v1/config/actions/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Action config returned | `ActionConfig` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Action not found in action.d/ | Error body |
| 502 | fail2ban unreachable | Error body |
### PUT /api/v1/config/actions/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Action updated | `ActionConfig` |
| 400 | Invalid action name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Action not found | Error body |
| 429 | Rate limit exceeded for action update operations | Error body |
| 500 | Failed to write .local file | Error body |
### POST /api/v1/config/actions
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Action created | `ActionConfig` |
| 400 | Invalid action name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 409 | Action already exists | Error body |
| 429 | Rate limit exceeded for action create operations | Error body |
| 500 | Failed to write .local file | Error body |
### DELETE /api/v1/config/actions/{name}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Action deleted successfully | No body |
| 400 | Invalid action name | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Action not found | Error body |
| 409 | Action is a shipped default (conf-only) | Error body |
| 429 | Rate limit exceeded for action delete operations | Error body |
| 500 | Failed to delete .local file | Error body |
---
## /api/v1/blocklists
### GET /api/v1/blocklists
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist sources returned | `BlocklistListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/blocklists
| Status | Description | Response Model |
|--------|-------------|----------------|
| 201 | Blocklist source created | `BlocklistSource` |
| 400 | URL validation failed | Error body |
| 401 | Session missing, expired, or invalid | Error body |
### POST /api/v1/blocklists/import
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Import completed | `ImportRunResult` |
| 401 | Session missing, expired, or invalid | Error body |
| 429 | Rate limit exceeded for blocklist import | Error body |
### GET /api/v1/blocklists/schedule
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Schedule info returned | `ScheduleInfo` |
| 401 | Session missing, expired, or invalid | Error body |
### PUT /api/v1/blocklists/schedule
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Schedule updated | `ScheduleInfo` |
| 401 | Session missing, expired, or invalid | Error body |
### GET /api/v1/blocklists/log
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Import log returned | `ImportLogListResponse` |
| 401 | Session missing, expired, or invalid | Error body |
### GET /api/v1/blocklists/{source_id}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist source returned | `BlocklistSource` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
### PUT /api/v1/blocklists/{source_id}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist source updated | `BlocklistSource` |
| 400 | URL validation failed | Error body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
### DELETE /api/v1/blocklists/{source_id}
| Status | Description | Response Model |
|--------|-------------|----------------|
| 204 | Blocklist source deleted successfully | No body |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
### GET /api/v1/blocklists/{source_id}/preview
| Status | Description | Response Model |
|--------|-------------|----------------|
| 200 | Blocklist preview returned | `PreviewResponse` |
| 401 | Session missing, expired, or invalid | Error body |
| 404 | Blocklist source not found | Error body |
| 502 | URL could not be reached | Error body |
---
## Error Response Format
All error responses follow this structure:
```json
{
"code": "error_code_string",
"detail": "Human-readable error message",
"metadata": {
"key": "value"
}
}
```
### Common error_code values
| code | Meaning |
|------|---------|
| `not_found` | Requested entity does not exist |
| `invalid_input` | Validation failure or bad parameters |
| `conflict` | State conflict (already exists, already done) |
| `authentication_required` | Session missing or invalid |
| `rate_limit_exceeded` | Rate limit hit — check `retry_after_seconds` in metadata |
| `fail2ban_unreachable` | fail2ban socket cannot be reached |
| `config_validation_failed` | Config value rejected |
| `config_file_not_found` | Config file does not exist |
| `jail_not_found` | Jail does not exist |
| `filter_not_found` | Filter does not exist |
| `action_not_found` | Action does not exist |
| `blocklist_source_not_found` | Blocklist source does not exist |
| `setup_already_complete` | Setup has already been run |
---
## Status Code Decision Guide
**Frontend gets 400 — what's wrong?**
- Has `code: "invalid_input"` → validation failure, check `detail`
- Has `code: "jail_not_found"` → jail doesn't exist
- Has `code: "config_validation_failed"` → config value rejected
**Frontend gets 502 — what's wrong?**
- fail2ban is down or socket path wrong
- Check `code: "fail2ban_unreachable"`
**Frontend gets 503 — what's wrong?**
- Setup not complete (`code: "setup_already_complete"`)
- Health check: fail2ban offline or component degraded
**Frontend gets 409 — what's wrong?**
- Already done: jail already active/inactive, setup already complete
- Operation failed: fail2ban rejected the command
- Conflict: resource already exists
**Frontend gets 429 — what's wrong?**
- Rate limit exceeded
- `metadata.retry_after_seconds` tells you how long to wait

166
Docs/API_VERSIONING.md Normal file
View File

@@ -0,0 +1,166 @@
# API Versioning Strategy
**Status:** Active — Current version: **v1**
All BanGUI API endpoints are versioned using URI path versioning (e.g., `/api/v1/`).
This document explains when and how to version endpoints, how deprecation works, and what guarantees consumers can rely on.
---
## 1. Version Lifecycle
| Stage | Meaning |
|-------|---------|
| **Current** | Active, receiving new features and bug fixes. |
| **Deprecated** | Still functional but marked for removal. Clients receive `Deprecation: true` and `Sunset: <date>` response headers. |
| **Removed** | Endpoint no longer exists. Clients must migrate to a newer version. |
---
## 2. URL Structure
```
/api/v{major}/<resource>/<path>
```
- **v1** — current version (released 2026-05-02)
- **v2** — reserved; skeleton router deployed at `/api/v2/jails` but **not yet active** for production traffic
- **PATCH** versions (v1.1, v1.2) are **not** used; only **major** version bumps indicate breaking changes
- The OpenAPI schema is always available at `/api/openapi.json` regardless of version
---
## 3. What Triggers a Version Bump
A new major version is required when a **breaking change** must be introduced, including:
- Removing or renaming a field in a response model
- Changing the type of a request or response field
- Removing an endpoint entirely
- Changing authentication/authorization semantics
- Modifying the semantics of an existing operation
**Non-breaking changes** (backward-compatible):
- Adding new optional request fields
- Adding new response fields
- Adding new endpoints
- Fixing bugs that caused incorrect behavior
These do **not** require a version bump.
---
## 4. Deprecation Policy
When an endpoint is deprecated:
1. The endpoint **remains functional** for a minimum of **6 months** from the `Sunset` date
2. Response headers are added to every 2xx response:
```
Deprecation: true
Sunset: <RFC-5322 date>
Link: <https://bangui.example.com/api/v2/...>; rel="successor-version"
```
3. The endpoint is registered in the deprecation middleware (``app/middleware/deprecation.py``)
4. The OpenAPI schema marks the endpoint with `deprecated: true`
5. Documentation is updated to show the endpoint as deprecated
### Implementing Deprecation Headers
The ``DeprecationHeaderMiddleware`` (``app/middleware/deprecation.py``) automatically injects
the correct headers for any registered deprecated endpoint. To schedule an endpoint for removal:
```python
from datetime import datetime, timezone, timedelta
from app.middleware.deprecation import register_deprecated_endpoint
# Example: deprecate /api/v1/jails on 2026-11-03 (6 months from v2 release)
register_deprecated_endpoint(
path_prefix="/api/v1/jails",
sunset_date=datetime(2026, 11, 3, tzinfo=timezone.utc),
successor_url="/api/v2/jails",
)
```
The middleware runs on every response; if the request path matches a registered deprecated prefix,
the appropriate headers are appended before the response is returned.
---
## 5. Backend Development: Adding Versioned Endpoints
### New endpoints
All new endpoints are added to the **current** version (`/api/v1/`). Prefix your router:
```python
router = APIRouter(prefix="/api/v1/my-resource", tags=["My Resource"])
```
### Breaking changes requiring v2
1. Create a new router file (e.g., `routers/my_resource_v2.py`) with the v2 prefix:
```python
router = APIRouter(prefix="/api/v2/my-resource", tags=["My Resource (v2)"])
```
2. Copy or adapt the v1 handler logic as needed. Extract shared business logic into
a **service layer function** so both routers call the same underlying code.
3. Register the new router in `app/main.py`:
```python
app.include_router(my_resource_v2.router)
```
4. Register the v1 endpoint for deprecation headers (see §4 above)
5. Update this document to reflect the new version lifecycle
### Keeping routers DRY
Routers should only contain HTTP concerns (parameters, responses, status codes). Business logic
belongs in the service layer. Both v1 and v2 handlers can call the same service function.
---
## 6. Frontend Development
The frontend always uses the current version's base URL:
```typescript
const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1";
```
All endpoint paths in `frontend/src/api/endpoints.ts` are defined as relative paths (e.g., `/bans`, `/jails`) and are appended to `BASE_URL` at runtime.
When v2 is released, update ``VITE_API_URL`` in the environment configuration to point to `/api/v2`.
---
## 7. OpenAPI / Documentation
- Swagger UI: `/api/docs`
- ReDoc: `/api/redoc`
- OpenAPI schema: `/api/openapi.json`
- Docs are **not** versioned; they always reflect the **current** (latest) API version
---
## 8. CI Breaking-Change Checks
A GitHub Actions job runs on every pull request to detect breaking OpenAPI changes:
- ``openapi-breaking-changes`` job (PR only): generates the current OpenAPI spec and
compares it against the baseline committed on the last push to `main`. If any breaking
changes are found, the job fails and the PR cannot be merged.
- ``openapi-baseline-commit`` job (main push only): generates and commits the current
OpenAPI spec as the new baseline for future PR comparisons.
To trigger the baseline update, push to main after merging a version bump or any change
that legitimately alters the OpenAPI surface.
---
## 9. Version History
| Version | Status | Released | Sunset Date | Notes |
|---------|--------|---------|-------------|-------|
| v1 | **Current** | 2026-05-02 | — | Initial versioning; all endpoints moved from `/api/` to `/api/v1/` |
| v2 | **Reserved — skeleton active, endpoints not yet available** | — | — | Router skeleton at `app/routers/jails_v2.py`; real endpoints will be added before activation |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

194
Docs/CONFIGURATION.md Normal file
View File

@@ -0,0 +1,194 @@
# Configuration Reference
All runtime settings are environment variables prefixed with `BANGUI_`. Values are validated at startup — missing required fields or invalid values cause the application to refuse to start.
For setup instructions, see [Instructions.md](./Instructions.md). For deployment, see [Deployment.md](./Deployment.md).
---
## Database
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_DATABASE_PATH` | string | `bangui.db` | Filesystem path to the SQLite application database. Parent directory must exist and be writable at startup. |
---
## Session & Security
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_SESSION_SECRET` | string | **(required)** | Secret key for signing session tokens. Must be ≥ 32 characters. Generate with `python -c "import secrets; print(secrets.token_hex(32))"`. Never reuse across environments. |
| `BANGUI_SESSION_SECRET_PREVIOUS` | string | `null` | Previous session secret used during rotation. Set to the old secret while rotating; unset once all old tokens expire. |
| `BANGUI_SESSION_DURATION_MINUTES` | int | `60` | Session lifetime in minutes. Must be ≥ 1. |
| `BANGUI_SESSION_CACHE_ENABLED` | bool | `false` | Enable in-memory session validation cache. Disable in multi-worker deployments to avoid stale revoked sessions. |
| `BANGUI_SESSION_CACHE_TTL_SECONDS` | float | `10.0` | TTL for cached session entries. Ignored when `BANGUI_SESSION_CACHE_ENABLED` is `false`. Must be ≥ 0. |
| `BANGUI_SESSION_COOKIE_HTTPONLY` | bool | `true` | Mark the session cookie as `HttpOnly` (JavaScript cannot access it). |
| `BANGUI_SESSION_COOKIE_SAMESITE` | string | `lax` | SameSite policy for the session cookie. Valid values: `lax`, `strict`, `none`. |
| `BANGUI_SESSION_COOKIE_SECURE` | bool | `true` | Set the `Secure` flag on the session cookie. `true` required for HTTPS. Set to `false` only for local HTTP development. |
---
## fail2ban Integration
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_FAIL2BAN_SOCKET` | string | `/var/run/fail2ban/fail2ban.sock` | Path to the fail2ban Unix domain socket. Socket must exist and be readable at startup (warning issued if not). |
| `BANGUI_FAIL2BAN_CONFIG_DIR` | string | `/config/fail2ban` | Path to the fail2ban configuration directory. Must contain `jail.d/`, `filter.d/`, and `action.d/`. |
| `BANGUI_FAIL2BAN_START_COMMAND` | string | `fail2ban-client start` | Shell command to start the fail2ban daemon (no shell interpretation). Used during recovery rollback. Must be parseable by `shlex.split`. |
| `BANGUI_ALLOWED_LOG_DIRS` | list | `/var/log,/config/log` | Allowed directory prefixes for jail log paths. Any log path must resolve within one of these directories. |
| `BANGUI_TRUSTED_PROXIES` | list | `[]` | Trusted reverse proxy IP addresses or CIDR ranges (e.g., `192.168.1.1,10.0.0.0/8`). Only these sources can set `X-Forwarded-For` and `X-Real-IP`. |
---
## HTTP Client
These settings control outbound HTTP requests made by the backend (geolocation fallback, blocklist downloads).
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_HTTP_REQUEST_TIMEOUT_SECONDS` | float | `20.0` | Maximum total time for an outbound HTTP request. Must be ≥ 0. |
| `BANGUI_HTTP_CONNECT_TIMEOUT_SECONDS` | float | `5.0` | Maximum time to establish a TCP connection. Must be ≥ 0. |
| `BANGUI_HTTP_MAX_CONNECTIONS` | int | `10` | Maximum concurrent outbound HTTP connections. Must be ≥ 1. |
| `BANGUI_HTTP_KEEPALIVE_TIMEOUT_SECONDS` | float | `15.0` | How long idle keepalive connections are retained. Must be ≥ 0. |
---
## Geolocation
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_GEOIP_DB_PATH` | string | `null` | Path to a MaxMind GeoLite2-Country `.mmdb` file. Primary resolver for IP geolocation when set. Download from https://dev.maxmind.com/geoip/geolite2-country. |
| `BANGUI_GEOIP_ALLOW_HTTP_FALLBACK` | bool | `false` | Allow HTTP fallback to `ip-api.com` when the MMDB is unavailable. **Warning**: sends IP addresses unencrypted. Only enable when MMDB cannot be mounted. |
---
## Cross-Origin (CORS)
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_CORS_ALLOWED_ORIGINS` | list | `http://localhost:5173,http://127.0.0.1:5173,https://localhost:5173,https://127.0.0.1:5173` | Allowed CORS origins. Comma-separated string or YAML list. Empty list disables CORS. **Never use `"*"` in production** when credentials are enabled. |
---
## Display
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_TIMEZONE` | string | `UTC` | IANA timezone name used when displaying timestamps in the UI (e.g., `America/New_York`, `Europe/London`). |
---
## External Logging
Enable with `BANGUI_EXTERNAL_LOGGING_ENABLED=true`, then set the provider and provider-specific variables.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_EXTERNAL_LOGGING_ENABLED` | bool | `false` | Send logs to a centralized logging platform instead of stdout only. |
| `BANGUI_EXTERNAL_LOGGING_PROVIDER` | string | `null` | Logging provider: `datadog`, `papertrail`, or `elasticsearch`. Required when external logging is enabled. |
| `BANGUI_EXTERNAL_LOGGING_BUFFER_SIZE` | int | `1000` | Max log records buffered in memory before dropping oldest. Must be ≥ 10. |
| `BANGUI_EXTERNAL_LOGGING_FLUSH_INTERVAL_SECONDS` | float | `5.0` | Max seconds before flushing a log batch. Must be > 0. |
### Datadog
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_DATADOG_API_KEY` | string | `null` | Datadog API key. Required when provider is `datadog`. |
| `BANGUI_DATADOG_SITE` | string | `datadoghq.com` | Datadog site: `datadoghq.com` (US) or `datadoghq.eu` (EU). |
| `BANGUI_DATADOG_BATCH_SIZE` | int | `10` | Number of log records per batch. Must be ≥ 1. |
### Papertrail
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_PAPERTRAIL_HOST` | string | `null` | Papertrail host address (e.g., `logs1.papertrailapp.com`). Required when provider is `papertrail`. |
| `BANGUI_PAPERTRAIL_PORT` | int | `null` | Papertrail port. Required when provider is `papertrail`. Range: 165535. |
| `BANGUI_PAPERTRAIL_PROGRAM_NAME` | string | `bangui` | Program name in Syslog messages sent to Papertrail. |
### Elasticsearch
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_ELASTICSEARCH_HOSTS` | list | `[]` | Elasticsearch host URLs (e.g., `http://elasticsearch:9200`). Required when provider is `elasticsearch`. |
| `BANGUI_ELASTICSEARCH_INDEX_PREFIX` | string | `bangui` | Prefix for Elasticsearch indices. |
| `BANGUI_ELASTICSEARCH_BATCH_SIZE` | int | `10` | Number of log documents per batch. Must be ≥ 1. |
---
## Rate Limiting
Per-IP rate limits applied to API endpoints.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_RATE_LIMIT_BANS_PER_MINUTE` | int | `100` | Max ban/unban requests per IP per minute. |
| `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `100` | Max blocklist import requests per IP per hour. |
| `BANGUI_RATE_LIMIT_CONFIG_UPDATE_PER_MINUTE` | int | `50` | Max config update requests per IP per minute. |
**Rate limit reset mechanism:** Each limit is applied per-client IP. To bypass the blocklist import rate limit in automated tests (E2E-4), send a unique `X-Forwarded-For` header with each import request — e.g., `X-Forwarded-For: 10.0.0.99`. The header is only honoured when the client IP falls within `BANGUI_TRUSTED_PROXIES`; otherwise the real client IP is used.
---
## Pagination & Display Limits
Configurable limits that affect API response sizes and data retention.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_MAX_PAGE_SIZE` | int | `500` | Maximum records returned per paginated API response. Individual endpoints may further limit this. Must be 110000. |
| `BANGUI_PREVIEW_MAX_LINES` | int | `100` | Maximum IP lines returned in a blocklist source preview. Must be ≥ 1. |
| `BANGUI_HISTORY_RETENTION_DAYS` | int | `90` | Number of days historical ban records are retained before archival cleanup. Must be ≥ 1. |
---
## Observability
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `BANGUI_LOG_LEVEL` | string | `info` | Application log level. Valid values: `debug`, `info`, `warning`, `error`, `critical`. |
| `BANGUI_ENABLE_DOCS` | bool | `false` | Enable FastAPI interactive docs at `/api/docs` (Swagger UI) and `/api/redoc` (ReDoc). Enable only in development. |
---
## Quick Reference
```bash
# Generate a session secret
python -c "import secrets; print(secrets.token_hex(32))"
# Minimal production .env
BANGUI_SESSION_SECRET=<your-32-plus-char-secret>
BANGUI_CORS_ALLOWED_ORIGINS=https://your-frontend.example.com
BANGUI_TIMEZONE=America/New_York
```
---
## `manual-Jail` Fail2ban Jail (E2E Test Dependency)
The E2E test **E2E-3** (`e2e/tests/02_ban_records.robot`) writes authentication-failure lines via `Docker/simulate_failed_logins.sh` and asserts that the resulting ban appears in the BanGUI UI. The test depends on the following `manual-Jail` configuration in `Docker/fail2ban-dev-config/fail2ban/jail.d/manual-Jail.conf`:
| Parameter | Value | Relevance to E2E-3 |
|-----------|-------|---------------------|
| `maxretry` | `3` | Ban triggers after 3 matching lines. `simulate_failed_logins.sh` writes 5 lines by default — enough to trigger the ban reliably. |
| `findtime` | `120` | Time window in seconds during which `maxretry` failures accumulate. |
| `bantime` | `60` | Ban duration in seconds. Teardown unbans via `check_ban_status.sh --unban` regardless of bantime. |
| `logpath` | `/remotelogs/bangui/auth.log` | fail2ban reads this path inside the container. `simulate_failed_logins.sh` writes to `Docker/logs/auth.log`, which must be volume-mapped to `/remotelogs/bangui/auth.log`. |
| `backend` | `polling` | fail2ban re-reads the log file on its own interval (not event-driven). A 15 s sleep in the E2E test gives fail2ban time to detect the ban. |
| `ignoreip` | `127.0.0.0/8 ::1 172.16.0.0/12` | Test IP `192.168.100.99` is not ignored. Ensure local overrides do not add this IP to `ignoreip`. |
**Log path mapping (Docker/Podman compose):** The host file `Docker/logs/auth.log` must be mounted to `/remotelogs/bangui/auth.log` inside the `bangui-fail2ban-dev` container. If the volume mapping is changed, `simulate_failed_logins.sh` will write to a path fail2ban does not watch, and the test will fail at Step 2 with no ban recorded.
**Test IP:** `192.168.100.99` (non-routable link-local test subnet, RFC 3927). Safe to use because it is outside all `ignoreip` ranges and unlikely to appear in real traffic.
**Scheduling note:** The backend does not receive push notifications from fail2ban. `GET /api/bans/active` queries the fail2ban Unix socket directly (on-demand). The history archive is populated by `history_sync`, a periodic job running every 300 s (`HISTORY_SYNC_INTERVAL` in `backend/app/tasks/history_sync.py`). The E2E test uses `GET /api/bans/active` for the API assertion (avoids the archive lag) and the History page with `?page_size=500` for the UI assertion.
---
## Cross-References
- [Deployment.md](./Deployment.md) — Docker configuration, health checks, graceful shutdown
- [Security.md](./Security.md) — Security recommendations and hardening
- [Observability.md](./Observability.md) — Logging, metrics, and monitoring
- [Backend-Development.md](./Backend-Development.md) — Backend coding conventions

347
Docs/DATABASE_SCHEMA.md Normal file
View File

@@ -0,0 +1,347 @@
# Database Schema Documentation
BanGUI uses two SQLite databases:
| Database | Purpose | Location |
|---|---|---|
| **BanGUI app DB** | Own configuration, sessions, blocklist sources, import logs, geo cache | `bangui.db` |
| **fail2ban DB** | fail2ban's internal ban/jail data (read-only) | Configured via `FAIL2BAN_DB` env var |
---
## 1. BanGUI Application Schema
Single source of truth: `backend/app/db.py`.
### 1.1 `settings`
Key-value store for application configuration.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `key` | TEXT | NOT NULL UNIQUE |
| `value` | TEXT | NOT NULL |
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
| `updated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Indexes:** PK only.
**Purpose:** Stores app-wide settings (e.g., timezone, UI preferences). All settings access goes through `settings_repo` / `settings_service`.
---
### 1.2 `sessions`
Session tokens for web authentication.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `token_hash` | TEXT | NOT NULL UNIQUE |
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
| `expires_at` | TEXT | NOT NULL |
**Indexes:** `idx_sessions_token_hash` (UNIQUE) on `token_hash`.
**Purpose:** Web session management. Tokens are SHA-256 hashed before storage. Sessions expire and are cleaned up by `session_cleanup` task. See `auth_service.py`.
---
### 1.3 `blocklist_sources`
Blocklist source definitions for the import pipeline.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `name` | TEXT | NOT NULL |
| `url` | TEXT | NOT NULL UNIQUE |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 (boolean) |
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
| `updated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Indexes:** PK only.
**Purpose:** Defines sources for blocklist imports. See `blocklist_repo`, `blocklist_service`, `blocklist_import_workflow`.
---
### 1.4 `import_log`
Audit log of individual blocklist import operations.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `source_id` | INTEGER | REFERENCES `blocklist_sources(id)` ON DELETE RESTRICT |
| `source_url` | TEXT | NOT NULL |
| `timestamp` | INTEGER | NOT NULL (UNIX epoch) |
| `ips_imported` | INTEGER | NOT NULL DEFAULT 0 |
| `ips_skipped` | INTEGER | NOT NULL DEFAULT 0 |
| `errors` | TEXT | |
**Indexes:**
- `idx_import_log_id_desc` on `(id DESC)` — cursor pagination
- `idx_import_log_source_id_desc` on `(source_id, id DESC)` — filtered pagination
**Purpose:** Audit trail for imports. `source_id` RESTRICT prevents source deletion when logs exist. See migration 9.
**Migration 8:** `timestamp` migrated from TEXT ISO 8601 to INTEGER UNIX epoch.
---
### 1.5 `geo_cache`
Geo-IP lookup cache for ban IP metadata.
| Column | Type | Constraints |
|---|---|---|
| `ip` | TEXT | PRIMARY KEY |
| `country_code` | TEXT | |
| `country_name` | TEXT | |
| `asn` | TEXT | |
| `org` | TEXT | |
| `cached_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Additional (migration 3):**
| Column | Type | Constraints |
|---|---|---|
| `last_seen` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Indexes:** PK only.
**Purpose:** Caches GeoIP results to reduce third-party API calls. TTL managed by `geo_cache_cleanup` task. See `geo_cache_repo`, `geo_service`.
---
### 1.6 `history_archive`
Archived ban/unban history mirrored from fail2ban DB.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `jail` | TEXT | NOT NULL |
| `ip` | TEXT | NOT NULL |
| `timeofban` | INTEGER | NOT NULL (UNIX epoch) |
| `bancount` | INTEGER | NOT NULL |
| `data` | TEXT | NOT NULL (JSON) |
| `action` | TEXT | NOT NULL CHECK IN ('ban', 'unban') |
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Constraints:** `UNIQUE(ip, jail, action, timeofban)` prevents duplicate archive rows.
**Indexes:**
- `idx_history_archive_jail_timeofban` on `(jail, timeofban DESC)` — dashboard filter by jail + time ordering
- `idx_history_archive_timeofban_jail_action` on `(timeofban DESC, jail, action)` — timeline filters
- `idx_history_archive_ip` on `(ip)` — IP prefix/exact searches
- `idx_history_archive_action` on `(action)` — ban/unban filtering
**Purpose:** Long-term ban history. Synced from fail2ban DB by `history_sync` task. See `history_archive_repo`, `history_service`.
---
### 1.7 `scheduler_lock`
Database-backed mutex for multi-worker scheduler safety.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY CHECK (id = 1) — singleton row |
| `pid` | INTEGER | NOT NULL |
| `hostname` | TEXT | NOT NULL |
| `created_at` | REAL | NOT NULL (UNIX epoch) |
| `heartbeat_at` | REAL | NOT NULL (UNIX epoch) |
**Indexes:** PK only (singleton constraint).
**Purpose:** Only one worker process holds the scheduler lock at a time. Lock is heartbeat-renewed by `scheduler_lock_heartbeat` task. Uses `BEGIN IMMEDIATE` transaction to acquire atomically. See `scheduler_lock.py`.
---
### 1.8 `import_runs`
Tracks unique blocklist imports for idempotent retries.
| Column | Type | Constraints |
|---|---|---|
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `source_id` | INTEGER | NOT NULL REFERENCES `blocklist_sources(id)` ON DELETE CASCADE |
| `content_hash` | TEXT | NOT NULL |
| `status` | TEXT | NOT NULL CHECK IN ('pending', 'completed', 'failed') |
| `imported_count` | INTEGER | NOT NULL DEFAULT 0 |
| `skipped_count` | INTEGER | NOT NULL DEFAULT 0 |
| `error_message` | TEXT | |
| `created_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
| `updated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Constraints:** `UNIQUE(source_id, content_hash)` — same source + content = same import run.
**Indexes:** `idx_import_runs_source_status` on `(source_id, status)` — lookup completed imports by source.
**Purpose:** Prevents duplicate IP bans on import crash/retry. See migration 6 and `blocklist_import_workflow`.
---
### 1.9 `schema_migrations`
Tracks applied schema versions.
| Column | Type | Constraints |
|---|---|---|
| `version` | INTEGER | PRIMARY KEY |
| `migrated_at` | TEXT | NOT NULL DEFAULT ISO 8601 |
**Indexes:** PK only.
**Purpose:** Idempotent schema migration tracker. Records each applied version number. See `init_db()` and `_migrate_schema()`.
---
## 2. Fail2ban Database Schema
Read-only access via `fail2ban_db_repo`. Fail2ban manages this DB; BanGUI mirrors data into `history_archive`.
### 2.1 `fail2banDb`
| Column | Type | Constraints |
|---|---|---|
| `version` | INTEGER | |
Single row tracking DB schema version.
---
### 2.2 `jails`
| Column | Type | Constraints |
|---|---|---|
| `name` | TEXT | NOT NULL UNIQUE |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
**Indexes:** `jails_name` on `(name)`.
---
### 2.3 `logs`
| Column | Type | Constraints |
|---|---|---|
| `jail` | TEXT | NOT NULL FK → `jails(name)` ON DELETE CASCADE |
| `path` | TEXT | |
| `firstlinemd5` | TEXT | |
| `lastfilepos` | INTEGER | DEFAULT 0 |
| `UNIQUE(jail, path)` | | |
| `UNIQUE(jail, path, firstlinemd5)` | | |
**Indexes:** `logs_path` on `(path)`, `logs_jail_path` on `(jail, path)`.
---
### 2.4 `bans`
| Column | Type | Constraints |
|---|---|---|
| `jail` | TEXT | NOT NULL FK → `jails(name)` |
| `ip` | TEXT | |
| `timeofban` | INTEGER | NOT NULL |
| `bantime` | INTEGER | NOT NULL |
| `bancount` | INTEGER | NOT NULL DEFAULT 1 |
| `data` | JSON | |
**Indexes:**
- `bans_jail_timeofban_ip` on `(jail, timeofban)`
- `bans_jail_ip` on `(jail, ip)`
- `bans_ip` on `(ip)`
---
### 2.5 `bips`
Backup IPs table (ban backup).
| Column | Type | Constraints |
|---|---|---|
| `ip` | TEXT | NOT NULL |
| `jail` | TEXT | NOT NULL FK → `jails(name)` |
| `timeofban` | INTEGER | NOT NULL |
| `bantime` | INTEGER | NOT NULL |
| `bancount` | INTEGER | NOT NULL DEFAULT 1 |
| `data` | JSON | |
| PRIMARY KEY | `(ip, jail)` | |
**Indexes:** `bips_timeofban` on `(timeofban)`, `bips_ip` on `(ip)`.
---
## 3. Relationships and Constraints
```
blocklist_sources (1) ──(id)──→ import_log.source_id [RESTRICT on delete]
└──→ import_runs.source_id [CASCADE on delete]
settings: standalone (key-value, no FK)
sessions: standalone (token hash, no FK)
geo_cache: standalone (IP → geo data, no FK)
history_archive: standalone (archived ban history, no FK)
scheduler_lock: singleton row (id=1), no FK
schema_migrations: standalone (migration tracking, no FK)
```
Fail2ban tables are separate and read-only from BanGUI's perspective.
---
## 4. Indexes Summary
| Table | Index | Columns |
|---|---|---|
| `sessions` | `idx_sessions_token_hash` | `token_hash` UNIQUE |
| `import_log` | `idx_import_log_id_desc` | `id DESC` |
| `import_log` | `idx_import_log_source_id_desc` | `source_id, id DESC` |
| `import_runs` | `idx_import_runs_source_status` | `source_id, status` |
| `history_archive` | `idx_history_archive_jail_timeofban` | `jail, timeofban DESC` |
| `history_archive` | `idx_history_archive_timeofban_jail_action` | `timeofban DESC, jail, action` |
| `history_archive` | `idx_history_archive_ip` | `ip` |
| `history_archive` | `idx_history_archive_action` | `action` |
| `jails` | `jails_name` | `name` |
| `logs` | `logs_path` | `path` |
| `logs` | `logs_jail_path` | `jail, path` |
| `bans` | `bans_jail_timeofban_ip` | `jail, timeofban` |
| `bans` | `bans_jail_ip` | `jail, ip` |
| `bans` | `bans_ip` | `ip` |
| `bips` | `bips_timeofban` | `timeofban` |
| `bips` | `bips_ip` | `ip` |
---
## 5. Migration History
| Version | Description |
|---|---|
| 1 | Initial schema: `settings`, `sessions`, `blocklist_sources`, `import_log`, `geo_cache`, `history_archive`, `schema_migrations` |
| 2 | Hash session tokens (`token_hash` column). Invalidates all existing sessions. |
| 3 | Add `last_seen` to `geo_cache` for retention policy. |
| 4 | Add `scheduler_lock` table for multi-worker scheduler mutex. |
| 5 | Add indexes to `history_archive` for query performance (4 indexes). |
| 6 | Add `import_runs` table for idempotent import tracking. |
| 7 | Add indexes to `import_log` for cursor-based pagination. |
| 8 | Migrate `import_log.timestamp` from TEXT ISO 8601 → INTEGER UNIX epoch. |
| 9 | Change `import_log.source_id` FK to `ON DELETE RESTRICT` (prevents orphaned logs). Recreate table with new FK semantics. |
**Current schema version:** 9 (`_CURRENT_SCHEMA_VERSION` in `db.py`).
---
## 6. Performance Notes
- **WAL mode** (`PRAGMA journal_mode=WAL`) — concurrent reads allowed, better write performance under concurrency.
- **Foreign keys enforced** (`PRAGMA foreign_keys=ON`) — data integrity at DB level.
- **Busy timeout** 5000 ms — prevents "database is locked" errors under contention.
- **`history_archive` indexes** — tuned for dashboard filter + time ordering + pagination. See migration 5 and `PERFORMANCE.md`.
- **`import_log` indexes** — tuned for cursor-based pagination (newest-first by id). See migration 7.
- **`geo_cache` PK on `ip`** — O(1) lookup for geo enrichment on ban events.
- **`scheduler_lock` singleton** (`CHECK (id = 1)`) — trivial lock existence check.
For detailed query patterns and benchmarks, see `Docs/PERFORMANCE.md`.

124
Docs/DOMAIN_MODELS.md Normal file
View File

@@ -0,0 +1,124 @@
# Domain Models — Reference Guide
This document explains the domain model pattern used in BanGUI's backend and where to find examples.
---
## What Are Domain Models?
Domain models (e.g., `DomainActiveBanList`, `DomainJailConfig`) are **frozen dataclasses** that represent pure business logic. They are defined in `app/models/{domain}_domain.py` and are **returned by services**.
Response models (e.g., `ActiveBanListResponse`, `JailConfigResponse`) are **Pydantic models** defined in `app/models/{domain}.py`. They are used **only by routers** for HTTP serialization.
---
## Why This Separation?
```
Service (returns domain model)
Router (converts domain → response via mapper)
HTTP Response (Pydantic model)
```
**Benefits:**
- Domain logic evolves without affecting API shape
- Services are reusable across different frontends (GraphQL, gRPC, CLI)
- Testing is simpler (no Pydantic overhead)
- Changes to endpoint responses don't require service changes
---
## Existing Domain Models
| Domain | Domain Model(s) | Mapper Module |
|--------|----------------|---------------|
| **Ban** | `DomainActiveBanList`, `DomainActiveBan`, `DomainBansByCountry` | `ban_mappers.py` |
| **Jail** | `DomainJailList`, `DomainJailDetail`, `DomainJailBannedIps`, `DomainActiveBan` | `jail_mappers.py` |
| **Config** | `DomainJailConfig`, `DomainJailConfigList`, `DomainGlobalConfig`, `DomainServiceStatus`, `DomainBantimeEscalation`, `DomainFilterConfig`, `DomainFilterList`, `DomainRegexTest`, `DomainMapColorThresholds` | `config_mappers.py` |
| **History** | `DomainHistoryList`, `DomainHistoryBanItem`, `DomainIpDetail`, `DomainIpTimelineEvent` | `history_mappers.py` |
| **Server** | `DomainServerSettings`, `DomainServerSettingsResult` | `server_mappers.py` |
| **Blocklist** | `DomainBlocklistSource`, `DomainImportLogEntry`, `DomainImportLogList`, `DomainImportSourceResult`, `DomainImportRunResult`, `DomainPreviewResult`, `DomainScheduleConfig`, `DomainScheduleInfo` | `blocklist_mappers.py` |
---
## The Pattern — Step by Step
### Step 1: Define Domain Model in `app/models/{domain}_domain.py`
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class DomainJailConfig:
"""Configuration snapshot of a single jail (domain model)."""
name: str
ban_time: int
max_retry: int
find_time: int
fail_regex: list[str]
actions: list[str] # ← no default BEFORE default = FIELD ORDER ERROR
date_pattern: str | None = None # ← all fields with defaults come AFTER
log_encoding: LogEncoding = "UTF-8"
```
**⚠️ Field Order Rule:** All fields without defaults must appear before all fields with defaults.
### Step 2: Add Mapper in `app/mappers/{domain}_mappers.py`
```python
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
"""Convert domain jail config to response model."""
return JailConfig(
name=domain.name,
ban_time=domain.ban_time,
...
)
```
### Step 3: Service Returns Domain Model
```python
# In app/services/jail_service.py
from app.models.config_domain import DomainJailConfig, DomainJailConfigList
async def get_jail_config(socket_path: str, name: str) -> DomainJailConfig:
...
return DomainJailConfig(...) # ← return domain model
```
### Step 4: Router Uses Mapper at Boundary
```python
# In app/routers/jail_config.py
from app.mappers import config_mappers
@router.get("/{name}", response_model=JailConfigResponse)
async def get_jail_config(...) -> JailConfigResponse:
domain_result = await config_service.get_jail_config(socket_path, name)
return config_mappers.map_domain_jail_config_to_response(domain_result)
```
---
## Reference Implementation
`ban_service.py` + `ban_mappers.py` is the canonical example of the correct pattern. Study it first when adding a new service.
---
## Common Issues
### Field Ordering Error
```
TypeError: non-default argument 'actions' follows default argument
```
**Fix:** Move all fields with defaults (`field: T | None = None`) after all fields without defaults.
### Forgetting the Mapper
If you refactor a service to return a domain model but forget to update the router, you'll get a type mismatch at the boundary. Always update router + service together.

1071
Docs/Deployment.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
### Options
- **Master Password** — Set a single global password that protects the entire web interface.
- **Master Password** — Set a single global password that protects the entire web interface. Must be between 8 and 72 characters long (72-byte limit is due to bcrypt truncation) and include one uppercase letter, one number, and one special character from `!@#$%^&*()`.
- **Database Path** — Define where the application stores its own SQLite database.
- **fail2ban Connection** — Specify how the application connects to the running fail2ban instance (socket path or related settings).
- **General Preferences** — Any additional application-level settings such as default time zone, date format, or session duration.
@@ -30,6 +30,22 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
- After entering the correct password the user is taken to the page they originally requested.
- A logout option is available from every page so the user can end their session.
### Session Validation on App Load
- On app mount (page reload or initial load), the frontend validates the cached session with the backend by calling `GET /api/auth/session`.
- While the validation check is in flight, a loading spinner is displayed to avoid UI flicker.
- If the backend returns **200**, the session is valid and the app proceeds normally.
- If the backend returns **401**, the session has expired or been revoked (server-side DB deletion, restart, etc.), and the user is logged out and redirected to the login page.
- If a **network error** occurs (backend temporarily unreachable), the user is not logged out — the app assumes the backend will recover and continues with the cached session state. The next API call will trigger a 401 if the session is actually invalid.
### Login Rate Limiting
- The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks with per-IP rate limiting.
- **Rate limit:** 5 login attempts per minute per IP address.
- When the limit is exceeded, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header indicating when requests will be accepted again.
- Each failed login attempt triggers a progressive server-side delay (exponential back-off from 1 to 10 seconds) to further slow down attack attempts, on top of the bcrypt password hashing cost. The penalty grows with consecutive failures and resets after the rate-limit window expires.
- The rate limiter tracks attempts in memory per IP, ensuring that rapid-fire attacks from a single source are quickly throttled.
---
## 3. Ban Overview (Dashboard)
@@ -196,11 +212,12 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- Option to register additional log files that fail2ban should monitor.
- For each new log, specify:
- The path to the log file.
- The path to the log file (must be within allowed directories to prevent unauthorized access to sensitive files).
- One or more regex patterns that define what constitutes a failure.
- The jail name and basic jail settings (ban time, retries, etc.).
- Choose whether the file should be read from the beginning or only new lines (head vs. tail).
- Preview matching lines from the log against the provided regex before saving, so the user can verify the pattern works.
- **Log Path Security:** Added log paths must resolve to locations within a configured allowlist of safe directories (default: `/var/log` and `/config/log`). This prevents authenticated users from instructing fail2ban to monitor sensitive system files. Paths containing symlinks are resolved to their canonical targets before validation.
### Regex Tester
@@ -211,8 +228,10 @@ A page to inspect and modify the fail2ban configuration without leaving the web
### Server Settings
- View and change the fail2ban log level (e.g. Critical, Error, Warning, Info, Debug).
- View and change the log target (file path, stdout, stderr, syslog, systemd journal).
- View and change the fail2ban log level using valid values: `CRITICAL`, `ERROR`, `WARNING`, `NOTICE`, `INFO`, `DEBUG`.
- View and change the log target, which can be:
- Special values: `STDOUT`, `STDERR`, `SYSLOG`
- A file path that resolves to one of the configured safe log directories (default: `/var/log` and `/config/log`). Symlinks are resolved to their canonical targets before validation.
- View and change the syslog socket if syslog is used.
- Flush and re-open log files (useful after log rotation).
- View and change the fail2ban database file location.
@@ -247,8 +266,8 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- **Auto-refresh** toggle with interval selector (5 s / 10 s / 30 s) for live monitoring.
- Truncation notice when the total log file line count exceeds the requested tail limit.
- Container automatically scrolls to the bottom after each data update.
- When fail2ban is configured to log to a non-file target (STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL), an informational banner explains that file-based log viewing is unavailable.
- The log file path is validated against a safe prefix allowlist on the backend to prevent path-traversal reads.
- When fail2ban is configured to log to a non-file target (`STDOUT`, `STDERR`, or `SYSLOG`), an informational banner explains that file-based log viewing is unavailable.
- Log file paths are validated against a configurable allowlist of safe directories on the backend to prevent unauthorized reads of sensitive system files.
---
@@ -295,6 +314,17 @@ Automated downloading and applying of external IP blocklists to block known mali
- Support for plain-text lists with one IP address per line.
- Preview the contents of a blocklist URL before enabling it (download and display a sample of entries).
#### URL Validation & Security
- **Scheme restriction:** Only `http://` and `https://` schemes are accepted. `file://`, `ftp://`, and other schemes are rejected.
- **Hostname validation:** The hostname is resolved via DNS and the resulting IP address is validated to prevent SSRF attacks:
- Private IP ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) are rejected.
- Loopback addresses (`127.0.0.1`, `::1`) are rejected.
- Link-local addresses (`169.254.0.0/16`, `fe80::/10`) are rejected.
- Reserved and multicast addresses are rejected.
- **Error handling:** If a URL fails validation (invalid scheme, unresolvable hostname, or resolves to a private IP), the API returns a `400 Bad Request` with a descriptive error message.
- **Ports:** URLs may specify custom ports (e.g. `https://example.com:8443/list.txt`), but the hostname must still resolve to a public IP address.
### Schedule
- Configure when the blocklist import runs using a simple time-and-frequency picker (no raw cron syntax required).
@@ -306,6 +336,12 @@ Automated downloading and applying of external IP blocklists to block known mali
- Option to run an import manually at any time via a "Run Now" button.
- Show the date and time of the last successful import and the next scheduled run.
#### Scheduling Reliability
- **Deterministic updates:** Schedule changes are applied immediately and deterministically. The schedule update endpoint waits for the reschedule operation to complete and surface any errors before returning the response.
- **Error observability:** If a schedule update fails (e.g., due to a database error), the HTTP response will reflect the error with an appropriate status code and error message. The user is never left wondering whether their schedule change took effect.
- **Atomicity:** The schedule is persisted to the database and the APScheduler job is updated in a coordinated manner. Both operations are completed before the update request returns success to the client.
### Import Behaviour
- On each scheduled run, download all enabled blocklist sources.
@@ -322,6 +358,13 @@ Automated downloading and applying of external IP blocklists to block known mali
- Display the import log in the web interface, filterable by source and date range.
- Show a warning badge in the navigation if the most recent import encountered errors.
### Data Retention & Deletion
- Import logs are retained for audit and troubleshooting purposes.
- A blocklist source **cannot be deleted** while it has associated import logs (foreign key RESTRICT constraint).
- Before deleting a source, delete all its import logs first via the API.
- Attempting to delete a source with logs returns **HTTP 409 Conflict** with error code `blocklist_source_has_logs`.
### Error Handling
- If a blocklist URL is unreachable, log the error and continue with remaining sources.

View File

@@ -72,13 +72,8 @@ Supporting documentation you must know and respect:
Repeat the following cycle for every task. Do not skip steps.
### Step 1 — Pick a Task
- Open `tasks.md` and pick the next unfinished task (highest priority first).
- Mark the task as **in progress**.
- Read the task description thoroughly. Understand the expected outcome before proceeding.
### Step 2 — Plan Your Steps
### Step 1 — Plan Your Steps
- Break the task into concrete implementation steps.
- Identify which files need to be created, modified, or deleted.
@@ -86,7 +81,7 @@ Repeat the following cycle for every task. Do not skip steps.
- Identify edge cases and error scenarios.
- Write down your plan before touching any code.
### Step 3 — Write Code
### Step 2 — Write Code
- Implement the feature or fix following the plan.
- Follow all rules from the relevant development docs:
@@ -97,14 +92,14 @@ Repeat the following cycle for every task. Do not skip steps.
- Write clean, well-structured, fully typed code.
- Keep commits atomic — one logical change per commit.
### Step 4 — Add Logging
### Step 3 — Add Logging
- Add structured log statements at key points in new or modified code.
- Backend: use **structlog** with contextual key-value pairs — never `print()`.
- Log at appropriate levels: `info` for operational events, `warning` for recoverable issues, `error` for failures.
- Never log sensitive data (passwords, tokens, session IDs).
### Step 5 — Write Tests
### Step 4 — Write Tests
- Write tests for every new or changed piece of functionality.
- Backend: use `pytest` + `pytest-asyncio` + `httpx.AsyncClient`. See [Backend-Development.md § 9](Backend-Development.md).
@@ -113,24 +108,24 @@ Repeat the following cycle for every task. Do not skip steps.
- Mock external dependencies — tests must never touch real infrastructure.
- Follow the naming pattern: `test_<unit>_<scenario>_<expected>`.
### Step 6 — Review Your Code
### Step 5 — Review Your Code
Run a thorough self-review before considering the task done. Check **all** of the following:
#### 6.1 — Warnings and Errors
#### 5.1 — Warnings and Errors
- Backend: run `ruff check` and `mypy --strict` (or `pyright --strict`). Fix every warning and error.
- Frontend: run `tsc --noEmit` and `eslint`. Fix every warning and error.
- Zero warnings, zero errors — no exceptions.
#### 6.2 — Test Coverage
#### 5.2 — Test Coverage
- Run the test suite with coverage enabled.
- Aim for **>80 % line coverage** overall.
- Critical paths (auth, banning, scheduling, API endpoints) must be **100 %** covered.
- If coverage is below the threshold, write additional tests before proceeding.
#### 6.3 — Coding Principles
#### 5.3 — Coding Principles
Verify your code against the coding principles defined in [Backend-Development.md § 13](Backend-Development.md) and [Web-Development.md](Web-Development.md):
@@ -141,7 +136,7 @@ Verify your code against the coding principles defined in [Backend-Development.m
- [ ] **KISS** — The simplest correct solution is used. No over-engineering.
- [ ] **Type Safety** — All types are explicit. No `any` / `Any`. No `# type: ignore` without justification.
#### 6.4 — Architecture Compliance
#### 5.4 — Architecture Compliance
Verify against [Architekture.md](Architekture.md) and the project structure rules:
@@ -153,7 +148,7 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule
- [ ] Pydantic models separate request, response, and domain shapes.
- [ ] Frontend types live in `types/`, not scattered across components.
### Step 7 — Update Documentation
### Step 6 — Update Documentation
- If your change introduces new features, new endpoints, new components, or changes existing behaviour, update the relevant docs:
- [Features.md](Features.md) — if feature behaviour changed.
@@ -161,51 +156,6 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule
- [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) — if new conventions were established.
- Keep documentation accurate and in sync with the code. Outdated docs are worse than no docs.
### Step 8 — Mark Task Complete
- Open `tasks.md` and mark the task as **done**.
- Add a brief summary of what was implemented or changed.
### Step 9 — Commit
- Stage all changed files.
- Write a commit message in **imperative tense**, max 72 characters for the subject line.
- Good: `Add jail reload endpoint`
- Bad: `added stuff` / `WIP` / `fix`
- If the change is large, include a body explaining **why**, not just **what**.
- Branch naming: `feature/<short-description>`, `fix/<short-description>`, `chore/<short-description>`.
- Ensure the commit passes: linter, type checker, all tests.
### Step 10 — Next Task
- Return to **Step 1** and pick the next task.
---
## 4. Workflow Summary
```
┌─────────────────────────────────────────┐
│ 1. Pick task from tasks.md │
│ 2. Plan your steps │
│ 3. Write code │
│ 4. Add logging │
│ 5. Write tests │
│ 6. Review your code │
│ ├── 6.1 Check warnings & errors │
│ ├── 6.2 Check test coverage │
│ ├── 6.3 Check coding principles │
│ └── 6.4 Check architecture │
│ 7. Update documentation if needed │
│ 8. Mark task complete in tasks.md │
│ 9. Git commit │
│ 10. Pick next task ──────── loop ───┐ │
│ ▲ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────────────┘
```
---
## 5. When You Are Stuck
@@ -229,7 +179,37 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule
---
## 7. Dev Quick-Reference
## 7. First-Run Setup
### Initialize the Development Environment
Before starting the stack for the first time, set up the required environment variables:
1. **Copy the example environment file:**
```bash
cp .env.example .env
```
2. **Generate a session secret:**
```bash
python -c 'import secrets; print(secrets.token_hex(32))'
```
Copy the output and paste it as the value for `BANGUI_SESSION_SECRET` in your `.env` file.
3. **Optional: Customize other settings**
- Edit `.env` to adjust timezone, port numbers, or other settings
- Default values are sensible for development (UTC, ports 8000/5173)
4. **Start the stack:**
```bash
make up
```
**Note:** The session secret is critical for security. Do not commit `.env` to version control — it is already in `.gitignore`. Each environment (dev, staging, production) must have its own unique secret.
---
## 8. Dev Quick-Reference
### Start / stop the stack
@@ -244,16 +224,17 @@ Backend: `http://127.0.0.1:8000` · Frontend (Vite proxy): `http://127.0.0.1:517
### API login (dev)
The frontend SHA256-hashes the password before sending it to the API.
The initial setup password must be at least 8 characters long and include one uppercase letter, one number, and one special character from `!@#$%^&*()`.
The session cookie is named `bangui_session`.
```bash
# Dev master password: Hallo123!
HASHED=$(echo -n "Hallo123!" | sha256sum | awk '{print $1}')
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/auth/login \
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d "{\"password\":\"$HASHED\"}" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
# Use token in subsequent requests:
curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/dashboard/status
curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/v1/dashboard/status
```

845
Docs/Observability.md Normal file
View File

@@ -0,0 +1,845 @@
# Observability
BanGUI provides comprehensive observability through structured logging, metrics, and tracing capabilities. This document outlines the observability architecture and how to configure it for production deployments.
---
## Logging Architecture
### Overview
BanGUI uses **structlog** to emit structured, machine-readable logs in JSON format. All logs are automatically enriched with:
- **Timestamps** in ISO 8601 format (`timestamp`)
- **Log levels** (`level` - debug, info, warning, error, critical)
- **Logger names** (`logger_name`)
- **Correlation IDs** for request tracking (`correlation_id`)
- **Custom context** from business logic (via context variables)
### Log Output
By default, logs are written to **stdout** in JSON format, making them suitable for:
- Container environments (Docker, Kubernetes)
- Log aggregation systems (ELK, Datadog, Papertrail)
- CI/CD pipelines and monitoring platforms
```bash
# Example log output (formatted for readability)
{
"timestamp": "2024-05-01T18:17:19.080+02:00",
"level": "info",
"logger_name": "app.main",
"event": "bangui_starting_up",
"database_path": "/var/lib/bangui/bangui.db",
"pid": 1234
}
```
### Sensitive Data Handling
**CRITICAL: Never log sensitive data.** The following must NEVER appear in logs:
- Session tokens or cookies
- API keys or secrets
- Passwords or password hashes
- Private cryptographic keys
- Personal information (PII)
- Full IP addresses (when not required for security auditing)
When logging authentication or sensitive operations:
```python
# ✓ Correct: Log event type and result, not credentials
log.info("user_login_attempt", username=username, ip=client_ip, success=True)
# ✓ Correct: Log sanitized identifiers
log.error("auth_token_validation_failed", token_hash=hashlib.sha256(token).hexdigest()[:16])
# ✗ WRONG: Don't do this
log.debug("raw_token", token=token) # Never!
log.info("password_check", password=password_hash) # Never!
```
Structlog provides context variable filtering to prevent accidental logging of sensitive data. Code reviews must verify compliance with this rule.
### Log Sanitization
All external output (subprocess results, API responses, config file contents) passed to structlog **must** be sanitized first using `sanitize_for_logging()` from `app.utils.log_sanitizer`.
This prevents sensitive data — passwords, API keys, tokens, private keys — from leaking into logs.
```python
from app.utils.log_sanitizer import sanitize_for_logging
# ✓ Correct: Sanitize before logging
log.error(
"fail2ban_start_failed",
command=" ".join(start_cmd_parts),
returncode=process.returncode,
stdout=sanitize_for_logging(stdout.decode("utf-8", errors="replace")),
stderr=sanitize_for_logging(stderr.decode("utf-8", errors="replace")),
)
# ✗ Wrong: Raw output may contain secrets
log.error("fail2ban_start_failed", stdout=stdout_raw, stderr=stderr_raw) # Never!
```
`sanitize_for_logging()` redacts the following patterns:
| Pattern | Example match | Replacement |
|---------|---------------|-------------|
| `password=X` | `password=Secret123` | `password=***` |
| `api_key=X` / `api-key=X` | `api_key=key123` | `api_key=***` |
| `token=X` | `token=eyJhbG...` | `token=***` |
| `Authorization: Bearer X` | `Authorization: Bearer tok...` | `Authorization: ***` |
| `secret=X` | `secret=myvalue` | `secret=***` |
| `-----BEGIN RSA PRIVATE KEY-----` | (key header) | `*** PRIVATE KEY ***` |
| `AKIA...` | `AKIAIOSFODNN7EXAMPLE` | `AKIA***` |
---
## Third-Party Library Logs
BanGUI uses **structlog** for all application logs, but third-party libraries often emit plain text through Python's standard `logging` module. To maintain uniform JSON output and reduce noise, the following libraries have their log levels overridden to `WARNING`:
| Library | Logger Name | Level | Rationale |
|---------|-------------|-------|-----------|
| APScheduler | `apscheduler` | `WARNING` | Suppresses routine scheduler polling ("Looking for jobs to run", "Next wakeup is due at...") while preserving job failure warnings. |
| aiosqlite | `aiosqlite` | `WARNING` | Suppresses database operation traces and connection details while preserving connection errors. |
These overrides are applied in `backend/app/main.py::_configure_logging()` immediately after `logging.basicConfig()`.
### Disabling Suppression
Set the environment variable `BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false` to allow APScheduler and aiosqlite to emit their normal DEBUG/INFO logs. This is useful when troubleshooting scheduler or database issues in development.
```bash
BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false python -m uvicorn app.main:create_app
```
When suppression is disabled, the loggers inherit the application's `BANGUI_LOG_LEVEL` (e.g., `debug`).
### Uniform JSON Formatting
All stdlib logs — including those from third-party libraries — are intercepted by `structlog.stdlib.ProcessorFormatter` and rendered as JSON. This ensures every log line in `bangui.log` is machine-readable, regardless of its source.
### Adding New Overrides
When integrating a new library that emits verbose DEBUG logs:
```python
# In backend/app/main.py, inside _configure_logging()
logging.getLogger("new_library").setLevel(logging.WARNING)
```
Use `WARNING` as the default to still capture errors and warnings. Only use `ERROR` if the library is exceptionally noisy and its warnings are not actionable.
---
## Structured Logging Best Practices
### Log Levels
Use log levels consistently:
| Level | Use Case | Example |
|-------|----------|---------|
| **debug** | Verbose diagnostic information | `log.debug("parsing_config_file", lines=1024)` |
| **info** | Operational events | `log.info("jail_created", jail_name="sshd", action_count=3)` |
| **warning** | Recoverable issues | `log.warning("config_reload_skipped", reason="no_changes")` |
| **error** | Failures that impact functionality | `log.error("fail2ban_connection_lost", error=str(e))` |
| **critical** | System failures | `log.critical("database_corrupted", error=str(e))` |
### Context Variables
Use structlog's context variables to automatically include request-scoped information in all logs within a request:
```python
import structlog
log = structlog.get_logger()
# In middleware or early in request processing
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(
correlation_id=request_id,
user_id=user_id,
client_ip=client_ip,
)
# All subsequent logs in this request will include these context variables
log.info("user_action", action="create_jail") # Automatically includes correlation_id, user_id, etc.
# Clear context at end of request
structlog.contextvars.clear_contextvars()
```
### Background Task Correlation
Background tasks (APScheduler jobs) run outside the HTTP request context.
Use :mod:`app.utils.correlation` to propagate correlation IDs through tasks:
```python
from app.utils.correlation import get_correlation_id, reset_correlation_id, set_correlation_id
async def my_background_task(correlation_id: str | None = None) -> None:
# Generate a new ID if not provided (scheduled tasks have no parent request)
if correlation_id is None:
import uuid
correlation_id = str(uuid.uuid4())
# Set the correlation ID for all logs in this task
token = set_correlation_id(correlation_id)
try:
log.info("task_started") # Now includes correlation_id
# ... task logic ...
finally:
reset_correlation_id(token)
# When scheduling, optionally pass the current correlation ID:
# scheduler.add_job(my_background_task, kwargs={"correlation_id": get_correlation_id()})
```
Scheduled tasks (no parent request) generate a fresh UUID for each run.
Tasks triggered by a request inherit the request's correlation ID.
### Event Naming Convention
Use snake_case for event names, prefixed with the component or module name:
```python
# ✓ Good naming
log.info("service_initialized", service="BanService", version="1.0")
log.warning("blocklist_import_slow", duration_ms=5000)
log.error("fail2ban_command_failed", command="list", exit_code=1)
# ✗ Bad naming
log.info("init") # Too generic
log.warning("slow operation") # Not machine-readable
log.error("ERROR: FAIL2BAN FAILED!") # Inconsistent formatting
```
### Attaching Structured Data
Always provide context as key-value pairs, not as unstructured strings:
```python
# ✓ Correct: Structured, queryable
log.info(
"ban_executed",
jail="sshd",
ip="192.0.2.1",
duration_seconds=3600,
reason="brute_force",
)
# ✗ Wrong: Unstructured, hard to query
log.info(f"Banned {ip} in jail {jail} for 3600 seconds because brute_force")
```
---
## Centralized Logging Configuration
### Environment Variables
External logging is configured via environment variables (all prefixed with `BANGUI_`):
#### Datadog
Enable logging to Datadog via HTTP API:
```bash
BANGUI_EXTERNAL_LOGGING_ENABLED=true
BANGUI_EXTERNAL_LOGGING_PROVIDER=datadog
BANGUI_DATADOG_API_KEY=your-api-key-here
BANGUI_DATADOG_SITE=datadoghq.com # or datadoghq.eu for EU
BANGUI_DATADOG_BATCH_SIZE=10 # Optional: logs per batch
BANGUI_DATADOG_FLUSH_INTERVAL_SECONDS=5 # Optional: flush interval
```
#### Papertrail
Enable logging to Papertrail via Syslog protocol:
```bash
BANGUI_EXTERNAL_LOGGING_ENABLED=true
BANGUI_EXTERNAL_LOGGING_PROVIDER=papertrail
BANGUI_PAPERTRAIL_HOST=logs1.papertrailapp.com
BANGUI_PAPERTRAIL_PORT=12345
BANGUI_PAPERTRAIL_PROGRAM_NAME=bangui # Optional: program name in syslog
```
#### ELK Stack
Enable logging to Elasticsearch/Logstash:
```bash
BANGUI_EXTERNAL_LOGGING_ENABLED=true
BANGUI_EXTERNAL_LOGGING_PROVIDER=elasticsearch
BANGUI_ELASTICSEARCH_HOSTS=http://elasticsearch:9200
BANGUI_ELASTICSEARCH_INDEX_PREFIX=bangui # Optional: index prefix
BANGUI_ELASTICSEARCH_BATCH_SIZE=10 # Optional: docs per batch
BANGUI_ELASTICSEARCH_FLUSH_INTERVAL_SECONDS=5 # Optional: flush interval
```
### Local Development (Disabled by Default)
External logging is **disabled by default**. In development, logs continue to write to stdout only:
```bash
# No configuration needed — logs go to stdout
docker compose up
```
To enable external logging in development for testing:
```bash
BANGUI_EXTERNAL_LOGGING_ENABLED=true \
BANGUI_EXTERNAL_LOGGING_PROVIDER=datadog \
BANGUI_DATADOG_API_KEY=test-key \
python -m uvicorn app.main:create_app --host 0.0.0.0 --port 8000
```
---
## Performance and Reliability
### Non-Blocking Delivery
External log delivery uses **asynchronous buffering** to prevent blocking the application:
1. Logs are written to an in-memory buffer
2. After the configured flush interval or batch size, the buffer is sent asynchronously
3. Send failures do not block application logic
4. Retries use exponential backoff (up to 5 attempts)
This ensures that external logging never degrades application performance.
### Failure Modes
If external logging becomes unavailable:
- **Transient failures** (network timeouts, temporary 5xx errors): Logs are retried with exponential backoff
- **Permanent failures** (invalid API key, host unreachable): A warning is logged; application continues
- **Steady-state**: Logs are buffered up to a maximum queue size (default: 1000 logs); older logs are dropped if buffer fills
The application **never crashes** due to external logging failures.
### Log Volume and Rate Limiting
Large log volumes can increase data transfer and storage costs. To manage log volume:
1. **Reduce log level in production**: Set `BANGUI_LOG_LEVEL=warning` or `error` to suppress debug/info logs
2. **Sample logs**: Some providers (Datadog, Papertrail) support sampling rules
3. **Filter sensitive paths**: Middleware can suppress verbose logging for noisy endpoints
Monitor actual log volume and adjust settings based on usage patterns.
---
## Integration Examples
### Docker Compose (Development with Datadog)
```yaml
version: "3.9"
services:
bangui:
build:
context: .
dockerfile: Docker/Dockerfile.app
environment:
BANGUI_EXTERNAL_LOGGING_ENABLED: "true"
BANGUI_EXTERNAL_LOGGING_PROVIDER: "datadog"
BANGUI_DATADOG_API_KEY: "${DATADOG_API_KEY}"
BANGUI_DATADOG_SITE: "datadoghq.com"
BANGUI_LOG_LEVEL: "info"
ports:
- "8000:8000"
```
### Kubernetes Deployment (Papertrail)
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: bangui-logging
data:
BANGUI_EXTERNAL_LOGGING_ENABLED: "true"
BANGUI_EXTERNAL_LOGGING_PROVIDER: "papertrail"
BANGUI_PAPERTRAIL_HOST: "logs1.papertrailapp.com"
BANGUI_PAPERTRAIL_PORT: "12345"
BANGUI_PAPERTRAIL_PROGRAM_NAME: "bangui"
BANGUI_LOG_LEVEL: "info"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bangui
spec:
template:
spec:
containers:
- name: bangui
image: bangui:latest
envFrom:
- configMapRef:
name: bangui-logging
env:
- name: BANGUI_DATADOG_API_KEY
valueFrom:
secretKeyRef:
name: bangui-secrets
key: datadog-api-key
```
---
## Monitoring Logging Infrastructure
### Datadog Dashboard Query
Search for all BanGUI logs:
```
service:bangui
```
Search for errors in authentication:
```
service:bangui status:error component:auth
```
### Papertrail Search
Search for all startup events:
```
program:bangui bangui_starting_up
```
Search for authentication failures:
```
program:bangui auth_token_validation_failed
```
### Elasticsearch Query (ELK)
```json
{
"query": {
"bool": {
"must": [
{ "match": { "logger_name": "app.auth" } },
{ "match": { "level": "error" } }
]
}
}
}
```
---
## Testing and Debugging
### Verify JSON Output
Inspect the actual JSON emitted by the logging system:
```bash
# Start the app and capture logs
python -m uvicorn app.main:create_app --host 0.0.0.0 --port 8000 2>&1 | head -10 | python -m json.tool
```
Expected output:
```json
{
"timestamp": "2024-05-01T18:20:45.123456+02:00",
"level": "info",
"logger_name": "app.main",
"event": "bangui_starting_up",
"database_path": "/var/lib/bangui/bangui.db"
}
```
### Enable Debug Logging for External Log Delivery
Set the log level to `debug` to see internal logs from the external logging system:
```bash
BANGUI_LOG_LEVEL=debug BANGUI_EXTERNAL_LOGGING_ENABLED=true python -m uvicorn app.main:create_app
```
This will emit logs like:
```json
{
"level": "debug",
"event": "external_log_batch_sent",
"provider": "datadog",
"batch_size": 10,
"duration_ms": 42
}
```
### Validate Configuration
Validate external logging configuration on startup:
```bash
python -c "from app.config import get_settings; s = get_settings(); print(s.model_dump())"
```
---
## Security Considerations
### API Key Rotation
Rotate API keys regularly:
1. Update `BANGUI_DATADOG_API_KEY` with the new key
2. Restart the application
3. Old keys can be revoked after restart
### Network Security
When sending logs over the network:
- **Datadog HTTP API**: Uses HTTPS, encrypted in transit
- **Papertrail Syslog**: Use TLS-enabled Syslog (if supported) or send over VPN/private network
- **Elasticsearch**: Use HTTPS and HTTP Basic Auth or API Key authentication
Never send logs over unencrypted channels in production.
### Compliance
Ensure that your external logging platform complies with your organization's data protection requirements:
- **GDPR**: Verify the platform's data processing agreements
- **HIPAA**: Ensure the provider is HIPAA-eligible
- **SOC 2**: Request audit reports from your logging provider
- **Data retention**: Configure appropriate log retention policies
---
## Troubleshooting
### Logs Not Appearing in External System
1. **Verify configuration**: Check that environment variables are set correctly
2. **Check API credentials**: Ensure the API key or credentials are valid
3. **Check network connectivity**: Verify the external system is reachable
4. **Review logs locally**: Run with `BANGUI_LOG_LEVEL=debug` and check stdout for errors
5. **Check disk space**: Ensure the local buffer directory has sufficient disk space
### Performance Degradation
1. **Check buffer size**: If the buffer is full, logs are dropped; increase `BANGUI_EXTERNAL_LOGGING_BUFFER_SIZE`
2. **Adjust flush interval**: Decrease flush interval if experiencing large batches
3. **Reduce log level**: Set `BANGUI_LOG_LEVEL=warning` to reduce log volume
4. **Monitor network**: Check bandwidth usage between application and external system
### Lost Logs
In the rare event that logs are lost:
1. **Buffer overflow**: The in-memory buffer has a maximum size; excess logs are dropped with a warning
2. **Network failure during batch send**: Logs are retried; after max retries, a warning is logged
3. **External system outage**: Logs may be dropped if buffer fills before service is restored
To minimize data loss:
- Increase buffer size (`BANGUI_EXTERNAL_LOGGING_BUFFER_SIZE`)
- Use persistent external logging platforms
- Monitor for warnings in application logs about dropped batches
---
## Application Performance Monitoring (Metrics)
BanGUI collects comprehensive metrics for request performance, application health, and resource utilization through **Prometheus**. Metrics are exposed in standard Prometheus text format and can be scraped by monitoring systems.
### Backend Metrics
#### HTTP Request Metrics
The backend automatically tracks HTTP request performance:
- **`bangui_http_requests_total`** (Counter) — Total HTTP requests by method, endpoint, and status code
```
bangui_http_requests_total{method="GET",endpoint="/api/jails",status_code="200"} 125
```
- **`bangui_http_request_duration_seconds`** (Histogram) — Request latency distribution by method and endpoint
```
bangui_http_request_duration_seconds_bucket{method="GET",endpoint="/api/jails",le="0.1"} 120
bangui_http_request_duration_seconds_sum{method="GET",endpoint="/api/jails"} 45.23
```
- **`bangui_http_active_requests`** (Gauge) — Current number of in-flight requests by method and endpoint
```
bangui_http_active_requests{method="GET",endpoint="/api/jails"} 5
```
#### Application Metrics
Domain-specific metrics track application state:
- **`bangui_bans_total`** (Gauge) — Total number of currently banned IPs across all jails
- **`bangui_jails_total`** (Gauge) — Total number of fail2ban jails
- **`bangui_fail2ban_connection_errors_total`** (Counter) — Total fail2ban connection errors
#### Accessing Metrics
Prometheus metrics are exposed at the `/metrics` endpoint:
```bash
curl http://localhost:8000/metrics
```
Response format:
```
# HELP bangui_http_requests_total Total HTTP requests by method, endpoint, and status code
# TYPE bangui_http_requests_total counter
bangui_http_requests_total{method="GET",endpoint="/api/dashboard/status",status_code="200"} 1523.0
# HELP bangui_http_request_duration_seconds HTTP request latency in seconds by method and endpoint
# TYPE bangui_http_request_duration_seconds histogram
bangui_http_request_duration_seconds_bucket{method="GET",endpoint="/api/dashboard/status",le="0.01"} 1200.0
bangui_http_request_duration_seconds_sum{method="GET",endpoint="/api/dashboard/status"} 156.78
```
### Frontend Metrics
#### Web Vitals
The frontend automatically measures Core Web Vitals using the `web-vitals` library:
- **Cumulative Layout Shift (CLS)** — Visual stability score (good: ≤0.1)
- **First Contentful Paint (FCP)** — Time until first content appears (good: ≤1.8s)
- **First Input Delay (FID)** — Responsiveness to user input (good: ≤100ms)
- **Largest Contentful Paint (LCP)** — Time until largest content is visible (good: ≤2.5s)
- **Time to First Byte (TTFB)** — Server response time (good: ≤600ms)
#### API Call Metrics
API calls are automatically tracked with:
- HTTP method and endpoint
- Response status code
- Duration in milliseconds
- Timestamp
### Integrating with Monitoring Systems
#### Prometheus + Grafana
Configure Prometheus to scrape BanGUI metrics:
```yaml
# prometheus.yml
scrape_configs:
- job_name: "bangui"
static_configs:
- targets: ["localhost:8000"]
metrics_path: "/metrics"
```
Then import a Grafana dashboard to visualize:
- Request rates by endpoint
- Latency percentiles (p50, p95, p99)
- Error rate trends
- Active request counts
#### Datadog
Configure BanGUI to send metrics via StatsD or HTTP API:
```bash
BANGUI_METRICS_ENABLED=true
BANGUI_METRICS_PROVIDER=datadog
BANGUI_DATADOG_API_KEY=your-api-key
BANGUI_DATADOG_SITE=datadoghq.com
```
#### New Relic
Send metrics to New Relic (custom event collection):
```bash
BANGUI_METRICS_ENABLED=true
BANGUI_METRICS_PROVIDER=newrelic
BANGUI_NEWRELIC_API_KEY=your-api-key
BANGUI_NEWRELIC_ACCOUNT_ID=your-account-id
```
### Metrics Best Practices
#### Cardinality Management
Metric labels (tags) can cause cardinality explosion if not carefully managed. BanGUI uses:
- Path normalization — `/api/jails/123` becomes `/api/{id}` to prevent unique labels per resource
- Status code grouping — errors are grouped by category, not individual codes
- Endpoint aggregation — only significant endpoints are tracked
#### Performance Considerations
- Metrics collection has negligible performance impact (<1ms per request)
- In-memory buffering prevents database writes on every request
- High-cardinality labels are avoided
- Metric export (scraping) does not block request processing
#### PII Protection
**NEVER include sensitive data in metric labels:**
- User IDs or session tokens
- Passwords or API keys
- Private IP addresses
- Full request/response bodies
Allowed: HTTP method, endpoint path (normalized), status code, duration, timestamp.
### Query Examples
#### Prometheus Queries
Find p95 request latency for `/api/jails`:
```promql
histogram_quantile(0.95, bangui_http_request_duration_seconds_bucket{endpoint="/api/jails"})
```
Find error rate (5xx responses):
```promql
rate(bangui_http_requests_total{status_code=~"5.."}[5m])
```
Find active requests per endpoint:
```promql
bangui_http_active_requests
```
#### Grafana Dashboard
Recommended panels:
1. **Request Rate** — `rate(bangui_http_requests_total[1m])` by endpoint
2. **Latency Percentiles** — `histogram_quantile([0.5, 0.95, 0.99], ...)`
3. **Error Rate** — `rate(bangui_http_requests_total{status_code=~"5.."}[5m])`
4. **Active Requests** — `bangui_http_active_requests` (gauge)
5. **fail2ban Connection Health** — `rate(bangui_fail2ban_connection_errors_total[5m])`
### Troubleshooting Metrics
#### Metrics endpoint not responding
1. Verify the `/metrics` endpoint is accessible: `curl http://localhost:8000/metrics`
2. Check application logs for errors during middleware initialization
3. Ensure prometheus-client is installed: `pip show prometheus-client`
#### High cardinality warnings
If Prometheus warns about high cardinality:
1. Check if custom labels are being added to metrics
2. Ensure path normalization is working (IDs should be replaced with `{id}`)
3. Consider sampling metrics for high-volume endpoints
#### Missing metrics
1. Check that endpoints are being called (look for 200 responses in logs)
2. Verify the metrics middleware is registered (check `app.add_middleware(MetricsMiddleware)`)
3. Ensure metrics are being recorded (call `recordApiCall()` on frontend)
---
## Future Enhancements
Planned observability improvements:
- [x] Application metrics collection (Prometheus)
- [x] Web Vitals tracking (frontend)
- [ ] Distributed tracing (OpenTelemetry integration)
- [ ] Custom metric hooks for business events
- [ ] Alerting rules and thresholds
- [ ] Log sampling strategies
- [ ] Additional provider support (Splunk, New Relic, CloudWatch)
---
## Scheduler Lock Health Monitoring
The scheduler lock ensures only one instance runs background tasks. Monitoring its health is critical for production reliability.
### Key Metrics
Monitor these log events for scheduler lock health:
| Event | Level | Meaning |
|-------|-------|---------|
| `scheduler_lock_acquired` | info | Successfully acquired the scheduler lock |
| `scheduler_lock_held_by_other_instance` | warning | Another instance holds the lock (expected during normal multi-instance operation) |
| `scheduler_lock_stale_overwrite` | info | Took over a stale lock from a crashed instance |
| `scheduler_lock_heartbeat_lost` | warning | Heartbeat update failed; we lost the lock |
| `scheduler_lock_release_mismatch` | warning | Release attempted but we don't hold the lock |
### Lock Health Check
Query current lock status via `get_lock_health()`:
```python
from app.utils.scheduler_lock import get_lock_health
health = await get_lock_health(db)
# Returns: {"locked": bool, "pid": int|None, "hostname": str|None,
# "age_seconds": float|None, "is_stale": bool, "ttl_remaining": float|None}
```
### Alerting Rules
**Critical alerts:**
- `scheduler_lock_acquired` not seen for >5 minutes during startup → Instance may not have acquired lock
- `scheduler_lock_heartbeat_lost` repeated >3 times → Lock keeps being stolen, possible contention issue
**Warning alerts:**
- `scheduler_lock_held_by_other_instance` every few minutes → Normal if multiple instances, abnormal if single instance
### Database Query
Check lock state directly in SQLite:
```sql
SELECT pid, hostname, heartbeat_at, heartbeat_timeout,
(datetime('now') - datetime(heartbeat_at, 'unixepoch')) as age
FROM scheduler_lock WHERE id = 1;
```
### Common Issues
1. **Lock not acquired on startup**: Check logs for `scheduler_lock_held_by_other_instance`. If another instance holds it, verify if that instance is healthy.
2. **Background tasks not running**: Use `get_lock_health()` to verify the lock is held. If not held, the instance cannot run scheduled tasks.
3. **Frequent lock steals**: If `scheduler_lock_stale_overwrite` occurs frequently, the heartbeat interval may be too long or network latency is causing false staleness detection.
---
## References
- [structlog Documentation](https://www.structlog.org/)
- [Datadog Logging Documentation](https://docs.datadoghq.com/logs/)
- [Papertrail Documentation](https://help.papertrailapp.com/)
- [Elasticsearch JSON Logging](https://www.elastic.co/guide/en/elasticsearch/reference/current/logging.html)
- [Observability Best Practices (OpenTelemetry)](https://opentelemetry.io/docs/concepts/observability-primer/)

146
Docs/PERFORMANCE.md Normal file
View File

@@ -0,0 +1,146 @@
# Performance Guidelines
Query optimization patterns for BanGUI backend services.
---
## Never Load Unbounded Result Sets
Loading large result sets into Python memory causes OOM crashes, slow responses, and unbounded growth. Every query that processes large datasets must use one of the following strategies.
### The Problem
With millions of ban records:
- Loading all rows as Python dicts → 200-400 MB+ memory spike
- Python loop aggregation (O(n) per item) → seconds of CPU time
- Offset pagination on large tables → O(n) scan before returning results
### The Solution: SQL Aggregation
SQL GROUP BY executes inside SQLite's optimized query planner, using indexes where available, and returns only the aggregated result (typically a few KB).
```python
# BAD: loads 1M rows into Python
all_rows = await get_all_archived_history(db, since=since)
agg = {}
for row in all_rows: # O(n) Python loop
agg[row["ip"]] = agg.get(row["ip"], 0) + 1
# GOOD: SQL aggregation, returns lightweight {ip, count} pairs
ip_counts = await get_ip_ban_counts(db, since=since)
# [{ip: "1.2.3.4", event_count: 42}, ...] — a few KB regardless of table size
```
### Aggregation Reference
| Use Case | SQL Pattern | Repository Function |
|----------|-------------|-------------------|
| Ban count per IP | `SELECT ip, COUNT(*) FROM history_archive ... GROUP BY ip` | `get_ip_ban_counts()` |
| Ban count per jail | `SELECT jail, COUNT(*) FROM history_archive ... GROUP BY jail ORDER BY COUNT(*) DESC` | `get_jail_ban_counts()` |
| Ban count per time bucket | `SELECT CAST((timeofban - ?) / ? AS INTEGER), COUNT(*) ... GROUP BY bucket_idx` | `get_ban_counts_by_bucket()` |
| Paginated rows (no offset) | `WHERE id < ? ORDER BY id DESC LIMIT ?` | `get_archived_history_keyset()` |
| Total count | `SELECT COUNT(*) FROM ...` (fast with where clause) | included in `get_jail_ban_counts()` return |
### Pagination vs Aggregation
Use **aggregation** when:
- Displaying summary data (counts, totals, group-by results)
- Building country/jail/timeline dashboards
- Only need counts, not individual row data
Use **pagination** when:
- Displaying individual records (ban list, history)
- Clients need access to specific rows
- Exporting or bulk operations
### Batch Geo Lookups
When you need geo data for many IPs, batch in a single call rather than per-IP:
```python
# BAD: N sequential API calls
for ip in unique_ips:
geo = await geo_service.lookup(ip) # 45 req/min rate limit × N calls
# GOOD: one batch call, geo_service handles rate limiting
geo_map, uncached = geo_cache_lookup(unique_ips) # uses in-memory cache
if uncached:
asyncio.create_task(geo_cache.lookup_batch(uncached, http_session)) # fire-and-forget
```
### Index Requirements
SQLite needs indexes on:
- Columns used in WHERE clauses (timeofban, jail, action)
- Columns used in GROUP BY (ip, jail, bucket index)
- Sort columns for pagination (id)
Current indexes on `history_archive`:
- `idx_history_archive_timeofban` — for time-range filtering
- `idx_history_archive_jail_timeofban` — for jail + time filtering
- `idx_history_archive_action_timeofban` — for action + time filtering
- `idx_history_archive_id` — for keyset pagination
Before adding a new query pattern, verify it uses an existing index or add one with a benchmark test.
### Memory Monitoring
Watch for these warning signs:
- Python RSS > 500 MB in container metrics
- Response time > 5s for dashboard endpoints
- Query time > 1s in SQLite EXPLAIN ANALYZE output
Use `EXPLAIN QUERY PLAN` to verify index usage:
```sql
EXPLAIN QUERY PLAN SELECT ip, COUNT(*) FROM history_archive WHERE timeofban >= ? GROUP BY ip;
```
Expected: `USING INDEX idx_history_archive_timeofban` in the output.
---
## Fail2ban Database Indexes
BanGUI reads from fail2ban's SQLite database (`/var/run/fail2ban/fail2ban.db`). Query performance degrades without appropriate indexes.
### Current fail2ban bans Indexes
Fail2ban creates these indexes on the `bans` table:
- `bans_jail_timeofban_ip` — composite (jail, timeofban, ip)
- `bans_jail_ip` — composite (jail, ip)
- `bans_ip` — single (ip)
**Missing**: standalone index on `timeofban` alone.
### BanGUI Automatic Index Creation
On startup, BanGUI calls `ensure_fail2ban_indexes()` to add missing indexes idempotently:
```python
# From fail2ban_db_utils.py
CREATE INDEX IF NOT EXISTS idx_bans_timeofban_desc ON bans(timeofban DESC);
```
This improves queries like:
```sql
SELECT * FROM bans WHERE timeofban >= ? ORDER BY timeofban DESC;
```
### Verifying Index Usage
Check if a query uses the index:
```sql
EXPLAIN QUERY PLAN SELECT * FROM bans WHERE timeofban >= 1700000000 ORDER BY timeofban DESC;
-- With index: SEARCH USING INDEX idx_bans_timeofban_desc
-- Without: SCAN TABLE bans
```
### Adding Indexes to Migrations
For BanGUI's own `history_archive` table, indexes go in migrations via `_ Migration.add_table_indexes()`:
```python
def _add_history_archive_indexes(m: Migration) -> None:
m.add_index("history_archive", ["timeofban"], unique=False, if_not_exists=True)
m.add_index("history_archive", ["jail", "timeofban"], unique=False, if_not_exists=True)
```

View File

@@ -3,3 +3,20 @@
This document catalogues architecture violations, code smells, and structural issues found during a full project review. Issues are grouped by category and prioritised.
---
## Security Fixes
- Fixed open redirect vulnerability in `frontend/src/pages/LoginPage.tsx` by validating the `?next=` parameter to ensure it is a relative path (starts with `/` but not `//`). The validation regex `/^\/(?!\/)/.test(next)` prevents protocol-relative URLs and external redirects. Invalid paths fall back to `"/"`.
---
## Completed Refactors
- Moved `Fail2BanConnectionError` and `Fail2BanProtocolError` from `backend/app/utils/fail2ban_client.py` into `backend/app/exceptions.py`. Updated all router, service, and test call sites to import these domain exceptions from `app.exceptions` and retained backward compatibility through re-exporting in `app.utils.fail2ban_client`.
- Moved config file exceptions (`ConfigDirError`, `ConfigFileNotFoundError`, `ConfigFileExistsError`, `ConfigFileWriteError`, `ConfigFileNameError`) from `backend/app/services/raw_config_io_service.py` into `backend/app/exceptions.py`. Updated router and tests to import the shared domain exceptions from `app.exceptions`.
- Added global domain exception handlers to `backend/app/main.py` so domain exceptions like `JailNotFoundError`, `ConfigValidationError`, and `ConfigWriteError` map consistently to 404, 400, and 500 responses.
- Fixed stale activation tracking in `backend/app/routers/jail_config.py` by recording `last_activation` only after a successful jail activation and preventing a failed activation attempt from leaving a stale runtime state record.
- Fixed infinite re-fetch loop in `frontend/src/hooks/useJailConfigs.ts` by wrapping the `onSuccess` callback in `useCallback` with empty dependencies. The bug occurred because `useListData` includes `onSuccess` in its internal `refresh` function's dependency array; an inline callback created a new reference on each render, causing `refresh` to be recreated, which triggered the `useEffect` again, leading to an unbounded fetch loop. Callers of `useListData` must always wrap `onSuccess` callbacks in `useCallback` to maintain reference stability.
- **T-11 — Repository module-as-Protocol structural type-safety:** Resolved the fragile `cast()` pattern where repository modules were loosely typed against Protocol interfaces. Created a **validation script** (`backend/scripts/validate_repository_protocols.py`) that runs at CI time to ensure all repository modules satisfy their Protocol interfaces. Fixed signature mismatches in `protocols.py` to match actual implementations in `session_repo`, `settings_repo`, `blocklist_repo`, `import_log_repo`, `geo_cache_repo`, `history_archive_repo`, and `fail2ban_db_repo` (correcting return types like `dict[str, Any]` vs `dict[str, object]`, `Sequence` vs `Iterable`, and typed models). Updated `backend/app/dependencies.py` with explicit documentation linking each repository provider to the pattern explained in Backend-Development.md § 13.7.1. **Option B (minimal):** Instead of refactoring to class-based repositories (Option A), the pattern is now formally documented and validated, preventing silent breakage.
- **T-3 — Blocklist import flow refactoring:** Extracted the monolithic `import_source()` function (776 lines with mixed responsibilities) into focused, testable components. Created `BlocklistDownloader` (HTTP download with retry logic), `BlocklistParser` (parsing and validation), `BanExecutor` (ban execution with error handling), and `BlocklistImportWorkflow` (thin orchestrator). This separation improves testability, evolution, and error handling. Each component has a single responsibility and clear boundaries. All 53 existing tests pass; added 17 new component unit tests achieving 96%+ coverage on new modules.

176
Docs/Security.md Normal file
View File

@@ -0,0 +1,176 @@
# Security — Guidelines and Implementation
Security considerations and implementation details for BanGUI.
---
## HTTP Security Headers
BanGUI implements defense-in-depth against client-side attacks by sending security-related HTTP response headers on all responses.
### Headers Implemented
| Header | Value | Purpose |
|---|---|---|
| `Content-Security-Policy` | `default-src 'self'` | Prevents XSS attacks by restricting script, style, font, image, and other resource origins to `self` only. Browsers refuse to load resources from other origins. |
| `X-Frame-Options` | `DENY` | Prevents clickjacking attacks by forbidding the page from being embedded in `<iframe>` tags on any origin. |
| `X-Content-Type-Options` | `nosniff` | Prevents MIME-type sniffing attacks by forcing browsers to respect the declared `Content-Type`. Blocks execution of misidentified scripts. |
| `X-XSS-Protection` | `1; mode=block` | Enables browser XSS filters (legacy header for older browsers). Modern browsers prioritize CSP. |
### Implementation
**Backend:** The `SecurityHeadersMiddleware` in `backend/app/main.py` adds these headers to every HTTP response, including error responses and non-API routes.
```python
response.headers["Content-Security-Policy"] = "default-src 'self'"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-XSS-Protection"] = "1; mode=block"
```
**Frontend:** The `<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />` tag in `frontend/index.html` provides an additional defense layer in case the backend headers are ever stripped (e.g., by a proxy).
### CSP Policy Details
The current policy `default-src 'self'` means:
- **Allowed:** Inline scripts, stylesheets, fonts, images, and other resources from the same origin (`self`)
- **Blocked:** Resources from external domains, inline event handlers, `eval()`, and `setTimeout(string)`
**Why no `'unsafe-inline'`?**
- `'unsafe-inline'` defeats CSP's primary purpose (XSS prevention) by allowing arbitrarily-embedded scripts
- All scripts and styles must be in separate files (never inline), which is best practice anyway
- The frontend build system (Vite) automatically handles asset bundling and file separation
**If external CDN resources are needed:**
1. Explicitly add the CDN origin to the CSP policy, e.g.: `default-src 'self' https://cdn.example.com`
2. Document the CDN addition with a justification comment
3. Ensure the CDN certificate chain is valid and trusted
4. Consider using Subresource Integrity (SRI) to verify resource authenticity
### Verification
To verify headers are being sent correctly:
1. **Chrome DevTools:**
- Open DevTools (F12)
- Go to Network tab
- Reload the page
- Click on any request and open the Response Headers section
- Look for `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `X-XSS-Protection`
2. **Command line (curl):**
```bash
curl -I http://localhost:8000/
curl -I http://localhost:5173/
```
3. **Online tools:**
- Use [securityheaders.com](https://securityheaders.com) or [csp-evaluator.withgoogle.com](https://csp-evaluator.withgoogle.com)
### Future Improvements
- **Stricter CSP:** If functionality allows, tighten to `default-src 'none'` and explicitly allow individual resources
- **SRI (Subresource Integrity):** Add integrity attributes to external script/style tags to prevent tampering
- **Preload headers:** Use `Link: <...>; rel=preload` to optimize critical resource delivery
- **HSTS:** Consider adding `Strict-Transport-Security` for production deployments to force HTTPS
---
## CSRF Protection
BanGUI protects cookie-authenticated state-mutating requests (POST, PUT, DELETE, PATCH) with a custom header check. Requests using the session cookie must include the header `X-BanGUI-Request: 1`. Bearer token authentication is exempt since tokens in headers are not CSRF-vulnerable.
### Single Source of Truth
The header name and value are defined once in `backend/app/utils/constants.py` (`CSRF_HEADER_NAME` and `CSRF_HEADER_VALUE`) and consumed by:
- `backend/app/middleware/csrf.py` — validates the header on incoming requests
- `frontend/src/api/client.ts` — attaches the header to state-mutating fetch calls
- `frontend/src/utils/constants.ts` — mirrors the values for type-safe import
### Endpoint
**`GET /api/v1/config/security-headers`** — returns the CSRF header name and value to authenticated clients:
```json
{
"csrf_header_name": "X-BanGUI-Request",
"csrf_header_value": "1"
}
```
This allows the frontend to discover the required header at runtime. Both frontend and backend constants must remain in sync — a build-time check is recommended when updating either constant.
### Header Rationale
The custom header is required because browsers block cross-site requests from setting custom headers without a CORS preflight, which BanGUI rejects for non-allowed origins.
---
## Session Security
See `backend/app/middleware/csrf.py` and `backend/app/middleware/rate_limit.py` for CSRF protection and rate limiting.
---
## Password Security
- Passwords are hashed with SHA256 on the frontend before transmission
- The backend never stores plain-text passwords
- See `backend/app/services/auth.py` for authentication implementation
- **Common password prevention:** The setup validator rejects a list of ~75 common plaintext passwords that pass structural complexity checks (e.g., `Password1!`). The list is embedded in `backend/app/models/setup.py` and is checked case-insensitively.
---
## Database Security
- The SQLite database contains no sensitive data (no passwords, API keys, or tokens stored)
- Database queries use parameterized statements to prevent SQL injection
- See `backend/app/repositories/` for data access patterns
---
## Regex (ReDoS) Protection
BanGUI validates all user-supplied regex patterns before they are compiled or stored.
### How It Works
1. **Static analysis** via [regexploit](https://github.com/doyensec/regexploit) detects catastrophic backtracking patterns before compilation
2. **Timeout enforcement** stops compilation if it exceeds 2 seconds (prevents hanging on pathological patterns)
3. **Length limit** (1000 characters) prevents memory exhaustion via bloated patterns
### Protected Endpoints
All endpoints that accept regex patterns validate them:
- Filter configuration (`prefregex`, `failregex`, `ignorregex`)
- Action configuration (any regex used in actions)
- Direct config editing
### ReDoS Pattern Examples
Patterns with nested quantifiers on overlapping text are blocked:
| Pattern | Why Blocked |
|---------|-------------|
| `(a+)+b` | Plus inside plus — exponential backtracking |
| `([a-z]+)*d` | Quantifier inside quantifier |
| `(x+)+y` | Nested quantifiers |
| `a[bcd]*e[bcd]*e` | Multiple unbounded quantifiers |
### Legitimate Complex Patterns
Not all complex patterns are blocked. Email and IP validation patterns typically pass:
```python
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" # OK
r"^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" # OK
```
### If Your Pattern Is Rejected
1. Rewrite to avoid nested quantifiers on the same text
2. Use atomic groups or possessive quantifiers: `(?>a+)+b` instead of `(a+)+b`
3. Test locally with Python's `re` module before deploying
4. If you believe the pattern is safe, check with [regexploit](https://github.com/doyensec/regexploit) directly

115
Docs/Service-Development.md Normal file
View File

@@ -0,0 +1,115 @@
# Service Development Guide
How to write and maintain services in BanGUI.
## Error Handling Contracts
Every service method must document which error handling pattern it follows.
This lets callers know what to expect without reading the implementation.
### The Three Patterns
```python
from app.services.error_handling import ABORT_ON_ERROR, RETURN_DEFAULT, PARTIAL_RESULT
```
**ABORT_ON_ERROR** — Raise an exception, let the router convert it to HTTP.
Used for: auth, writes, state changes, any operation where partial success is meaningless.
```python
async def start_jail(socket_path: str, name: str) -> None:
"""Start a stopped fail2ban jail.
Error contract: ABORT_ON_ERROR. Raises JailNotFoundError (404),
JailOperationError (409), Fail2BanConnectionError (503).
"""
...
```
**RETURN_DEFAULT** — Return empty result and log warning. Never raises.
Used for: informational reads (list, get) where infrastructure unavailability
should not block the UI.
```python
async def get_settings(socket_path: str) -> DomainServerSettingsResult:
"""Return current fail2ban server-level settings.
Error contract: RETURN_DEFAULT. Returns DomainServerSettingsResult
with default values if socket is unreachable. Never raises.
"""
...
```
**PARTIAL_RESULT** — Return (result, errors) tuple. Errors collected, not raised.
Used for: batch operations on collections where one item failing does not
invalidate the rest.
```python
# Not yet used in codebase; define as needed for batch operations.
```
### When to Use Which
| Operation type | Pattern |
|---------------|---------|
| Auth / session | ABORT_ON_ERROR |
| Write / state change | ABORT_ON_ERROR |
| Config updates | ABORT_ON_ERROR |
| Single-item read (jail, ban) | ABORT_ON_ERROR |
| Multi-item read (list) | RETURN_DEFAULT |
| Server settings read | RETURN_DEFAULT |
| Batch / parallel fetch | PARTIAL_RESULT |
### Changing Patterns
Switching a method's error contract is a **breaking change**. Update the docstring,
add a changelog entry, and bump the major version if this is a public API.
## Service Structure
Services live in `backend/app/services/`. They contain **no** HTTP/FastAPI concerns.
```
app/services/
ban_service.py # ban/unban, ban history queries
jail_service.py # jail lifecycle, ignore lists
server_service.py # server-level settings
geo_service.py # geolocation
...
error_handling.py # contract definitions
protocols.py # Protocol interfaces for DI
```
## Protocols
Each service has a corresponding protocol in `protocols.py` for dependency injection.
Protocol methods include the error contract in their docstring:
```python
class JailService(Protocol):
async def list_jails(self, socket_path: str) -> DomainJailList:
"""Error contract: ABORT_ON_ERROR."""
...
```
## Router Error Handling
Routers must not catch and silently swallow exceptions from services using
ABORT_ON_ERROR unless they convert to a specific HTTP response.
Let domain exceptions propagate — the global exception handlers handle them.
Exception handler registration (in `main.py`):
- `DomainError` → JSON error response
- `Fail2BanConnectionError` → HTTP 503
- `JailNotFoundError` → HTTP 404
## Logging
Log at the service layer using structlog:
```python
log.info("jail_started", jail=name)
log.warning("socket_unreachable_using_default", socket_path=socket_path)
```
Never log sensitive data (tokens, passwords, IPs in full).

487
Docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,487 @@
# Troubleshooting Guide
## Scheduler Lock Issues
### Lock Held by Crashed Instance (Orphaned Lock)
**Symptom:** Background tasks stop running. Logs show `scheduler_lock_held_by_other_instance` but no other instance is running.
**Diagnosis:**
```bash
sqlite3 /var/lib/bangui/bangui.db "SELECT pid, hostname, heartbeat_at FROM scheduler_lock;"
```
If `heartbeat_at` is older than 5 minutes and the PID no longer exists, the lock is orphaned.
**Recovery:**
```bash
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
```
Restart the backend. It will acquire the lock fresh.
**Prevention:**
- Monitor `scheduler_lock_heartbeat_lost` events in logs
- If >3 occurrences per hour, investigate database I/O performance
---
### Two Instances Both Running Scheduler
**Symptom:** Duplicate blocklist imports, duplicate geo cache cleanups, or duplicate history syncs.
**Cause:** Both instances believe they hold the lock.
**Diagnosis:**
1. Check which instance holds the lock: `SELECT pid, hostname FROM scheduler_lock;`
2. Compare with running processes: `ps aux | grep bangui`
**Solution:**
1. Stop one instance immediately
2. Clear lock: `DELETE FROM scheduler_lock;`
3. Restart the remaining instance
**Prevention:**
- Ensure only one instance starts before heartbeat begins
- Check `BANGUI_SINGLE_INSTANCE=true` is set if single-instance operation is required
---
### Heartbeat Update Failures
**Symptom:** Logs show `scheduler_lock_heartbeat_lost` repeatedly, then lock is lost.
**Cause:** Database writes failing or extremely slow (>5 seconds per write).
**Diagnosis:**
```bash
time sqlite3 /var/lib/bangui/bangui.db "UPDATE scheduler_lock SET heartbeat_at = unixepoch();"
```
If this takes >1 second, database I/O is degraded.
**Solution:**
1. Check disk health: `sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"`
2. Move database to faster storage (SSD)
3. Check for other I/O bottlenecks on the host
---
### Lock Not Acquired at Startup
**Symptom:** Instance fails to start with error "Could not acquire scheduler lock".
**Cause:** Another instance already holds the lock and appears healthy.
**Diagnosis:**
```bash
sqlite3 /var/lib/bangui/bangui.db "SELECT pid, hostname, heartbeat_at FROM scheduler_lock;"
ps aux | grep <pid>
```
**Solution:**
- If other instance is healthy and should run scheduler: this instance must wait
- If other instance is crashed: `DELETE FROM scheduler_lock;` then restart this instance
- If running single instance: ensure no other instances are running before startup
---
## Rate Limiting
### Getting 429 Too Many Requests
**Symptom:** API returns HTTP 429 with `rate_limit_exceeded` error code.
**Cause:** You have exceeded the per-IP rate limit for a specific operation.
**Diagnosis:**
1. Check the `Retry-After` header in the response — this tells you how many seconds to wait
2. Look for the log event `*_rate_limit_exceeded` which shows the bucket and client IP
**Rate limit buckets:**
| Bucket | Limit | Window | Operations |
|--------|-------|--------|------------|
| `bans:ban` | 100 | 1 minute | Ban IP addresses |
| `bans:unban` | 100 | 1 minute | Unban IP addresses |
| `blocklist:import` | 10 | 1 hour | Import blocklists |
| `config:update` | 50 | 1 minute | Update configuration |
| `jail:update` | 100 | 1 minute | Update jail config |
| `jail:create` | 100 | 1 minute | Add log paths, assign filters/actions |
| `jail:delete` | 100 | 1 minute | Remove log paths, actions |
| `jail:activate` | 100 | 1 minute | Activate jails |
| `jail:deactivate` | 100 | 1 minute | Deactivate jails |
| `filter:update` | 50 | 1 minute | Update filters |
| `filter:create` | 50 | 1 minute | Create filters |
| `filter:delete` | 50 | 1 minute | Delete filters |
| `action:update` | 50 | 1 minute | Update actions |
| `action:create` | 50 | 1 minute | Create actions |
| `action:delete` | 50 | 1 minute | Delete actions |
**Solution:**
1. Wait for the `Retry-After` period before retrying
2. If you hit the limit during legitimate bulk operations, consider batching requests
3. For blocklist imports (10/hour), ensure automated imports are not more frequent
**Prevention:**
- Monitor `*_rate_limit_exceeded` log events
- Adjust limits via environment variables if needed (see `Docs/CONFIGURATION.md`)
- For bulk operations, implement client-side throttling
**Note:** If rate limiting triggers unexpectedly for legitimate use, check for:
- Internal monitoring scripts hitting endpoints too frequently
- Multiple users behind the same proxy IP
- Stale rate limit state after process restart (uses in-memory tracking)
---
## Database Migration Failures
### Application Won't Start After Upgrade
**Symptom:** Application fails to start. Logs show migration errors.
**Cause:** Migration failed mid-transaction. Database left in inconsistent state.
**Diagnosis:**
```bash
# Check current schema version
sqlite3 /var/lib/bangui/bangui.db "SELECT MAX(version) FROM schema_migrations;"
# List all tables
sqlite3 /var/lib/bangui/bangui.db "SELECT name FROM sqlite_master WHERE type='table';"
# Check logs for specific error
grep -i migration /var/log/bangui.log
```
**Solution:**
1. **If migration was auto-rolled back**: Startup will retry the same migration. Run application again.
2. **If migration keeps failing**: Check if table already exists:
```bash
sqlite3 /var/lib/bangui/bangui.db "SELECT name FROM sqlite_master WHERE type='table' AND name='<table>';"
```
If it exists, manually insert the migration record:
```bash
sqlite3 /var/lib/bangui/bangui.db "INSERT INTO schema_migrations (version) VALUES (?);"
```
3. **Full database reset** (development only):
```bash
rm /var/lib/bangui/bangui.db /var/lib/bangui/bangui.db-wal /var/lib/bangui/bangui.db-shm
```
**Prevention:**
- Always backup before upgrades: `cp bangui.db bangui.db.backup`
- Never manually modify database schema
- Monitor `migrating_database_schema` log events during upgrades
---
### Schema Version Mismatch
**Symptom:** Error: "database schema version X is newer than supported version Y"
**Cause:** Downgraded to older BanGUI version that doesn't support current schema.
**Solution:** Upgrade to a version compatible with the current schema, or restore from backup.
---
## 502 Bad Gateway Errors
### Symptom: Nginx returns 502 Bad Gateway
**Cause:** The backend container is unreachable — either down, restarting, or not yet healthy.
**Diagnosis:**
```bash
# Check backend container status
docker ps -a | grep bangui-backend
# Check if backend is responding directly (on the container network)
docker exec bangui-frontend curl -f http://bangui-backend:8000/api/v1/health
# Check backend logs
docker logs bangui-backend --tail 50
```
**Common causes and solutions:**
| Cause | Diagnosis | Solution |
|---|---|---|
| Backend restarting | `docker ps` shows backend repeatedly restarting | Check health check timing; may need longer `start_period` |
| Health check failing | Backend log shows socket errors | Verify fail2ban container is healthy before backend starts |
| Startup too slow | `start_period: 40s` not enough on slow hosts | Increase `start_period` in compose file |
| Port misconfiguration | `expose` vs `ports` mismatch | Ensure backend exposes 8000 and frontend proxies to it |
**Prevention:**
- The `depends_on: condition: service_healthy` ensures the backend is fully started before the frontend proxies requests.
- The health check returns 503 when fail2ban is offline, triggering container restart automatically.
- Health check parameters are tuned for typical startup time — adjust `start_period` if the host is slow or resource-constrained.
---
## Graceful Shutdown Issues
### Container Killed Before Tasks Complete
**Symptom:** Logs show `pending_tasks_timeout` and tasks are cancelled mid-execution.
**Cause:** Docker's `stop_grace_period` is too short, or tasks take longer than the 25s graceful timeout.
**Diagnosis:**
```bash
# Check if container was killed by SIGKILL
docker inspect bangui-backend --format '{{.State.ExitCode}}'
# Exit code 137 = SIGKILL
```
**Solution:**
1. Increase `stop_grace_period` in `docker-compose.yml`:
```yaml
backend:
stop_grace_period: 60s
```
2. The Python graceful timeout is 25s (leaving margin before Docker kill)
3. If tasks still timeout, check task code — long-running tasks should handle cancellation gracefully
### Scheduler Lock Not Released
**Symptom:** After container restart, logs show `Could not acquire scheduler lock`.
**Cause:** Previous instance shut down without releasing the lock, or lock TTL hasn't expired.
**Diagnosis:**
```bash
sqlite3 /var/lib/bangui/bangui.db "SELECT * FROM scheduler_lock;"
```
**Solution:**
```bash
# Clear stale lock
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
# Restart container
```
**Prevention:**
- Graceful shutdown releases lock immediately (not waiting for TTL expiry)
- Monitor logs for `scheduler_lock_released` on clean shutdown
### In-Flight Requests Dropped
**Symptom:** Client connections closed abruptly during shutdown.
**Cause:** Too short a graceful timeout, or clients not configured to retry.
**Solution:**
1. Ensure clients implement proper retry logic with backoff
2. For critical operations, use background tasks with status polling
3. Increase graceful timeout if network latency is high
---
## General Recovery Commands
Clear all locks:
```bash
sqlite3 /var/lib/bangui/bangui.db "DELETE FROM scheduler_lock;"
```
Check lock status:
```bash
sqlite3 /var/lib/bangui/bangui.db "SELECT * FROM scheduler_lock;"
```
Verify database integrity:
```bash
sqlite3 /var/lib/bangui/bangui.db "PRAGMA integrity_check;"
```
---
## Regex Pattern Rejected
### Symptom: Filter or action configuration fails with "Invalid regex" error
**Cause:** The regex pattern is either syntactically invalid or detected as a ReDoS (Regular Expression Denial of Service) vulnerability.
**Diagnosis:**
1. Check the error message — it indicates whether the pattern is syntactically invalid or flagged as dangerous
2. Look for log events: `regex_redos_detected` or `regex_compilation_timeout`
**Common ReDoS patterns that are rejected:**
| Pattern | Problem |
|---------|---------|
| `(a+)+b` | Nested quantifiers with overlap |
| `([a-z]+)*d` | Quantifier inside quantifier |
| `(x+)+y` | Nested plus operators |
**Solution:**
1. Rewrite the pattern to avoid nested quantifiers on overlapping groups
2. Use atomic groups or possessive quantifiers where possible: `(?>a+)+b`
3. Simplify complex alternations
**Prevention:**
- Test regex patterns in isolation before deploying
- Avoid patterns with quantified groups inside other quantifiers
- Prefer explicit character classes over `.*` where possible
- Use [regexploit](https://github.com/doyensec/regexploit) to audit patterns
---
## Configuration Validation at Startup
BanGUI validates configuration at startup. Errors raised here indicate misconfiguration that must be fixed before the application can start.
### Database Parent Directory Does Not Exist
**Symptom:** Application fails to start with: `Database parent directory does not exist: /path/to/parent`
**Cause:** The parent directory of `BANGUI_DATABASE_PATH` does not exist.
**Solution:**
```bash
mkdir -p /path/to/parent
# Then restart BanGUI
```
---
### Database Parent Directory Not Writable
**Symptom:** Application fails to start with: `Database parent directory not writable: /path/to/parent`
**Cause:** The process cannot write to the database parent directory.
**Solution:**
```bash
chmod 755 /path/to/parent
# Verify the user running BanGUI owns the directory or has write access
```
---
### fail2ban Socket Not Readable
**Symptom:** Application fails to start with: `fail2ban socket not readable: /path/to/socket`
**Cause:** The socket file exists but is not readable by the BanGUI process.
**Solution:**
```bash
chmod 644 /path/to/socket
ls -la /path/to/socket
```
---
### fail2ban Config Directory Does Not Exist
**Symptom:** Application fails to start with: `fail2ban config directory does not exist: /path/to/config`
**Cause:** `BANGUI_FAIL2BAN_CONFIG_DIR` points to a directory that does not exist.
**Solution:**
- Mount the fail2ban configuration directory at the expected path
- Or adjust `BANGUI_FAIL2BAN_CONFIG_DIR` to point to the correct location
- In Docker: add a volume mount for the fail2ban config directory
---
### GeoIP Database File Does Not Exist
**Symptom:** Application fails to start with: `GeoIP database file does not exist: /path/to/GeoLite2-Country.mmdb`
**Cause:** `BANGUI_GEOIP_DB_PATH` points to a file that does not exist.
**Solution:**
1. Download the MaxMind GeoLite2-Country database from https://dev.maxmind.com/geoip/geolite2-country
2. Place it at the configured path, or update `BANGUI_GEOIP_DB_PATH` to the correct location
3. Alternatively, set `BANGUI_GEOIP_DB_PATH` to `null` to disable GeoIP lookups
---
### session_secret Too Short or Weak
**Symptom:** Application fails to start with: `session_secret must be at least 32 characters` or `session_secret is too weak`
**Cause:** `BANGUI_SESSION_SECRET` is missing, too short, or contains common weak words.
**Solution:**
```bash
# Generate a new secret
python -c "import secrets; print(secrets.token_hex(32))"
```
Then set it in your `.env` file or environment variables.
---
## Enabling Debug Logs for Third-Party Libraries
BanGUI suppresses verbose DEBUG logs from APScheduler and aiosqlite by default (see `Docs/Observability.md`). When troubleshooting scheduler or database issues, you can temporarily re-enable these logs.
### Quick method (environment variable)
Set `BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false` and ensure `BANGUI_LOG_LEVEL=debug`:
```bash
BANGUI_SUPPRESS_THIRD_PARTY_LOGS=false \
BANGUI_LOG_LEVEL=debug \
python -m uvicorn app.main:create_app
```
This allows APScheduler and aiosqlite to inherit the application log level without editing code.
### Code method (for permanent changes)
If you need to change the level for a specific library only, edit `backend/app/main.py` inside `_configure_logging()`:
```python
logging.getLogger("apscheduler").setLevel(logging.DEBUG)
```
Restart the application. You will see scheduler polling messages such as:
- `Looking for jobs to run`
- `Next wakeup is due at ...`
- `Running job ...`
### Reverting
Remove the environment variable or code change and restart. When suppression is re-enabled, the loggers return to `WARNING` level.
---
## Plain Text Logs Still Appearing
If `bangui.log` contains plain text lines that are not JSON, a library is bypassing structlog's `ProcessorFormatter`.
**Diagnosis:**
1. Identify the logger name in the plain text line (usually at the start of the line).
2. Check whether the logger is listed in `backend/app/main.py::_configure_logging()` under the third-party overrides.
3. Verify that `structlog.stdlib.ProcessorFormatter` is attached to all handlers:
```python
for handler in handlers:
handler.setFormatter(formatter)
```
**Common causes:**
| Cause | Fix |
|-------|-----|
| Library initializes its own handler after startup | Add `logging.getLogger("library_name").setLevel(logging.WARNING)` in `_configure_logging()`. |
| Custom handler added outside `_configure_logging()` | Ensure all handlers use `structlog.stdlib.ProcessorFormatter`. |
| Log emitted before `_configure_logging()` is called | Move logging configuration earlier in the lifespan or app factory. |
---
## Getting Help
If issues persist after following this guide:
1. Enable debug logging: `BANGUI_LOG_LEVEL=debug`
2. Collect logs around the failure time
3. Check `Docs/Deployment.md` for configuration guidance
4. Check `Docs/Observability.md` for monitoring setup

145
Docs/TYPE_SAFETY.md Normal file
View File

@@ -0,0 +1,145 @@
# Type Safety Between Frontend and Backend
This document describes how BanGUI maintains type alignment between the TypeScript frontend and Python backend, and the constraints that keep runtime type mismatches from occurring.
---
## 1. The Problem
Frontend TypeScript types and backend Pydantic models are defined independently. Drift between them causes runtime errors:
- **Empty string vs. null** — `country_code: string | null` in TypeScript but backend returns `""` for unresolved geo lookups. Frontend truthiness check `if (ban.country_code)` passes for `""` but the value is meaningless.
- **Timestamp ambiguity** — Frontend expects ISO 8601 strings; backend was passing mixed UNIX integers in some paths.
- **Silent zero values** — `0` can be indistinguishable from "not set" in weakly-typed paths.
---
## 2. Shared Type Conventions
All JSON field names use `snake_case` in both backend (Python/Pydantic) and frontend (TypeScript). No alias generators are applied.
| Python type | TypeScript type | Notes |
|---|---|---|
| `str` | `string` | |
| `str \| None` | `string \| null` | Null-capable strings use explicit null |
| `int` | `number` | |
| `bool` | `boolean` | |
| `list[T]` | `T[]` | |
| `dict[str, T]` | `Record<string, T>` | |
### Country Code Constraint
`country_code` is always `string | null`. An empty string `""` is **never** a valid value. The backend normalises empty strings to `None` at the Pydantic validator level so the frontend always receives either a valid 2-char uppercase code or `null`.
---
## 3. Backend Validation Layer
Every Pydantic response model that includes a country code has a `field_validator` that coerces empty strings to `None`:
```python
@field_validator("country_code")
@classmethod
def _normalize_empty_country_code(cls, v: str | None) -> str | None:
if v == "":
return None
return v
```
Models affected:
- `DashboardBanItem``country_code`
- `ActiveBan``country`
- `Ban``country`
The same pattern should be applied to any new field that could arrive as `""` from the geo enrichment layer.
### Why the Validator Lives in the Model
Validation at the Pydantic model layer means:
- It fires on **every** API response, regardless of which router endpoint produced it.
- The mapper layer cannot accidentally skip normalisation.
- Serialisation from domain → response model is automatically safe.
---
## 4. Timestamp Standard
All ban timestamps are transmitted as **ISO 8601 UTC strings** (`"2026-04-28T07:00:00+00:00"`). UNIX integers are used internally in repositories but converted to ISO strings using `ts_to_iso()` before entering the response model:
```python
from datetime import UTC, datetime
def ts_to_iso(unix_ts: int) -> str:
return datetime.fromtimestamp(unix_ts, tz=UTC).isoformat()
```
This conversion happens once — in the service layer when building `DomainDashboardBanItem` and similar domain objects — so all response models receive pre-formatted strings.
---
## 5. Frontend Type Narrowing Rules
When consuming `country_code` in TypeScript, use explicit null checks rather than truthiness:
```typescript
// BAD — empty string passes this check
if (ban.country_code) { ... }
// GOOD — only null/undefined are falsy
if (ban.country_code !== null) { ... }
```
The backend normalisation ensures that `!ban.country_code` and `ban.country_code === null` are equivalent, but the explicit form is clearer and defensive against future changes.
---
## 6. CI Type Synchronisation
A type-generation script (planned) will emit a combined JSON schema from all Pydantic models and validate the generated TypeScript types against it on every build. Until that is in place:
- Any change to a Pydantic model field type must be mirrored in the corresponding TypeScript interface in `frontend/src/types/ban.ts`.
- Run `pytest tests/test_models.py` to verify model-level validation after changing `ban.py`.
---
## 7. Adding New Shared Types
When adding a new response model to `backend/app/models/`:
1. Define the Pydantic model with explicit `str | None` (not `Optional[str]`) for nullable strings.
2. Add `field_validator` stubs for any field that could receive an empty string from a database or external API.
3. Add the corresponding TypeScript interface in `frontend/src/types/`.
4. Add model-level unit tests in `tests/test_models.py`.
5. Run the full test suite before committing.
---
## 8. TypedDict for Error Metadata
Error response metadata uses `ErrorMetadata` (a `TypedDict` with `total=False`) instead of generic `dict[str, str | int | float | bool | None]`. This enables type-safe field access in exception handlers and type checkers can verify correct field usage.
```python
# BAD — generic dict, no type narrowing
def get_error_metadata(self) -> dict[str, str | int | float | bool | None]:
return {"jail_name": self.name}
# GOOD — TypedDict, type checker knows exact fields
def get_error_metadata(self) -> ErrorMetadata:
return {"jail_name": self.name}
```
When accessing error metadata in exception handlers, the type checker can now verify which keys are present:
```python
metadata = exc.get_error_metadata()
jail_name = metadata["jail_name"] # type checker verifies "jail_name" exists
```
`ErrorMetadata` is defined in `backend/app/models/response.py` and imported via `TYPE_CHECKING` blocks in `exceptions.py` and `main.py` to avoid circular dependencies at runtime.
## 9. Related Documents
- [Architekture.md](Architekture.md) — system architecture and data flow
- [Backend-Development.md](Backend-Development.md) — Python coding conventions, Pydantic usage
- [Web-Development.md](../frontend/Docs/Web-Development.md) — TypeScript conventions

View File

@@ -1,73 +0,0 @@
# BanGUI — Task List
This document breaks the entire BanGUI project into development stages, ordered so that each stage builds on the previous one. Every task is described in prose with enough detail for a developer to begin work. References point to the relevant documentation.
Reference: `Docs/Refactoring.md` for full analysis of each issue.
---
## Open Issues
---
### TASK-001 — WorldMap: filter companion table by selected country (server-side)
**Status:** Done
**Priority:** Medium
**Domain:** Full-stack (backend + frontend)
**References:** `Docs/Features.md §4`, `Docs/Web-Development.md`
#### Background
The `GET /api/dashboard/bans/by-country` endpoint always returns the **200 most recent** ban rows in `bans` (constant `_MAX_COMPANION_BANS = 200` in `backend/app/services/ban_service.py`). `MapPage.tsx` stores a `selectedCountry` state and filters the returned rows client-side via `visibleBans`. This means the companion table can only show the fraction of a country's bans that fall within the global top-200 window. If the selected time range has, say, 1 500 bans and 300 are from China, but China's bans are not all in the top 200 overall, the table will silently display fewer than 300 rows.
When a country is selected the companion table **must** return the complete set of bans for that country so the user sees an accurate picture.
#### Desired behaviour
- No country selected → companion table shows the 200 most recent bans across all countries (existing behaviour, no change).
- Country selected → the server returns **all** ban entries for that country in the selected time window; no client-side row-count cap applies.
- Deselecting a country (clicking the same country again, or the "Clear filter" button) reverts to the default 200-row unfiltered view.
- The existing `visibleBans` client-side filter in `MapPage.tsx` can remain as a defensive guard but must not be the only filter.
#### Implementation steps
1. **Backend — router** (`backend/app/routers/dashboard.py`)
- Add `country_code: str | None = Query(default=None, description="ISO alpha-2 country code to filter companion rows.")` to `get_bans_by_country`.
- Pass it to `ban_service.bans_by_country(..., country_code=country_code)`.
2. **Backend — service** (`backend/app/services/ban_service.py`)
- Add `country_code: str | None = None` keyword argument to `bans_by_country`.
- After `geo_map` is built (existing geo-resolution step), collect IPs whose resolved country matches `country_code`.
- For the **fail2ban source**: call `fail2ban_db_repo.get_currently_banned` with `ip_filter=matched_ips` and no `limit` (remove the `_MAX_COMPANION_BANS` cap for filtered queries).
- For the **archive source**: filter `all_rows` to those whose IP is in `matched_ips` and return all of them (skip the `page_size=_MAX_COMPANION_BANS` call).
- When `country_code` is `None`, behaviour is identical to today.
3. **Backend — repository** (`backend/app/repositories/fail2ban_db_repo.py`)
- Add `ip_filter: list[str] | None = None` to `get_currently_banned`.
- When provided and non-empty, append `AND ip IN ({placeholders})` to the SQL `WHERE` clause, parameterised safely (never interpolated as a string).
4. **Backend — repository (archive)** (`backend/app/repositories/history_archive_repo.py`)
- Similarly add optional `ip_filter` to the archive companion-rows query used from `bans_by_country`.
5. **Frontend — API client** (`frontend/src/api/map.ts`)
- Add optional `countryCode?: string` parameter to `fetchBansByCountry`.
- When set, append `country_code=<value>` to the query string.
6. **Frontend — hook** (`frontend/src/hooks/useMapData.ts`)
- Add `countryCode?: string` to the function signature.
- Include it in the `useCallback` dependency array and pass it to `fetchBansByCountry`.
7. **Frontend — page** (`frontend/src/pages/MapPage.tsx`)
- Pass `selectedCountry ?? undefined` as `countryCode` to `useMapData`.
- The hook's effect will re-fetch automatically when `selectedCountry` changes; the existing `useEffect` that resets `page` to 1 already covers this.
#### Testing guidance
- Select a country that has > 200 bans in the chosen time window; confirm the companion table shows more than the previous cap would allow.
- With no country selected, confirm only 200 rows are returned (no regression).
- Deselect the country; confirm the unfiltered 200-row view is restored.
- Test with the archive source as well as the fail2ban live source.
- Verify the `ip_filter` SQL clause is parameterised and cannot be injected.
---

View File

@@ -0,0 +1,118 @@
# Testing Requirements
## Coverage Threshold
- **Minimum: 80% line coverage** for all backend code
- Critical paths (auth, banning, scheduling, API endpoints): **100%**
## CI Enforcement
`.github/workflows/ci.yml` runs pytest with `--cov-fail-under=80`. Build fails if coverage drops below threshold.
## Running Tests Locally
```bash
cd backend
pytest --cov=app --cov-report=term-missing
```
## Coverage Reports
- Terminal: `--cov-report=term-missing`
- HTML: `--cov-report=html` (output in `htmlcov/`)
## Coverage Badge
Add to README once CI runs successfully:
```md
[![Coverage](https://codecov.io/gh/<owner>/BanGUI/branch/main/graph/badge.svg)](https://codecov.io/gh/<owner>/BanGUI)
```
Requires codecov.io integration with repository.
## Writing Tests
- Follow pattern: `test_<unit>_<scenario>_<expected>`
- Mock external dependencies (fail2ban socket, aiohttp calls)
- Test happy path AND error/edge cases
- See `Docs/Backend-Development.md §9` for detailed testing guide
## E2E Testing
An end-to-end test suite using **Robot Framework** with the Browser library (Playwright-backed) exercises the full running stack: frontend → backend → fail2ban → database.
### Running E2E Tests
```bash
make e2e
```
Requires:
- `BANGUI_SESSION_SECRET` env var must be set (see [Backend-Development.md](Backend-Development.md) for setup)
- Stack must be startable via `make up` (Docker/Podman + compose installed)
- `rfbrowser init` is run automatically by the `e2e` target (Playwright browsers downloaded on first run; re-run after `robotframework-browser` version changes)
### HTML Report
After a run, open `e2e/results/report.html` in a browser to view the detailed HTML report with screenshots on failure.
### Writing New E2E Tests
Place new `.robot` files in `e2e/tests/`. Use `e2e/resources/common.resource` for shared variables and setup/teardown, and `e2e/resources/auth.resource` for the `Login As Admin` keyword.
### E2E-3 — Ban Pipeline Timing
Test **E2E-3** (`e2e/tests/02_ban_records.robot`: *Simulated Failed Logins Appear As Ban Records*) exercises the full ban pipeline:
```
simulate_failed_logins.sh → fail2ban log scan → ban recorded in fail2ban DB
→ backend polls socket (on-demand, no push) → /api/bans/active
→ history_sync archive (every 300 s) → /api/history
```
Key timing facts:
- **fail2ban** (`manual-Jail`, `backend=polling`) re-reads `auth.log` on its own interval, not event-driven.
- **maxretry=3** means a ban triggers after the 3rd matching line. `simulate_failed_logins.sh` writes 5 lines to ensure the threshold is crossed.
- **15 s sleep** in the test gives fail2ban time to detect and record the ban before the first assertion. This is a heuristic — the actual polling interval depends on fail2ban's internal cycle.
- **history_sync** runs every 300 s (`HISTORY_SYNC_INTERVAL` in `backend/app/tasks/history_sync.py`). The History page reads from the archive DB, so it may lag up to 300 s behind real-time. The E2E test uses `GET /api/bans/active` (direct socket query) for the API assertion to avoid this lag.
- **Pagination**: the History page paginates results. Use `?page_size=500` to push the test IP onto the first page, or assert via the API.
If the test fails at Step 2 (no ban detected via API) but `check_ban_status.sh` shows the IP is banned inside the container, the backend-to-fail2ban socket path is broken. If `check_ban_status.sh` also shows no ban, the log volume mapping is wrong (fail2ban is not reading the file `simulate_failed_logins.sh` writes to).
### E2E-4 — Blocklist Import
Test **E2E-4** (`e2e/tests/03_blocklist_import.robot`: *Manual Blocklist Import Completes Without Error*) exercises the full import pipeline:
```
UI button click → POST /api/v1/blocklists/import → async background task
→ DNS validation → HTTP fetch (external or local mock)
→ IP parsing → fail2ban ban_ip call → DB write → import log entry
```
Key facts:
- **Rate limit**: `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR = 10` per client IP. E2E tests bypass this by sending a unique `X-Forwarded-For` header (e.g., `10.0.0.99`). The header is only honoured when the client IP is in `BANGUI_TRUSTED_PROXIES`.
- **Network dependency**: The import fetches the blocklist URL over HTTP. In CI environments without internet access the test starts a local Python `http.server` (port 8765) serving `e2e/test_blocklist.txt`. The `Ensure Blocklist Source Exists` keyword points the source URL at `http://localhost:8765/test.txt` when no internet is detected.
- **"Import ran" vs "bans added"**: These are separate outcomes. The test asserts that the log entry count increases — confirming the import ran to completion — regardless of whether any IPs were actually banned.
- **Timeout**: Large lists may exceed the 45 s button-wait timeout. Increase as needed.
- **Selector**: The import button is selected via `css=[data-testid="blocklist-import-button"],button`. The `data-testid` attribute must be added to the frontend component (see [E2E-6] in [Tasks.md](Tasks.md)). If the attribute is absent, the fallback `button` selector is used.
**Teardown**: `Cleanup Mock Server` stops the local HTTP server started in the test.
### E2E-5 — Config Field Edit Persistence
Test **E2E-5** (`e2e/tests/04_config_edit.robot`: *Config Field Edit Persists After Reload*) exercises the auto-save round-trip:
```
UI edit → useAutoSave debounce (500 ms) → PATCH /api/config/jails/:name
→ fail2ban config write → GET /api/jails rehydration on reload
```
Key facts:
- **Debounce**: `useAutoSave` fires no HTTP request until 500 ms of inactivity after the last keystroke. The test waits for the "Saved" indicator (`[role="status"]:has-text("Saved")`) rather than a fixed `Sleep`, ensuring the PATCH actually fired before the reload.
- **Selector**: `input[aria-label="Ban Time"]` is used to locate the bantime field — no `data-*` attribute required. The `aria-label` is stable across refactors.
- **Teardown**: `Restore Original Ban Time` is set as `[Teardown]` so it runs even when the test fails mid-way. Config edits restart fail2ban internally; restoring state prevents subsequent tests from reading modified values.
- **Run order**: E2E-5 should run last in the suite to avoid destabilising fail2ban health for other tests.

View File

@@ -41,6 +41,7 @@ BanGUI uses a **single custom theme** generated with the [Fluent UI Theme Design
- The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements.
- Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ.
- Persist the user's explicit theme choice in `localStorage` and otherwise follow the operating system's `prefers-color-scheme` setting.
- Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly.
### Colour Rules
@@ -234,12 +235,86 @@ Use Fluent UI React components as the building blocks. The following mapping sho
| Success messages | `MessageBar` (success) | "IP 1.2.3.4 has been banned in jail sshd." |
| Error messages | `MessageBar` (error) | "Failed to connect to fail2ban server." |
| Warning messages | `MessageBar` (warning) | "Blocklist import encountered 12 invalid entries." |
| Loading states | `Shimmer` | Apply to `DetailsList` rows and stat cards while data loads. |
| Loading states | `SkeletonTable` / `SkeletonChart` (preferred) or `Shimmer` (legacy) | Show skeleton placeholders matching layout. Use `PageLoadingSkeleton` for page-level loading. |
| Empty states | Custom illustration + text | "No bans recorded in the last 24 hours." Centre on the content area. |
| Tooltips | `Tooltip` / `TooltipHost` | Full IP info on hover, full regex on truncated text, icon-only button labels. |
---
## 8a. Loading States & Skeleton Components
Progressive loading states improve perceived responsiveness and reduce cognitive load during data fetches.
### When to Use Skeleton Placeholders
Use skeleton loading states instead of spinners for regions with a fixed, known layout:
- **Tables** — Show skeleton rows that match the actual column layout and row height
- **Charts** — Show skeleton bars matching the chart dimensions
- **Stat cards** — Show skeleton badges matching actual stat dimensions
- **Forms** — Show skeleton fields matching input dimensions
Use full-page spinners only when:
- Loading time is expected to be under 1 second
- Content layout is unknown or highly variable
- Entire page structure is being loaded
### Skeleton Components
BanGUI provides pre-built skeleton components in `src/components/skeletons/`:
| Component | Usage | Notes |
|---|---|---|
| `<SkeletonTable>` | Table/grid loading | Pass `rowCount` and `cellCount` to match real layout. |
| `<SkeletonTableRow>` | Individual table rows | Renders a single animated skeleton row. |
| `<SkeletonChart>` | Chart/graph loading | Pass `barCount` and `height` to match container dimensions. |
| `<SkeletonStat>` | Stat badge loading | Shows label + value stacked. Pass `showLabel={false}` to hide label. |
| `<SkeletonFormField>` | Form input loading | Shows label + input placeholder. Pass `inputHeight` for custom sizing. |
| `<PageLoadingSkeleton>` | Page-level loading | Convenience wrapper. Pass `type="table"` or `type="chart"`. |
### Skeleton Implementation Details
- **Dimensions:** Skeletons must exactly match real content dimensions (height, width, spacing) to prevent layout shift when content arrives.
- **Animation:** All skeletons use a subtle 2-second pulse animation (`skeleton-pulse` keyframe). The animation respects `prefers-reduced-motion`.
- **Accessibility:** Skeleton elements are marked `aria-hidden="true"` and `role="presentation"` — they are not part of the accessible tree.
- **Theming:** Skeleton colour uses `colorNeutralBackground1Hover` token for automatic light/dark mode support.
### Usage Examples
```tsx
// Loading table data
if (loading) {
return <SkeletonTable rowCount={10} cellCount={6} />;
}
// Loading chart
if (chartLoading) {
return <PageLoadingSkeleton type="chart" itemCount={12} chartHeight={220} />;
}
// Loading multiple stats
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)" }}>
{[1, 2, 3].map(i => <SkeletonStat key={i} />)}
</div>
// Loading form
<div style={{ gap: "16px", display: "flex", flexDirection: "column" }}>
<SkeletonFormField />
<SkeletonFormField />
<SkeletonFormField showLabel={false} />
</div>
```
### Avoiding Layout Shift
**Critical:** Skeleton components must reserve the exact space that real content will occupy. Test by:
1. Load skeleton while data is fetching
2. Observe the skeleton render to full height/width
3. Observe real content arriving — no movement or repaint of surrounding elements
If content shifts when it arrives, adjust skeleton dimensions or parent container constraints.
---
## 9. Tables & Data Grids
Tables are the primary UI element in BanGUI. They must be treated with extreme care.
@@ -386,7 +461,7 @@ export const sideNavCollapsedWidth = 48;
| Reference theme tokens for all colours | Hard-code hex values like `#ff0000` in components |
| Follow the 4 px spacing grid | Use arbitrary pixel values (13 px, 7 px, 19 px) |
| Provide a tooltip for every icon-only button | Leave icons unlabelled and inaccessible |
| Use `Shimmer` for loading states | Show a blank screen or a standalone spinner with no context |
| Use skeleton placeholders for loading states | Show a blank screen or a standalone spinner with no context |
| Design for both light and dark themes | Default to white backgrounds assuming light mode only |
| Use `DetailsList` for all tabular data | Use raw HTML `<table>` elements or a third-party data grid |
| Use semantic colour slots (`errorText`, `bodyBackground`) | Use descriptive palette slots (`red`, `neutralLight`) directly |

File diff suppressed because it is too large Load Diff

154
Docs/runner.csx Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env dotnet-script
#nullable enable
using System;
using System.IO;
using System.Diagnostics;
using System.Threading;
using System.Text;
using System.Text.RegularExpressions;
using System.Linq;
using System.Collections.Generic;
// ── Ctrl+C: kill active process and exit cleanly ──────────────────────────────
var cts = new CancellationTokenSource();
Process? activeProcess = null;
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Console.WriteLine("\n[runner] Interrupted — shutting down...");
cts.Cancel();
try { activeProcess?.Kill(entireProcessTree: true); } catch { }
};
// ── Paths ─────────────────────────────────────────────────────────────────────
var repoRoot = Directory.GetCurrentDirectory();
var tasksFile = Path.Combine(repoRoot, "Docs", "Tasks.md");
if (!File.Exists(tasksFile))
{
Console.Error.WriteLine($"[runner] ERROR: Tasks.md not found at {tasksFile}");
Console.Error.WriteLine("[runner] Run this script from the repository root.");
Environment.Exit(1);
}
// ── Read & split by "---" separator lines ────────────────────────────────────
var content = File.ReadAllText(tasksFile);
var items = Regex
.Split(content, @"\r?\n---\r?\n")
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToList();
Console.WriteLine($"[runner] Found {items.Count} section(s) in Tasks.md");
// ── Helper: run copilot and stream output, return full output ─────────────────
async Task<string> RunCopilot(IEnumerable<string> extraArgs, string prompt)
{
var output = new StringBuilder();
var argList = new List<string> { "launch", "copilot", "--model", "minimax-m2.7:cloud", "--yes", "--", "--allow-all-tools" };
argList.AddRange(extraArgs);
argList.Add("-p");
argList.Add(prompt);
var psi = new ProcessStartInfo("ollama")
{
WorkingDirectory = repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argList)
psi.ArgumentList.Add(a);
activeProcess = new Process { StartInfo = psi };
activeProcess.OutputDataReceived += (_, e) =>
{
if (e.Data is null) return;
Console.WriteLine(e.Data);
output.AppendLine(e.Data);
};
activeProcess.ErrorDataReceived += (_, e) =>
{
if (e.Data is null) return;
Console.Error.WriteLine(e.Data);
output.AppendLine(e.Data);
};
activeProcess.Start();
activeProcess.BeginOutputReadLine();
activeProcess.BeginErrorReadLine();
await activeProcess.WaitForExitAsync(cts.Token);
activeProcess = null;
return output.ToString();
}
// ── Main loop ─────────────────────────────────────────────────────────────────
for (int i = 0; i < items.Count; i++)
{
var item = items[i];
if (cts.IsCancellationRequested) break;
Console.WriteLine();
Console.WriteLine("[runner] ══════════════════════════════════════════════");
Console.WriteLine($"[runner] Task:\n{item}");
Console.WriteLine("[runner] ══════════════════════════════════════════════");
Console.WriteLine();
// Step 1 — run the task prompt
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
await RunCopilot(new[] { "--continue" }, $"read ./Docs/Instructions.md. fix the following test and only that one. Keep in mind that i did many refactorings and test may is obsolet or need to be changed. {item}");
if (cts.IsCancellationRequested) break;
// Step 2 — confirm completion in the same chat session
Console.WriteLine("\n[runner] Asking for task confirmation...\n");
var confirmation = await RunCopilot(
new[] { "--continue" },
"are you sure tasks is done. reply with yes"
);
if (cts.IsCancellationRequested) break;
// Step 3 — check for "yes" in the reply, with retry logic for issue resolution
int maxRetries = 3;
int retryCount = 0;
bool taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
while (!taskConfirmed && retryCount < maxRetries)
{
retryCount++;
Console.WriteLine($"\n[runner] Attempt {retryCount}/{maxRetries}: Resolving remaining issues and running tests...\n");
confirmation = await RunCopilot(
new[] { "--continue" },
"resolve any remaining issues, make sure all tests are running and pass. then confirm with yes if done"
);
if (cts.IsCancellationRequested) break;
taskConfirmed = confirmation.Contains("yes", StringComparison.OrdinalIgnoreCase);
}
if (!taskConfirmed)
{
Console.WriteLine($"\n[runner] Task not confirmed as done after {maxRetries} attempts. Stopping.");
break;
}
// Step 4 — commit the work
Console.WriteLine("\n[runner] Task confirmed. Making git commit...\n");
await RunCopilot(Enumerable.Empty<string>(), $"/caveman full");
await RunCopilot(new[] { "--continue" }, "make git commit");
if (cts.IsCancellationRequested) break;
// Step 5 — remove completed task from Tasks.md
var remaining = items.Skip(i + 1).ToList();
File.WriteAllText(tasksFile, string.Join("\n\n---\n\n", remaining));
Console.WriteLine("[runner] Removed completed task from Tasks.md");
}
Console.WriteLine("\n[runner] Finished.");

View File

@@ -1 +0,0 @@
https://lists.blocklist.de/lists/all.txt

View File

@@ -12,6 +12,7 @@
# make logs — tail logs for all debug services
# make restart — restart the debug stack
# make dev-ban-test — one-command smoke test of the ban pipeline
# make e2e — run the Robot Framework E2E test suite
# ──────────────────────────────────────────────────────────────
COMPOSE_FILE := Docker/compose.debug.yml
@@ -36,45 +37,83 @@ DEV_IMAGES := \
COMPOSE := $(shell command -v podman-compose 2>/dev/null \
|| echo "podman compose")
# Env file in the project root.
# Passed explicitly because docker compose v2 defaults to the compose file's
# directory as the project directory, not the shell's cwd.
ENV_FILE := .env
COMPOSE_OPTS := --env-file $(ENV_FILE) -f $(COMPOSE_FILE)
# Detect available container runtime (podman or docker).
RUNTIME := $(shell command -v podman 2>/dev/null || echo "docker")
.PHONY: up down build restart logs clean dev-ban-test
.PHONY: up down build restart logs clean dev-ban-test e2e ensure-env
## Ensure .env exists with BANGUI_SESSION_SECRET set.
## Copies .env.example → .env on first run and auto-generates the secret.
ensure-env:
@if [ ! -f .env ]; then \
cp .env.example .env; \
python3 -c "\
import re, secrets; \
content = open('.env').read(); \
secret = secrets.token_hex(32); \
content = re.sub(r'(?m)^BANGUI_SESSION_SECRET=.*', 'BANGUI_SESSION_SECRET=' + secret, content); \
open('.env', 'w').write(content); \
print('Created .env with a generated BANGUI_SESSION_SECRET.')"; \
fi
## Start the debug stack (detached).
## Ensures log stub files exist so fail2ban can open them on first start.
up:
@mkdir -p Docker/logs
@touch Docker/logs/auth.log
$(COMPOSE) -f $(COMPOSE_FILE) up -d
## All output is logged to /data/log/make-up.log.
up: ensure-env
@mkdir -p data/log
@touch data/log/auth.log
$(COMPOSE) $(COMPOSE_OPTS) up -d 2>&1 | tee data/log/make-up.log
## Stop the debug stack.
down:
$(COMPOSE) -f $(COMPOSE_FILE) down
down: ensure-env
$(COMPOSE) $(COMPOSE_OPTS) down
## (Re)build the backend image without starting containers.
build:
$(COMPOSE) -f $(COMPOSE_FILE) build
build: ensure-env
$(COMPOSE) $(COMPOSE_OPTS) build
## Restart the debug stack.
restart: down up
## Tail logs for all debug services.
logs:
$(COMPOSE) -f $(COMPOSE_FILE) logs -f
logs: ensure-env
$(COMPOSE) $(COMPOSE_OPTS) logs -f
## Stop containers, remove ALL debug volumes and locally-built images.
## The next 'make up' will rebuild images from scratch and start fresh.
clean:
$(COMPOSE) -f $(COMPOSE_FILE) down --remove-orphans
clean: ensure-env
$(COMPOSE) $(COMPOSE_OPTS) down --remove-orphans
$(RUNTIME) volume rm $(DEV_VOLUMES) 2>/dev/null || true
$(RUNTIME) rmi $(DEV_IMAGES) 2>/dev/null || true
@echo "All debug volumes and local images removed. Run 'make up' to rebuild and start fresh."
rm -rf ./data
@echo "All debug volumes, local images, and ./data removed. Run 'make up' to rebuild and start fresh."
## Run the Robot Framework E2E test suite.
## Requires: stack up (make up), BANGUI_SESSION_SECRET env var set.
## Installs: pip install -r e2e/requirements.txt && rfbrowser init
e2e: down clean up
@echo "Waiting 2 minutes for services to initialize..."
@sleep 120
@echo "Waiting for stack to be healthy..."
@timeout=120; \
until curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; do \
sleep 5; timeout=$$((timeout-5)); \
if [ $$timeout -le 0 ]; then echo "Backend not healthy after 120s"; exit 1; fi; \
done
pip install -r e2e/requirements.txt -q
rfbrowser init
robot --outputdir e2e/results e2e/tests/
## One-command smoke test for the ban pipeline:
## 1. Start fail2ban, 2. write failure lines, 3. check ban status.
dev-ban-test:
$(COMPOSE) -f $(COMPOSE_FILE) up -d fail2ban
dev-ban-test: ensure-env
$(COMPOSE) $(COMPOSE_OPTS) up -d fail2ban
sleep 5
bash Docker/simulate_failed_logins.sh
sleep 3

View File

@@ -8,6 +8,12 @@ BANGUI_DATABASE_PATH=bangui.db
# Path to the fail2ban Unix domain socket.
BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
# Path to the fail2ban configuration directory used by the web UI.
BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
# Shell command used to start fail2ban during recovery operations.
BANGUI_FAIL2BAN_START_COMMAND=fail2ban-client start
# Secret key used to sign session tokens. Use a long, random string.
# Generate with: python -c "import secrets; print(secrets.token_hex(64))"
BANGUI_SESSION_SECRET=replace-this-with-a-long-random-secret
@@ -20,3 +26,21 @@ BANGUI_TIMEZONE=UTC
# Application log level: debug | info | warning | error | critical
BANGUI_LOG_LEVEL=info
# Comma-separated list of allowed CORS origins when the frontend is served
# from a different origin than the backend.
# Leave this blank in production when the UI is served from the same origin.
BANGUI_CORS_ALLOWED_ORIGINS=http://localhost:5173
# ---------------------------------------------------------------------------
# Pagination & display limits
# ---------------------------------------------------------------------------
# Maximum records per paginated response. Must be between 1 and 10000.
BANGUI_MAX_PAGE_SIZE=500
# Maximum IP lines returned in a blocklist source preview. Must be at least 1.
BANGUI_PREVIEW_MAX_LINES=100
# Number of days to retain historical ban records before archival cleanup.
BANGUI_HISTORY_RETENTION_DAYS=90

View File

@@ -4,9 +4,21 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_
and validated at startup via the Settings singleton.
"""
from pydantic import Field
import ipaddress
import os
import shlex
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.utils.constants import (
DEFAULT_DATABASE_PATH,
DEFAULT_FAIL2BAN_SOCKET,
DEFAULT_SESSION_DURATION_MINUTES,
)
class Settings(BaseSettings):
"""BanGUI runtime configuration.
@@ -18,38 +30,287 @@ class Settings(BaseSettings):
"""
database_path: str = Field(
default="bangui.db",
default=DEFAULT_DATABASE_PATH,
description="Filesystem path to the BanGUI SQLite application database.",
)
fail2ban_socket: str = Field(
default="/var/run/fail2ban/fail2ban.sock",
default=DEFAULT_FAIL2BAN_SOCKET,
description="Path to the fail2ban Unix domain socket.",
)
session_secret: str = Field(
...,
min_length=32,
description=(
"Secret key used when generating session tokens. "
"Must be at least 32 characters. "
"Must be unique and never committed to source control. "
"Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
),
)
session_secret_previous: str | None = Field(
default=None,
description=(
"Previous session secret for rotation support. "
"Set this to the old secret during a rotation to accept tokens signed "
"with either the current or previous secret. Tokens valid with the "
"previous secret will be re-signed with the current secret. "
"After all old tokens have expired, unset this field to disable rotation."
),
)
session_duration_minutes: int = Field(
default=60,
default=DEFAULT_SESSION_DURATION_MINUTES,
ge=1,
description="Number of minutes a session token remains valid after creation.",
)
session_cache_enabled: bool = Field(
default=False,
description=(
"Enable the in-memory session validation cache. "
"Disable it in multi-worker deployments to avoid stale revoked sessions."
),
)
session_cache_ttl_seconds: float = Field(
default=10.0,
ge=0.0,
description=(
"How long (seconds) a cached session validation entry remains fresh. "
"Ignored when session_cache_enabled is false."
),
)
http_request_timeout_seconds: float = Field(
default=20.0,
ge=0.0,
description="Maximum total time in seconds for outbound external HTTP requests.",
)
http_connect_timeout_seconds: float = Field(
default=5.0,
ge=0.0,
description="Maximum time in seconds to establish outbound external HTTP connections.",
)
http_max_connections: int = Field(
default=10,
ge=1,
description="Maximum number of concurrent outbound HTTP connections.",
)
http_keepalive_timeout_seconds: float = Field(
default=15.0,
ge=0.0,
description="How long idle keepalive connections are retained by the HTTP connector.",
)
timezone: str = Field(
default="UTC",
description="IANA timezone name used when displaying timestamps in the UI.",
)
session_cookie_httponly: bool = Field(
default=True,
description=(
"Mark the session cookie as HttpOnly so browser scripts cannot access it."
),
)
session_cookie_samesite: Literal["lax", "strict", "none"] = Field(
default="lax",
description=(
"SameSite policy for the session cookie. "
"Use 'lax', 'strict', or 'none' depending on deployment requirements."
),
)
session_cookie_secure: bool = Field(
default=True,
description=(
"Set the session cookie Secure flag when the backend is served over HTTPS. "
"Defaults to True for security. Set to False only for local development over HTTP."
),
)
cors_allowed_origins: str | list[str] = Field(
default_factory=lambda: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"https://localhost:5173",
"https://127.0.0.1:5173",
],
description=(
"Comma-separated list of allowed CORS origins when the frontend is "
"served from a different origin than the backend. "
"Defaults to common localhost development origins. "
"Override in production with the specific frontend domain."
),
)
@field_validator("database_path", mode="after")
@classmethod
def _validate_database_path(cls, value: str) -> str:
"""Validate database_path parent directory exists and is writable.
Args:
value: The database path string.
Returns:
The validated path string.
Raises:
ValueError: If parent directory does not exist or is not writable.
"""
path = Path(value)
parent = path.parent
if not parent.exists():
raise ValueError(
f"Database parent directory does not exist: {parent}\n"
f"Hint: Create it with: mkdir -p {parent}"
)
if not os.access(parent, os.W_OK):
raise ValueError(
f"Database parent directory not writable: {parent}\n"
f"Hint: Fix with: chmod 755 {parent}"
)
return value
@field_validator("fail2ban_socket", mode="after")
@classmethod
def _validate_fail2ban_socket(cls, value: str) -> str:
"""Validate fail2ban socket exists and is readable.
Args:
value: The fail2ban socket path string.
Returns:
The validated path string.
Raises:
ValueError: If the socket path exists but is not readable.
"""
path = Path(value)
if path.exists() and not os.access(path, os.R_OK):
raise ValueError(
f"fail2ban socket not readable: {path}\n"
f"Hint: Fix with: chmod 644 {path}"
)
return value
@field_validator("geoip_db_path", mode="after")
@classmethod
def _validate_geoip_db_path(cls, value: str | None) -> str | None:
"""Validate geoip_db_path exists if set.
Args:
value: The GeoIP database path or None.
Returns:
The validated path or None.
Raises:
ValueError: If the path is set but the file does not exist.
"""
if value is None:
return value
path = Path(value)
if not path.exists():
raise ValueError(
f"GeoIP database file does not exist: {path}\n"
f"Hint: Download from https://dev.maxmind.com/geoip/geolite2-country"
)
return value
@field_validator("fail2ban_config_dir", mode="after")
@classmethod
def _validate_fail2ban_config_dir(cls, value: str) -> str:
"""Validate fail2ban_config_dir exists.
Args:
value: The fail2ban configuration directory path.
Returns:
The validated path string.
Raises:
ValueError: If the directory does not exist.
"""
path = Path(value)
if not path.exists():
raise ValueError(
f"fail2ban config directory does not exist: {path}\n"
f"Hint: Mount the fail2ban config directory or adjust BANGUI_FAIL2BAN_CONFIG_DIR"
)
return value
@field_validator("session_secret", mode="after")
@classmethod
def _validate_session_secret(cls, value: str) -> str:
"""Validate session_secret is sufficiently long and non-trivial.
Args:
value: The session secret string.
Returns:
The validated secret string.
Raises:
ValueError: If the secret is too short or appears weak.
"""
if len(value) < 32:
raise ValueError(
f"session_secret must be at least 32 characters. Got {len(value)}.\n"
f"Hint: Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
weak_indicators = {"password", "secret", "123", "abc", "admin"}
value_lower = value.lower()
if any(value_lower.startswith(w) for w in weak_indicators):
raise ValueError(
"session_secret is too weak (found common word).\n"
"Hint: Generate one with: python -c \"import secrets; print(secrets.token_hex(32))\""
)
return value
@field_validator("cors_allowed_origins", mode="before")
@classmethod
def _normalize_cors_origins(cls, value: str | list[str] | None) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
log_level: str = Field(
default="info",
description="Application log level: debug | info | warning | error | critical.",
)
log_file: str | None = Field(
default="/data/log/bangui.log",
description="Optional file path for writing application logs. Set to null to disable file logging.",
)
suppress_third_party_logs: bool = Field(
default=True,
description=(
"When true, sets APScheduler and aiosqlite loggers to WARNING level. "
"Set to false to allow third-party libraries to emit DEBUG/INFO logs."
),
)
geoip_db_path: str | None = Field(
default=None,
description=(
"Optional path to a MaxMind GeoLite2-Country .mmdb file. "
"When set, failed ip-api.com lookups fall back to local resolution."
"When set, it is used as the primary resolver for IP geolocation. "
"The ip-api.com HTTP API is only used as a fallback when the MMDB is unavailable or returns no result."
),
)
geoip_allow_http_fallback: bool = Field(
default=False,
description=(
"Allow fallback to ip-api.com HTTP API when the MaxMind database is unavailable. "
"WARNING: Enabling this sends unencrypted IP addresses over HTTP. "
"Only use this flag when the MMDB cannot be mounted and you understand the security implications. "
"Default is False (only use local MMDB, fail if unavailable)."
),
)
fail2ban_config_dir: str = Field(
@@ -60,6 +321,14 @@ class Settings(BaseSettings):
"Used for listing, viewing, and editing configuration files through the web UI."
),
)
allowed_log_dirs: list[str] = Field(
default_factory=lambda: ["/var/log", "/config/log"],
description=(
"List of allowed directory prefixes for jail log paths. "
"Any log path added must resolve to a path within one of these directories. "
"Use absolute paths. Symlinks are resolved before validation."
),
)
fail2ban_start_command: str = Field(
default="fail2ban-client start",
description=(
@@ -69,12 +338,276 @@ class Settings(BaseSettings):
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
),
)
enable_docs: bool = Field(
default=False,
description=(
"Enable FastAPI interactive API documentation at /api/docs (Swagger UI) "
"and /api/redoc (ReDoc). Should be true only in development environments. "
"In production, leave unset (defaults to false) to avoid exposing API schema."
),
)
trusted_proxies: str | list[str] = Field(
default_factory=list,
description=(
"Comma-separated list of trusted reverse proxy IP addresses or CIDR ranges. "
"Only requests from these IPs/ranges are allowed to set X-Forwarded-For and X-Real-IP headers. "
"Examples: '192.168.1.1' or '10.0.0.0/8' or '192.168.1.1,10.0.0.0/8'. "
"Leave empty to disable proxy header forwarding (default). "
"This is critical for correct client IP extraction behind reverse proxies like nginx."
),
)
@field_validator("trusted_proxies", mode="before")
@classmethod
def _normalize_trusted_proxies(cls, value: str | list[str] | None) -> list[str]:
"""Normalize trusted_proxies from comma-separated string to list.
Args:
value: A comma-separated string or list of trusted proxy IPs/CIDRs.
Returns:
A list of normalized proxy IP/CIDR strings.
"""
if value is None:
return []
if isinstance(value, str):
return [proxy.strip() for proxy in value.split(",") if proxy.strip()]
return value
@field_validator("trusted_proxies", mode="after")
@classmethod
def _validate_trusted_proxies(cls, value: list[str]) -> list[str]:
"""Validate trusted_proxies as valid IPs or CIDR ranges.
Args:
value: A list of proxy IP addresses or CIDR ranges.
Returns:
The validated list.
Raises:
ValueError: If any item is not a valid IP address or CIDR range.
"""
for proxy in value:
try:
# Try to parse as a CIDR network first
ipaddress.ip_network(proxy, strict=False)
except ValueError:
try:
# Fall back to parsing as a single IP address
ipaddress.ip_address(proxy)
except ValueError as exc:
raise ValueError(
f"Invalid IP address or CIDR range: {proxy!r}. "
f"Expected format: '192.168.1.1' or '10.0.0.0/8'"
) from exc
return value
@field_validator("fail2ban_start_command", mode="after")
@classmethod
def _validate_fail2ban_start_command(cls, value: str) -> str:
"""Validate fail2ban_start_command by attempting to parse it with shlex.
Ensures the command can be split into arguments without shell interpretation.
Raises ValueError if the command contains mismatched quotes.
Args:
value: The fail2ban start command string.
Returns:
The validated command string.
Raises:
ValueError: If the command contains mismatched quotes.
"""
try:
shlex.split(value)
except ValueError as e:
raise ValueError(
f"fail2ban_start_command contains mismatched quotes or is otherwise "
f"unparseable: {value!r}{e}"
) from e
return value
external_logging_enabled: bool = Field(
default=False,
description=(
"Enable sending logs to an external centralized logging platform. "
"When disabled (default), logs are written to stdout only. "
"When enabled, set external_logging_provider and provider-specific settings."
),
)
external_logging_provider: Literal["datadog", "papertrail", "elasticsearch"] | None = Field(
default=None,
description=(
"External logging platform provider. "
"Set to 'datadog', 'papertrail', or 'elasticsearch'. "
"Only used when external_logging_enabled is true."
),
)
external_logging_buffer_size: int = Field(
default=1000,
ge=10,
description=(
"Maximum number of log records to buffer in memory before dropping oldest logs. "
"Prevents unbounded memory growth if the external system is temporarily unavailable."
),
)
external_logging_flush_interval_seconds: float = Field(
default=5.0,
gt=0.0,
description=(
"Maximum time in seconds to buffer logs before sending to the external system. "
"Logs are sent earlier if the batch size is reached."
),
)
external_log_required: bool = Field(
default=False,
description=(
"When enabled and external logging is configured, startup aborts if the "
"external log handler fails to initialize. When disabled (default), a failed "
"handler is treated as a warning and the application continues without external "
"logging. Set to true in production environments where logs must reach the "
"monitoring system."
),
)
datadog_api_key: str | None = Field(
default=None,
description=(
"Datadog API key for sending logs. Required when external_logging_provider is 'datadog'. "
"Obtain from Datadog organization settings."
),
)
datadog_site: str = Field(
default="datadoghq.com",
description=(
"Datadog site: 'datadoghq.com' for US or 'datadoghq.eu' for EU. "
"Only used when external_logging_provider is 'datadog'."
),
)
datadog_batch_size: int = Field(
default=10,
ge=1,
description=(
"Number of log records to batch before sending to Datadog. "
"Smaller batches send logs faster; larger batches are more efficient."
),
)
papertrail_host: str | None = Field(
default=None,
description=(
"Papertrail host address (e.g., 'logs1.papertrailapp.com'). "
"Required when external_logging_provider is 'papertrail'."
),
)
papertrail_port: int | None = Field(
default=None,
ge=1,
le=65535,
description=(
"Papertrail port number. Required when external_logging_provider is 'papertrail'. "
"Typically 12345 or in range 10000-32768."
),
)
papertrail_program_name: str = Field(
default="bangui",
description=(
"Program name to include in Syslog messages sent to Papertrail. "
"Useful for filtering logs by program in Papertrail UI."
),
)
elasticsearch_hosts: str | list[str] = Field(
default_factory=list,
description=(
"Elasticsearch host addresses. Can be comma-separated string or list. "
"Examples: 'http://elasticsearch:9200' or 'http://es1:9200,http://es2:9200'. "
"Required when external_logging_provider is 'elasticsearch'."
),
)
elasticsearch_index_prefix: str = Field(
default="bangui",
description=(
"Prefix for Elasticsearch indices where logs are stored. "
"Final index names will be '{prefix}-{date}' or similar."
),
)
elasticsearch_batch_size: int = Field(
default=10,
ge=1,
description=(
"Number of log documents to batch before sending to Elasticsearch. "
"Larger batches are more efficient but introduce slight latency."
),
)
# Rate limit configuration (per IP)
rate_limit_bans_per_minute: int = Field(
default=100,
ge=1,
description="Max ban/unban requests per IP per minute.",
)
rate_limit_blocklist_import_per_hour: int = Field(
default=10,
ge=1,
description="Max blocklist import requests per IP per hour.",
)
rate_limit_config_update_per_minute: int = Field(
default=50,
ge=1,
description="Max config update requests per IP per minute.",
)
# -------------------------------------------------------------------------
# Pagination & display limits (configurable per deployment)
# -------------------------------------------------------------------------
max_page_size: int = Field(
default=500,
ge=1,
le=10000,
description=(
"Maximum number of records returned per paginated API response. "
"Individual endpoints may further limit this value. "
"Must be between 1 and 10000."
),
)
blocklist_preview_max_lines: int = Field(
default=100,
ge=1,
description=(
"Maximum number of IP lines returned in a blocklist source preview. "
"Must be at least 1."
),
)
history_retention_days: int = Field(
default=90,
ge=1,
description=(
"Number of days historical ban records are retained before being "
"archived or purged by the cleanup task. Must be at least 1."
),
)
@field_validator("elasticsearch_hosts", mode="before")
@classmethod
def _normalize_elasticsearch_hosts(cls, value: str | list[str] | None) -> list[str]:
"""Normalize elasticsearch_hosts from comma-separated string to list.
Args:
value: A comma-separated string or list of host URLs.
Returns:
A list of normalized host URLs.
"""
if value is None or (isinstance(value, list) and len(value) == 0):
return []
if isinstance(value, str):
return [host.strip() for host in value.split(",") if host.strip()]
return value
model_config = SettingsConfigDict(
env_prefix="BANGUI_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
@@ -85,4 +618,4 @@ def get_settings() -> Settings:
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
if required keys are absent or values fail validation.
"""
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
return Settings()

View File

@@ -9,10 +9,15 @@ The fail2ban database is separate and is accessed read-only by the history
and ban services.
"""
import aiosqlite
import structlog
from __future__ import annotations
log: structlog.stdlib.BoundLogger = structlog.get_logger()
from pathlib import Path
import aiosqlite
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
# ---------------------------------------------------------------------------
# DDL statements
@@ -31,14 +36,14 @@ CREATE TABLE IF NOT EXISTS settings (
_CREATE_SESSIONS: str = """
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
"""
_CREATE_SESSIONS_TOKEN_INDEX: str = """
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions (token);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions (token_hash);
"""
_CREATE_BLOCKLIST_SOURCES: str = """
@@ -55,9 +60,9 @@ CREATE TABLE IF NOT EXISTS blocklist_sources (
_CREATE_IMPORT_LOG: str = """
CREATE TABLE IF NOT EXISTS import_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE SET NULL,
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE RESTRICT,
source_url TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
timestamp INTEGER NOT NULL,
ips_imported INTEGER NOT NULL DEFAULT 0,
ips_skipped INTEGER NOT NULL DEFAULT 0,
errors TEXT
@@ -89,6 +94,13 @@ CREATE TABLE IF NOT EXISTS history_archive (
);
"""
_CREATE_SCHEMA_MIGRATIONS: str = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
migrated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""
# Ordered list of DDL statements to execute on initialisation.
_SCHEMA_STATEMENTS: list[str] = [
_CREATE_SETTINGS,
@@ -100,14 +112,332 @@ _SCHEMA_STATEMENTS: list[str] = [
_CREATE_HISTORY_ARCHIVE,
]
_CURRENT_SCHEMA_VERSION: int = 9
_MIGRATIONS: dict[int, str] = {
1: "\n".join(_SCHEMA_STATEMENTS),
2: """
-- Migration 2: Hash session tokens for security.
-- Drop the old sessions table and recreate with token_hash column.
-- This invalidates all existing sessions, which is acceptable as the DB
-- contents were exposed in plaintext.
DROP TABLE IF EXISTS sessions;
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL
);
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'));
""",
4: """
-- Migration 4: Add scheduler_lock table for multi-worker safety.
-- Implements database-backed locking to ensure only one worker runs the scheduler.
-- Uses atomic transactions to prevent race conditions in container orchestration.
-- Lock is held by the process that successfully inserts the singleton row (id=1).
CREATE TABLE scheduler_lock (
id INTEGER PRIMARY KEY CHECK (id = 1),
pid INTEGER NOT NULL,
hostname TEXT NOT NULL,
created_at REAL NOT NULL,
heartbeat_at REAL NOT NULL,
heartbeat_timeout REAL NOT NULL DEFAULT 300
);
""",
5: """
-- Migration 5: Add indexes to history_archive table for query performance.
-- The history_archive table supports filtering by jail, IP, action, and time range,
-- combined with pagination (ORDER BY timeofban DESC LIMIT/OFFSET).
-- These indexes accelerate common dashboard and API queries.
-- See Docs/Backend-Development.md § Database Performance for details.
-- Composite index for common queries: jail + timeofban ordering (dashboard filter).
CREATE INDEX IF NOT EXISTS idx_history_archive_jail_timeofban
ON history_archive (jail, timeofban DESC);
-- Composite index for time-range + jail queries (history timeline filters).
CREATE INDEX IF NOT EXISTS idx_history_archive_timeofban_jail_action
ON history_archive (timeofban DESC, jail, action);
-- Index for single-column filters: supports IP prefix searches and exact matches.
CREATE INDEX IF NOT EXISTS idx_history_archive_ip
ON history_archive (ip);
-- Index for action-based queries: supports ban/unban filtering.
CREATE INDEX IF NOT EXISTS idx_history_archive_action
ON history_archive (action);
""",
6: """
-- Migration 6: Add import_runs table for tracking blocklist import idempotency.
-- Tracks unique imports by source and content hash to enable idempotent retries.
-- On import crash, retry will detect the operation_id and skip duplicate bans.
-- This prevents duplicate IP bans if the scheduler retries after a failure.
CREATE TABLE IF NOT EXISTS import_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
content_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'completed', 'failed')),
imported_count INTEGER NOT NULL DEFAULT 0,
skipped_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(source_id, content_hash)
);
-- Index for looking up completed imports by source
CREATE INDEX IF NOT EXISTS idx_import_runs_source_status
ON import_runs (source_id, status);
""",
7: """
-- Migration 7: Add indexes to import_log table for cursor-based pagination.
-- The import_log table is paginated by id (newest first) and filtered by source_id.
-- These indexes accelerate pagination queries and maintain consistent ordering.
-- See Docs/Backend-Development.md § Database Performance for details.
-- Index for ordering by id DESC for cursor-based pagination (newest first)
CREATE INDEX IF NOT EXISTS idx_import_log_id_desc
ON import_log (id DESC);
-- Composite index for source_id + id DESC ordering (filtered pagination)
CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
ON import_log (source_id, id DESC);
""",
8: """
-- Migration 8: Migrate import_log.timestamp from TEXT ISO 8601 to INTEGER UNIX epoch.
-- Standardizes all BanGUI timestamps on INTEGER UNIX (seconds since epoch).
-- This aligns import_log with history_archive which already uses INTEGER timeofban.
-- TEXT ISO 8601: "2024-06-15T13:45:00.000Z"
-- INTEGER UNIX: 1718453100
ALTER TABLE import_log ADD COLUMN timestamp_unix INTEGER;
UPDATE import_log SET timestamp_unix = strftime('%s', timestamp);
ALTER TABLE import_log DROP COLUMN timestamp;
ALTER TABLE import_log RENAME COLUMN timestamp_unix TO timestamp;
""",
9: """
-- Migration 9: Change import_log.source_id foreign key to ON DELETE RESTRICT.
-- Previously, deleting a blocklist source set source_id to NULL, leaving orphaned
-- log records with populated URL but NULL source_id (meaningless/useless data).
-- Now, RESTRICT prevents source deletion if import logs exist, preserving data
-- integrity. Admin must delete logs before deleting source.
-- See Issue #11: Foreign Key ON DELETE Semantics Problem.
DROP INDEX IF EXISTS idx_import_log_source_id_desc;
DROP TABLE IF EXISTS _import_log_backup;
CREATE TABLE _import_log_backup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE RESTRICT,
source_url TEXT NOT NULL,
timestamp INTEGER NOT NULL,
ips_imported INTEGER NOT NULL DEFAULT 0,
ips_skipped INTEGER NOT NULL DEFAULT 0,
errors TEXT
);
INSERT INTO _import_log_backup (id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors)
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors FROM import_log;
DROP TABLE import_log;
ALTER TABLE _import_log_backup RENAME TO import_log;
CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc
ON import_log (source_id, id DESC);
""",
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def _configure_connection(db: aiosqlite.Connection) -> None:
"""Apply hardening pragmas to a newly-opened SQLite connection."""
await db.execute("PRAGMA journal_mode=WAL;")
await db.execute("PRAGMA foreign_keys=ON;")
await db.execute("PRAGMA busy_timeout=5000;")
async def _cleanup_wal_files(db_path: str) -> None:
"""Remove orphaned WAL files after crashes.
When SQLite crashes in WAL mode, it may leave behind stale .wal and .shm
files that prevent the database from opening properly. This function removes
them if they exist and are not in use by any connection.
The actual recovery is done by SQLite automatically when opening the database.
This just cleans up orphaned files from previous crashes.
Args:
db_path: Path to the database file.
"""
import time
wal_path = Path(db_path + "-wal")
shm_path = Path(db_path + "-shm")
for path in (wal_path, shm_path):
if path.exists():
# Skip files that were modified recently — they likely belong to an
# active connection. Only remove stale files left by crashes.
mtime = path.stat().st_mtime
if time.time() - mtime < 10:
continue
try:
path.unlink()
log.warning("orphaned_sqlite_file_removed", path=str(path))
except OSError:
pass # File in use or permission denied
async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
"""Return the highest applied schema version for the given database."""
await db.execute(_CREATE_SCHEMA_MIGRATIONS)
async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor:
row = await cursor.fetchone()
if row is None or row[0] is None:
return 0
return int(row[0])
async def _parse_migration_statements(script: str) -> list[str]:
"""Parse a migration script into individual SQL statements.
Splits on semicolons but ignores semicolons inside string literals and
comments. Handles both block (-- comment) and line comments.
Args:
script: The raw migration script.
Returns:
List of SQL statements (stripped of whitespace and comments).
"""
statements: list[str] = []
current_stmt: list[str] = []
i = 0
while i < len(script):
char = script[i]
# Skip block comments (-- ...)
if i < len(script) - 1 and script[i : i + 2] == "--":
while i < len(script) and script[i] != "\n":
i += 1
i += 1
continue
# Skip line comments (/* ... */)
if i < len(script) - 1 and script[i : i + 2] == "/*":
i += 2
while i < len(script) - 1:
if script[i : i + 2] == "*/":
i += 2
break
i += 1
continue
# Handle string literals (single or double quotes)
if char in ("'", '"'):
quote = char
current_stmt.append(char)
i += 1
while i < len(script):
if script[i] == quote:
if i + 1 < len(script) and script[i + 1] == quote:
# Escaped quote
current_stmt.append(quote)
current_stmt.append(quote)
i += 2
else:
# End of string
current_stmt.append(quote)
i += 1
break
else:
current_stmt.append(script[i])
i += 1
continue
# Statement separator
if char == ";":
stmt = "".join(current_stmt).strip()
if stmt:
statements.append(stmt)
current_stmt = []
i += 1
continue
current_stmt.append(char)
i += 1
# Add any remaining statement
stmt = "".join(current_stmt).strip()
if stmt:
statements.append(stmt)
return statements
async def _apply_migration(db: aiosqlite.Connection, version: int) -> None:
"""Apply a single migration step and record its completion atomically.
Wraps all DDL statements and the schema_migrations insert in a single
BEGIN IMMEDIATE ... COMMIT transaction to ensure atomicity. If any
statement fails, the entire migration is rolled back.
Args:
db: An open aiosqlite.Connection.
version: The migration version number.
Raises:
Any exception from executing the migration statements or inserting
the schema migration record will cause a rollback.
"""
migration_script = _MIGRATIONS[version]
statements = await _parse_migration_statements(migration_script)
try:
await db.execute("BEGIN IMMEDIATE;")
for statement in statements:
try:
await db.execute(statement)
except aiosqlite.OperationalError as exc:
# Ignore duplicate column / table errors so migrations remain
# idempotent when a legacy database already has the object.
msg = str(exc).lower()
if "duplicate column name" in msg or "table" in msg and "already exists" in msg:
continue
raise
await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
await db.commit()
except Exception:
await db.rollback()
raise
async def _migrate_schema(db: aiosqlite.Connection) -> None:
"""Migrate the database schema to the latest supported version."""
current_version = await _get_current_schema_version(db)
if current_version == _CURRENT_SCHEMA_VERSION:
return
if current_version > _CURRENT_SCHEMA_VERSION:
raise RuntimeError(
f"database schema version {current_version} is newer than supported version {_CURRENT_SCHEMA_VERSION}"
)
log.info("migrating_database_schema", from_version=current_version, to_version=_CURRENT_SCHEMA_VERSION)
for next_version in range(current_version + 1, _CURRENT_SCHEMA_VERSION + 1):
await _apply_migration(db, next_version)
log.info("database_schema_ready", schema_version=_CURRENT_SCHEMA_VERSION)
async def init_db(db: aiosqlite.Connection) -> None:
"""Create all BanGUI application tables if they do not already exist.
"""Create or migrate the BanGUI application database schema.
This function is idempotent — calling it on an already-initialised
database has no effect. It should be called once during application
@@ -117,11 +447,21 @@ async def init_db(db: aiosqlite.Connection) -> None:
db: An open :class:`aiosqlite.Connection` to the application database.
"""
log.info("initialising_database_schema")
async with db.execute("PRAGMA journal_mode=WAL;"):
pass
async with db.execute("PRAGMA foreign_keys=ON;"):
pass
for statement in _SCHEMA_STATEMENTS:
await db.executescript(statement)
await db.commit()
log.info("database_schema_ready")
await _configure_connection(db)
await _migrate_schema(db)
async def open_db(database_path: str) -> aiosqlite.Connection:
"""Open a new application SQLite connection with the standard settings.
Args:
database_path: Path to the BanGUI SQLite database.
Returns:
A configured :class:`aiosqlite.Connection` instance.
"""
await _cleanup_wal_files(database_path)
db = await aiosqlite.connect(database_path)
db.row_factory = aiosqlite.Row
await _configure_connection(db)
return db

View File

@@ -1,33 +1,101 @@
"""FastAPI dependency providers.
"""FastAPI dependency providers and composition root.
All ``Depends()`` callables that inject shared resources (database
connection, settings, services, auth guard) are defined here.
Routers import directly from this module — never from ``app.state``
directly — to keep coupling explicit and testable.
This module is BanGUI's dependency injection composition root. All injectable
resources — database connections, settings, services, repositories, and
authentication guards — are defined here as provider functions.
**Key Principles:**
1. **Composition Root Pattern**: No heavyweight DI container is used. Instead,
FastAPI's `Depends()` framework manages all dependencies, keeping the pattern
lightweight and explicit.
2. **Explicit Over Implicit**: Every dependency is declared in function signatures.
There is no hidden coupling or magic. This makes the dependency graph visible
to type checkers, debuggers, and developers.
3. **Service Context Dependencies**: Related resources (e.g., db + repository) are
bundled into context objects (SessionServiceContext, BlocklistServiceContext)
to prevent routers from accessing raw database connections.
4. **Repository Boundary Enforcement**: Routers must NOT import DbDep. They depend
on service context dependencies instead, which contain both the database
connection and the necessary repositories. This ensures repositories are the
only modules executing SQL.
See Architekture.md § 2.3 (Dependency Wiring and Service Composition) for a
complete guide to the DI pattern, including examples of adding new services.
See Backend-Development.md § 6 for dependency layering rules.
"""
import time
from typing import Annotated, Protocol, cast
import datetime
from collections.abc import AsyncGenerator, Awaitable, Callable
from dataclasses import dataclass
from typing import Annotated, cast
import aiohttp
import aiosqlite
import structlog
from fastapi import Depends, HTTPException, Request, status
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
from fastapi import Depends, FastAPI, HTTPException, Request, status
from app.config import Settings
from app.exceptions import RateLimitError
from app.models.auth import Session
from app.utils.time_utils import utc_now
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# Module-level imports for repositories and services
# These are safe at module level since no circular dependencies exist
from app.repositories import (
blocklist_repo,
fail2ban_db_repo,
geo_cache_repo,
history_archive_repo,
import_log_repo,
import_run_repo,
session_repo,
settings_repo,
)
from app.repositories.protocols import (
BlocklistRepository,
Fail2BanDbRepository,
GeoCacheRepository,
HistoryArchiveRepository,
ImportLogRepository,
ImportRunRepository,
SessionRepository,
SettingsRepository,
)
from app.services import auth_service, health_service
from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service
from app.services.geo_cache import GeoCache
from app.services.protocols import Fail2BanMetadataService
from app.utils.constants import SESSION_COOKIE_NAME
from app.utils.logging_compat import get_logger
from app.utils.rate_limiter import GlobalRateLimiter
from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState
from app.utils.session_cache import NoOpSessionCache, SessionCache
log = get_logger(__name__)
class AppState(Protocol):
"""Partial view of the FastAPI application state used by dependencies."""
@dataclass
class ApplicationContext:
"""A typed wrapper around shared application lifecycle resources."""
settings: Settings
http_session: aiohttp.ClientSession | None
scheduler: AsyncIOScheduler | None
server_status: ServerStatus
pending_recovery: PendingRecovery | None
last_activation: dict[str, datetime.datetime] | None
runtime_settings: Settings | None
runtime_state: RuntimeState
session_cache: SessionCache | None
global_rate_limiter: GlobalRateLimiter
_COOKIE_NAME = "bangui_session"
# ---------------------------------------------------------------------------
# Session validation cache
# ---------------------------------------------------------------------------
@@ -35,84 +103,547 @@ _COOKIE_NAME = "bangui_session"
#: How long (seconds) a validated session token is served from the in-memory
#: cache without re-querying SQLite. Eliminates repeated DB lookups for the
#: same token arriving in near-simultaneous parallel requests.
_SESSION_CACHE_TTL: float = 10.0
#: ``token → (Session, cache_expiry_monotonic_time)``
_session_cache: dict[str, tuple[Session, float]] = {}
#:
#: NOTE: this cache is process-local and is not cluster-safe. In multi-worker
#: or distributed deployments, the configured cache backend should provide
#: invalidation semantics appropriate for the deployment.
def clear_session_cache() -> None:
"""Flush the entire in-memory session validation cache.
Useful in tests to prevent stale state from leaking between test cases.
"""
_session_cache.clear()
def _session_cache_enabled(settings: Settings) -> bool:
"""Return whether the session validation cache should be used."""
return settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
def invalidate_session_cache(token: str) -> None:
"""Evict *token* from the in-memory session cache.
def _build_app_context(request: Request) -> ApplicationContext:
state = cast("ApplicationState", request.app.state)
session_cache = getattr(state, "session_cache", None)
if session_cache is None:
session_cache = NoOpSessionCache()
Must be called during logout so the revoked token is no longer served
from cache without a DB round-trip.
global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None)
if global_rate_limiter is None:
global_rate_limiter = GlobalRateLimiter()
return ApplicationContext(
settings=state.settings,
http_session=getattr(state, "http_session", None),
scheduler=getattr(state, "scheduler", None),
server_status=getattr(state, "server_status", ServerStatus(online=False)),
pending_recovery=getattr(state, "pending_recovery", None),
last_activation=getattr(state, "last_activation", None),
runtime_settings=getattr(state, "runtime_settings", None),
runtime_state=state.runtime_state,
session_cache=session_cache,
global_rate_limiter=global_rate_limiter,
)
async def get_app_context(request: Request) -> ApplicationContext:
"""Provide the typed application context for the current request."""
return _build_app_context(request)
async def get_settings(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> Settings:
"""Provide the effective application settings for the current request."""
return app_context.runtime_settings if app_context.runtime_settings is not None else app_context.settings
async def get_db(
settings: Annotated[Settings, Depends(get_settings)],
) -> AsyncGenerator[aiosqlite.Connection, None]:
"""Provide a request-scoped :class:`aiosqlite.Connection` for the current request.
Opens a fresh connection for every request and closes it when the request
is finished. This avoids contention and locking issues from a single shared
SQLite connection across concurrent requests.
The database path is taken from the effective application settings so
runtime overrides stored during setup are respected.
Args:
token: The session token to remove.
settings: The effective application settings for the current request.
Yields:
An open :class:`aiosqlite.Connection` for the request.
"""
_session_cache.pop(token, None)
from app.db import open_db # noqa: PLC0415
async def get_db(request: Request) -> aiosqlite.Connection:
"""Provide the shared :class:`aiosqlite.Connection` from ``app.state``.
Args:
request: The current FastAPI request (injected automatically).
Returns:
The application-wide aiosqlite connection opened during startup.
Raises:
HTTPException: 503 if the database has not been initialised.
"""
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
if db is None:
log.error("database_not_initialised")
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.",
)
return db
) from exc
try:
yield db
finally:
await db.close()
async def get_settings(request: Request) -> Settings:
"""Provide the :class:`~app.config.Settings` instance from ``app.state``.
async def get_http_session(
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
) -> aiohttp.ClientSession:
"""Provide the shared HTTP client session from application context.
Args:
request: The current FastAPI request (injected automatically).
app_context: The injected shared application context.
Returns:
The application settings loaded at startup.
A shared :class:`aiohttp.ClientSession` managed by the lifespan.
Raises:
HTTPException: If the session is unavailable.
"""
state = cast("AppState", request.app.state)
return state.settings
if app_context.http_session is None:
log.error("http_session_unavailable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="HTTP session is not available.",
)
return app_context.http_session
async def get_scheduler(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> AsyncIOScheduler:
"""Provide the shared scheduler from application context.
Args:
app_context: The injected shared application context.
Returns:
The :class:`apscheduler.schedulers.asyncio.AsyncIOScheduler` instance.
Raises:
HTTPException: If the scheduler is unavailable.
"""
if app_context.scheduler is None:
log.error("scheduler_unavailable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scheduler is not available.",
)
return app_context.scheduler
async def get_fail2ban_socket(settings: Settings = Depends(get_settings)) -> str:
"""Provide the configured path to the fail2ban Unix domain socket."""
return settings.fail2ban_socket
async def get_fail2ban_config_dir(settings: Settings = Depends(get_settings)) -> str:
"""Provide the configured fail2ban configuration directory."""
return settings.fail2ban_config_dir
async def get_fail2ban_start_command(settings: Settings = Depends(get_settings)) -> str:
"""Provide the configured fail2ban start command."""
return settings.fail2ban_start_command
async def get_geo_cache(request: Request) -> GeoCache:
"""Provide the application's GeoCache instance."""
geo_cache: GeoCache = cast("GeoCache", request.app.state.geo_cache)
return geo_cache
async def get_session_cache(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> SessionCache:
"""Provide the configured session cache backend from application context."""
if app_context.session_cache is None:
log.error("session_cache_unavailable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Session cache is not available.",
)
return app_context.session_cache
async def get_global_rate_limiter(
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
) -> GlobalRateLimiter:
"""Provide the global rate limiter from application context."""
return app_context.global_rate_limiter
def rate_limit_dependency(
bucket: str,
max_requests: int,
window_seconds: int,
) -> Callable[[Request, "GlobalRateLimiter"], None]:
"""Create a rate limit dependency for a specific bucket and limit.
Use this factory to create per-endpoint rate limit dependencies.
Each call returns a configured dependency that enforces the
specified rate limit before the endpoint handler runs.
Args:
bucket: Bucket name (e.g., "bans:ban", "blocklist:import").
max_requests: Maximum requests allowed within the window.
window_seconds: Time window for this bucket.
Returns:
A callable that can be used as a FastAPI Depends() dependency.
"""
async def check_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
from app.utils.client_ip import get_client_ip
settings: Settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(bucket, client_ip, max_requests, window_seconds)
if not is_allowed:
log.warning(
"operation_rate_limit_exceeded",
client_ip=client_ip,
bucket=bucket,
path=request.url.path,
method=request.method,
retry_after=retry_after,
)
raise RateLimitError(
f"Rate limit exceeded for {bucket}. Please try again later.",
retry_after_seconds=retry_after,
)
return check_rate_limit
async def get_session_repo() -> SessionRepository:
"""Provide the concrete session repository implementation.
The session_repo module uses structural typing to satisfy the SessionRepository
Protocol interface — its top-level async functions must match the Protocol
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
"""
return session_repo
async def get_blocklist_repo() -> BlocklistRepository:
"""Provide the concrete blocklist repository implementation.
The blocklist_repo module uses structural typing to satisfy the BlocklistRepository
Protocol interface — its top-level async functions must match the Protocol
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
"""
return cast("BlocklistRepository", blocklist_repo)
async def get_import_log_repo() -> ImportLogRepository:
"""Provide the concrete import log repository implementation.
The import_log_repo module uses structural typing to satisfy the ImportLogRepository
Protocol interface — its top-level async functions must match the Protocol
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
"""
return cast("ImportLogRepository", import_log_repo)
async def get_import_run_repo() -> ImportRunRepository:
"""Provide the concrete import run repository implementation.
The import_run_repo module uses structural typing to satisfy the ImportRunRepository
Protocol interface for tracking blocklist imports for idempotency detection.
"""
return cast("ImportRunRepository", import_run_repo)
async def get_settings_repo() -> SettingsRepository:
"""Provide the concrete settings repository implementation.
The settings_repo module uses structural typing to satisfy the SettingsRepository
Protocol interface — its top-level async functions must match the Protocol
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
"""
return cast("SettingsRepository", settings_repo)
async def get_history_archive_repo() -> HistoryArchiveRepository:
"""Provide the concrete history archive repository implementation.
The history_archive_repo module uses structural typing to satisfy the
HistoryArchiveRepository Protocol interface — its top-level async functions
must match the Protocol signatures exactly. This is documented in
Backend-Development.md § 13.7.1.
"""
return cast("HistoryArchiveRepository", history_archive_repo)
async def get_geo_cache_repo() -> GeoCacheRepository:
"""Provide the concrete geo cache repository implementation.
The geo_cache_repo module uses structural typing to satisfy the GeoCacheRepository
Protocol interface — its top-level async functions must match the Protocol
signatures exactly. This is documented in Backend-Development.md § 13.7.1.
"""
return cast("GeoCacheRepository", geo_cache_repo)
async def get_fail2ban_db_repo() -> Fail2BanDbRepository:
"""Provide the concrete fail2ban DB repository implementation.
The fail2ban_db_repo module uses structural typing to satisfy the
Fail2BanDbRepository Protocol interface — its top-level async functions must
match the Protocol signatures exactly. This is documented in
Backend-Development.md § 13.7.1.
"""
return cast("Fail2BanDbRepository", fail2ban_db_repo)
async def get_app_state(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> ApplicationContext:
"""Provide the application state object for the current request."""
return app_context
async def get_app(request: Request) -> FastAPI:
"""Provide the FastAPI application instance for the current request."""
return request.app
async def get_server_status(app_context: Annotated[ApplicationContext, Depends(get_app_context)]) -> ServerStatus:
"""Return the cached fail2ban server status snapshot from application context."""
if app_context.server_status is None:
return ServerStatus(online=False)
return app_context.server_status
async def get_pending_recovery(
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
) -> PendingRecovery | None:
"""Return the current pending recovery record from application context."""
return app_context.pending_recovery
async def get_jail_service_state(
app_context: Annotated[ApplicationContext, Depends(get_app_context)],
) -> JailServiceState:
"""Return the jail service state holder from runtime state.
Returns:
The JailServiceState containing capability detection cache and
synchronization primitives for jail operations.
"""
return app_context.runtime_state.jail_service_state
async def get_health_probe() -> Callable[[str], Awaitable[ServerStatus]]:
"""Provide the health probe function for checking fail2ban connectivity.
Returns:
A callable that probes the fail2ban socket and returns ServerStatus.
This allows explicit dependency injection to avoid hidden service coupling.
"""
return health_service.probe
async def get_fail2ban_metadata_service() -> object:
"""Provide the Fail2BanMetadataService instance.
Returns:
The singleton Fail2BanMetadataService for resolving fail2ban metadata
(such as the database path) and caching results.
"""
return default_fail2ban_metadata_service
# -----------------------------------------------------------------------
# Service facade dependencies (db + repositories combined)
# These are for routers that need database access through services.
# Routers should depend on these instead of raw database connections.
# -----------------------------------------------------------------------
@dataclass
class SessionServiceContext:
"""Context for session-related database operations.
Combines the database connection and session repository so that
routers don't need to import DbDep directly.
"""
db: aiosqlite.Connection
session_repo: SessionRepository
async def get_session_service_context(
db: Annotated[aiosqlite.Connection, Depends(get_db)],
session_repo: Annotated[SessionRepository, Depends(get_session_repo)],
) -> SessionServiceContext:
"""Provide combined session database context for routers.
Args:
db: Request-scoped database connection.
session_repo: Session repository implementation.
Returns:
SessionServiceContext with both db and repository.
"""
return SessionServiceContext(db=db, session_repo=session_repo)
@dataclass
class BlocklistServiceContext:
"""Context for blocklist-related database operations.
Combines the database connection and blocklist-related repositories
so that routers don't need to import DbDep directly.
"""
db: aiosqlite.Connection
blocklist_repo: BlocklistRepository
import_log_repo: ImportLogRepository
settings_repo: SettingsRepository
async def get_blocklist_service_context(
db: Annotated[aiosqlite.Connection, Depends(get_db)],
blocklist_repo: Annotated[BlocklistRepository, Depends(get_blocklist_repo)],
import_log_repo: Annotated[ImportLogRepository, Depends(get_import_log_repo)],
settings_repo: Annotated[SettingsRepository, Depends(get_settings_repo)],
) -> BlocklistServiceContext:
"""Provide combined blocklist database context for routers.
Args:
db: Request-scoped database connection.
blocklist_repo: Blocklist repository implementation.
import_log_repo: Import log repository implementation.
settings_repo: Settings repository implementation.
Returns:
BlocklistServiceContext with db and all blocklist repositories.
"""
return BlocklistServiceContext(
db=db,
blocklist_repo=blocklist_repo,
import_log_repo=import_log_repo,
settings_repo=settings_repo,
)
@dataclass
class SettingsServiceContext:
"""Context for settings-related database operations.
Combines the database connection and settings repository so that
routers don't need to import DbDep directly.
"""
db: aiosqlite.Connection
settings_repo: SettingsRepository
async def get_settings_service_context(
db: Annotated[aiosqlite.Connection, Depends(get_db)],
settings_repo: Annotated[SettingsRepository, Depends(get_settings_repo)],
) -> SettingsServiceContext:
"""Provide combined settings database context for routers.
Args:
db: Request-scoped database connection.
settings_repo: Settings repository implementation.
Returns:
SettingsServiceContext with both db and repository.
"""
return SettingsServiceContext(db=db, settings_repo=settings_repo)
@dataclass
class BanServiceContext:
"""Context for ban-related database operations.
Combines the database connection and fail2ban DB repository.
"""
db: aiosqlite.Connection
fail2ban_db_repo: Fail2BanDbRepository
async def get_ban_service_context(
db: Annotated[aiosqlite.Connection, Depends(get_db)],
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
) -> BanServiceContext:
"""Provide combined ban database context for routers.
Args:
db: Request-scoped database connection.
fail2ban_db_repo: Fail2Ban DB repository implementation.
Returns:
BanServiceContext with both db and repository.
"""
return BanServiceContext(db=db, fail2ban_db_repo=fail2ban_db_repo)
@dataclass
class HistoryServiceContext:
"""Context for history-related database operations.
Combines database connection and history-related repositories.
"""
db: aiosqlite.Connection
fail2ban_db_repo: Fail2BanDbRepository
history_archive_repo: HistoryArchiveRepository
async def get_history_service_context(
db: Annotated[aiosqlite.Connection, Depends(get_db)],
fail2ban_db_repo: Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)],
history_archive_repo: Annotated[HistoryArchiveRepository, Depends(get_history_archive_repo)],
) -> HistoryServiceContext:
"""Provide combined history database context for routers.
Args:
db: Request-scoped database connection.
fail2ban_db_repo: Fail2Ban DB repository implementation.
history_archive_repo: History archive repository implementation.
Returns:
HistoryServiceContext with db and all history repositories.
"""
return HistoryServiceContext(
db=db,
fail2ban_db_repo=fail2ban_db_repo,
history_archive_repo=history_archive_repo,
)
# Internal database dependency for use by other dependencies only
# Routers should NOT import this - they should use repository dependencies instead
_DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
async def require_auth(
request: Request,
db: Annotated[aiosqlite.Connection, Depends(get_db)],
db: _DbDep,
settings: Annotated[Settings, Depends(get_settings)],
session_cache: Annotated[SessionCache, Depends(get_session_cache)],
session_repo: Annotated[SessionRepository, Depends(get_session_repo)],
) -> Session:
"""Validate the session token and return the active session.
The token is read from the ``bangui_session`` cookie or the
``Authorization: Bearer`` header.
Validated tokens are cached in memory for :data:`_SESSION_CACHE_TTL`
seconds so that concurrent requests sharing the same token avoid repeated
SQLite round-trips. The cache is bypassed on expiry and explicitly
cleared by :func:`invalidate_session_cache` on logout.
Validated tokens may be cached in memory for a short period so that
concurrent requests sharing the same token avoid repeated SQLite
round-trips. This cache is disabled by default because process-local
invalidation is not safe in multi-worker or clustered deployments.
When enabled, entries are bypassed on expiry and explicitly cleared by
the configured session cache backend on logout.
Args:
request: The incoming FastAPI request.
db: Injected aiosqlite connection.
db: Injected aiosqlite connection (for repository operations).
settings: Application settings used for signed session token validation.
session_cache: Session validation cache backend.
session_repo: Session repository for persistence operations.
Returns:
The active :class:`~app.models.auth.Session`.
@@ -120,9 +651,8 @@ async def require_auth(
Raises:
HTTPException: 401 if no valid session token is found.
"""
from app.services import auth_service # noqa: PLC0415
token: str | None = request.cookies.get(_COOKIE_NAME)
token: str | None = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
auth_header: str = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
@@ -135,18 +665,20 @@ async def require_auth(
headers={"WWW-Authenticate": "Bearer"},
)
# Fast path: serve from in-memory cache when the entry is still fresh and
# the session itself has not yet exceeded its own expiry time.
cached = _session_cache.get(token)
cache_enabled = _session_cache_enabled(settings)
if cache_enabled:
cached = session_cache.get(token)
if cached is not None:
session, cache_expires_at = cached
if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat():
return session
# Stale cache entry — evict and fall through to DB.
_session_cache.pop(token, None)
return cached
try:
session = await auth_service.validate_session(db, token)
session = await auth_service.validate_session(
db,
token,
settings.session_secret,
settings.session_secret_previous,
session_repo=session_repo,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -154,11 +686,51 @@ async def require_auth(
headers={"WWW-Authenticate": "Bearer"},
) from exc
_session_cache[token] = (session, time.monotonic() + _SESSION_CACHE_TTL)
if cache_enabled:
session_cache.set(token, session, settings.session_cache_ttl_seconds)
return session
# Convenience type aliases for route signatures.
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
# NOTE: Database connections are NOT exported to routers. Routers should depend on
# repository dependencies (SessionRepoDep, BlocklistRepositoryDep, etc.) instead.
# See Backend-Development.md for the dependency layering rules.
SettingsDep = Annotated[Settings, Depends(get_settings)]
HttpSessionDep = Annotated[aiohttp.ClientSession, Depends(get_http_session)]
SchedulerDep = Annotated[AsyncIOScheduler, Depends(get_scheduler)]
Fail2BanSocketDep = Annotated[str, Depends(get_fail2ban_socket)]
Fail2BanConfigDirDep = Annotated[str, Depends(get_fail2ban_config_dir)]
Fail2BanStartCommandDep = Annotated[str, Depends(get_fail2ban_start_command)]
GeoCacheDep = Annotated[GeoCache, Depends(get_geo_cache)]
ServerStatusDep = Annotated[ServerStatus, Depends(get_server_status)]
PendingRecoveryDep = Annotated[PendingRecovery | None, Depends(get_pending_recovery)]
JailServiceStateDep = Annotated[JailServiceState, Depends(get_jail_service_state)]
HealthProbeDep = Annotated[Callable[[str], Awaitable[ServerStatus]], Depends(get_health_probe)]
SessionCacheDep = Annotated[SessionCache, Depends(get_session_cache)]
SessionRepoDep = Annotated[SessionRepository, Depends(get_session_repo)]
SettingsRepoDep = Annotated[SettingsRepository, Depends(get_settings_repo)]
HistoryArchiveRepositoryDep = Annotated[HistoryArchiveRepository, Depends(get_history_archive_repo)]
BlocklistRepositoryDep = Annotated[BlocklistRepository, Depends(get_blocklist_repo)]
ImportLogRepositoryDep = Annotated[ImportLogRepository, Depends(get_import_log_repo)]
ImportRunRepositoryDep = Annotated[ImportRunRepository, Depends(get_import_run_repo)]
GeoCacheRepositoryDep = Annotated[GeoCacheRepository, Depends(get_geo_cache_repo)]
Fail2BanDbRepositoryDep = Annotated[Fail2BanDbRepository, Depends(get_fail2ban_db_repo)]
AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)]
AppDep = Annotated[FastAPI, Depends(get_app)]
AuthDep = Annotated[Session, Depends(require_auth)]
GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)]
Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)]
# Service context dependencies (db + repositories combined for routers)
# Routers should use these instead of importing DbDep directly.
SessionServiceContextDep = Annotated[SessionServiceContext, Depends(get_session_service_context)]
BlocklistServiceContextDep = Annotated[BlocklistServiceContext, Depends(get_blocklist_service_context)]
SettingsServiceContextDep = Annotated[SettingsServiceContext, Depends(get_settings_service_context)]
BanServiceContextDep = Annotated[BanServiceContext, Depends(get_ban_service_context)]
HistoryServiceContextDep = Annotated[HistoryServiceContext, Depends(get_history_service_context)]
# DEPRECATED: DbDep is provided for backward compatibility only.
# DO NOT use in new code. Use repository dependencies instead (SessionRepoDep, BlocklistRepositoryDep, etc.)
# See Backend-Development.md § 6 for dependency layering rules.
DbDep = _DbDep

View File

@@ -1,53 +1,527 @@
"""Shared domain exception classes used across routers and services."""
"""Shared domain exception classes used across routers and services.
Exception Taxonomy
==================
All domain exceptions inherit from one of these base categories:
- **NotFoundError** (404): Domain entity not found
- **BadRequestError** (400): Invalid input, validation failure, invalid identifiers
- **ConflictError** (409): State conflict, resource already exists, invalid state transition
- **OperationError** (500): Operation failure, write errors
- **ServiceUnavailableError** (503): Infrastructure/external service issues
- **AuthenticationError** (401): Authentication or authorization failure
- **RateLimitError** (429): Rate limit exceeded
Service exceptions inherit from the appropriate category, allowing routers to
handle categories rather than individual exception types. Exception handlers in
main.py register only base category types.
Every exception class has:
- **error_code**: A machine-readable error code for client-side branching
- **get_error_metadata()**: Returns structured metadata for the API response
Example:
def get_jail(name: str) -> Jail:
# Raises JailNotFoundError (subclass of NotFoundError)
...
@app.exception_handler(NotFoundError)
async def handle_not_found(request, exc):
return JSONResponse(status_code=404, content=ErrorResponse(
code=exc.error_code,
detail=str(exc),
metadata=exc.get_error_metadata()
).model_dump())
See Backend-Development.md for the complete exception contract.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
class JailNotFoundError(Exception):
from app.utils.display_sanitizer import sanitize_for_display
if TYPE_CHECKING:
from app.models.response import ErrorMetadata
# ---------------------------------------------------------------------------
# Exception Base Classes (Categories)
# ---------------------------------------------------------------------------
class DomainError(Exception):
"""Base class for all domain exceptions.
All domain exceptions must:
1. Define an `error_code` class attribute (machine-readable error code)
2. Implement `get_error_metadata()` to return structured error context
"""
error_code: str = "internal_error"
def get_error_metadata(self) -> ErrorMetadata:
"""Return structured metadata for the API error response.
Subclasses should override to expose only safe, relevant metadata.
Returns:
A dictionary of metadata key-value pairs safe for client consumption.
"""
return {}
class NotFoundError(DomainError):
"""Raised when a requested domain entity is not found. HTTP 404."""
error_code: str = "not_found"
class BadRequestError(DomainError):
"""Raised for invalid input, validation failures, or invalid identifiers. HTTP 400."""
error_code: str = "invalid_input"
class ConflictError(DomainError):
"""Raised for state conflicts or resource constraints. HTTP 409."""
error_code: str = "conflict"
class OperationError(DomainError):
"""Raised when a domain operation fails (write, update, etc.). HTTP 500."""
error_code: str = "operation_failed"
class ServiceUnavailableError(DomainError):
"""Raised for infrastructure or external service issues. HTTP 503."""
error_code: str = "service_unavailable"
class AuthenticationError(DomainError):
"""Raised for authentication or authorization failures. HTTP 401."""
error_code: str = "authentication_required"
class RateLimitError(DomainError):
"""Raised when a client exceeds rate limits. HTTP 429."""
error_code: str = "rate_limit_exceeded"
def __init__(self, message: str, retry_after_seconds: float = 60.0) -> None:
"""Initialize with a message and optional retry-after time.
Args:
message: Description of the rate limit violation.
retry_after_seconds: Estimated seconds to wait before retrying (default 60).
"""
self.retry_after_seconds: float = retry_after_seconds
super().__init__(message)
def get_error_metadata(self) -> ErrorMetadata:
return {"retry_after_seconds": self.retry_after_seconds}
# ---------------------------------------------------------------------------
# Jail-Specific Exceptions
# ---------------------------------------------------------------------------
class JailNotFoundError(NotFoundError):
"""Raised when a requested jail name does not exist."""
error_code: str = "jail_not_found"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail not found: {name!r}")
super().__init__(f"Jail not found: {sanitize_for_display(name)!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"jail_name": self.name}
class JailOperationError(Exception):
"""Raised when a fail2ban jail operation fails."""
class JailOperationError(ConflictError):
"""Raised when a jail state operation fails (e.g. start/stop already in progress)."""
error_code: str = "jail_operation_failed"
class ConfigValidationError(Exception):
class ConfigValidationError(BadRequestError):
"""Raised when config values fail validation before applying."""
error_code: str = "config_validation_failed"
class ConfigOperationError(Exception):
class ConfigOperationError(BadRequestError):
"""Raised when a config payload update or command fails."""
error_code: str = "config_operation_failed"
class ServerOperationError(Exception):
class ConfigDirError(ServiceUnavailableError):
"""Raised when the fail2ban config directory is missing or inaccessible."""
error_code: str = "config_dir_unavailable"
class ConfigFileNotFoundError(NotFoundError):
"""Raised when a requested config file does not exist."""
error_code: str = "config_file_not_found"
def __init__(self, filename: str) -> None:
"""Initialize with the filename that was not found.
Args:
filename: The filename that could not be located.
"""
self.filename = filename
super().__init__(f"Config file not found: {filename!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"filename": self.filename}
class ConfigFileExistsError(ConflictError):
"""Raised when trying to create a file that already exists."""
error_code: str = "config_file_exists"
def __init__(self, filename: str) -> None:
"""Initialize with the filename that already exists.
Args:
filename: The filename that conflicts.
"""
self.filename = filename
super().__init__(f"Config file already exists: {filename!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"filename": self.filename}
class ConfigFileWriteError(OperationError):
"""Raised when a file cannot be written (permissions, disk full, etc.)."""
error_code: str = "config_file_write_failed"
class ConfigFileNameError(BadRequestError):
"""Raised when a supplied filename is invalid or unsafe."""
error_code: str = "config_file_name_invalid"
class ServerOperationError(BadRequestError):
"""Raised when a server control command (e.g. refresh) fails."""
error_code: str = "server_operation_failed"
class FilterInvalidRegexError(Exception):
class Fail2BanConnectionError(ServiceUnavailableError):
"""Raised when the fail2ban socket is unreachable or returns an error."""
error_code: str = "fail2ban_unreachable"
def __init__(self, message: str, socket_path: str) -> None:
"""Initialize with a human-readable message and the socket path.
Args:
message: Description of the connection problem.
socket_path: The fail2ban socket path that was targeted.
"""
self.socket_path: str = socket_path
super().__init__(f"{message} (socket: {socket_path})")
def get_error_metadata(self) -> ErrorMetadata:
return {"socket_path": self.socket_path}
class Fail2BanProtocolError(ServiceUnavailableError):
"""Raised when the response from fail2ban cannot be parsed."""
error_code: str = "fail2ban_protocol_error"
class FilterInvalidRegexError(BadRequestError):
"""Raised when a regex pattern fails to compile."""
error_code: str = "filter_invalid_regex"
def __init__(self, pattern: str, error: str) -> None:
"""Initialize with the invalid pattern and compile error."""
self.pattern = pattern
self.error = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
def get_error_metadata(self) -> ErrorMetadata:
return {"pattern": self.pattern, "error": self.error}
class JailNotFoundInConfigError(Exception):
class FilterRegexTooLongError(BadRequestError):
"""Raised when a regex pattern exceeds the maximum length."""
error_code: str = "filter_regex_too_long"
def __init__(self, pattern: str, max_length: int) -> None:
"""Initialize with the pattern and maximum allowed length.
Args:
pattern: The regex pattern that is too long.
max_length: The maximum allowed length.
"""
self.pattern = pattern
self.max_length = max_length
self.actual_length = len(pattern)
super().__init__(
f"Regex pattern exceeds maximum length of {max_length} characters: {self.actual_length} provided"
)
def get_error_metadata(self) -> ErrorMetadata:
return {
"pattern_length": self.actual_length,
"max_length": self.max_length,
}
class FilterRegexTimeoutError(BadRequestError):
"""Raised when a regex pattern compilation times out (possible ReDoS attack)."""
error_code: str = "filter_regex_timeout"
def __init__(self, pattern: str, timeout_seconds: int) -> None:
"""Initialize with the pattern and timeout value.
Args:
pattern: The regex pattern that timed out.
timeout_seconds: The timeout value in seconds.
"""
self.pattern = pattern
self.timeout_seconds = timeout_seconds
super().__init__(
f"Regex pattern compilation timed out after {timeout_seconds}s "
f"(possible ReDoS attack). Pattern is too complex or causes catastrophic backtracking."
)
def get_error_metadata(self) -> ErrorMetadata:
return {"timeout_seconds": self.timeout_seconds}
class JailNotFoundInConfigError(NotFoundError):
"""Raised when the requested jail name is not defined in any config file."""
error_code: str = "jail_not_in_config"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail not found in config: {name!r}")
super().__init__(f"Jail not found in config: {sanitize_for_display(name)!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"jail_name": self.name}
class ConfigWriteError(Exception):
class ConfigWriteError(OperationError):
"""Raised when writing a configuration file modification fails."""
error_code: str = "config_write_failed"
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
def get_error_metadata(self) -> ErrorMetadata:
return {"message": self.message}
class JailNameError(BadRequestError):
"""Raised when a jail name contains invalid characters."""
error_code: str = "jail_name_invalid"
class JailAlreadyActiveError(ConflictError):
"""Raised when trying to activate a jail that is already active."""
error_code: str = "jail_already_active"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail is already active: {sanitize_for_display(name)!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"jail_name": self.name}
class JailAlreadyInactiveError(ConflictError):
"""Raised when trying to deactivate a jail that is already inactive."""
error_code: str = "jail_already_inactive"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Jail is already inactive: {sanitize_for_display(name)!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"jail_name": self.name}
class FilterNotFoundError(NotFoundError):
"""Raised when the requested filter name is not found."""
error_code: str = "filter_not_found"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Filter not found: {name!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"filter_name": self.name}
class FilterAlreadyExistsError(ConflictError):
"""Raised when trying to create a filter whose `.conf` or `.local` already exists."""
error_code: str = "filter_already_exists"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Filter already exists: {name!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"filter_name": self.name}
class FilterNameError(BadRequestError):
"""Raised when a filter name contains invalid characters."""
error_code: str = "filter_name_invalid"
class FilterReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` filter with no `.local` override."""
error_code: str = "filter_readonly"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
def get_error_metadata(self) -> ErrorMetadata:
return {"filter_name": self.name}
class ActionNotFoundError(NotFoundError):
"""Raised when the requested action name is not found."""
error_code: str = "action_not_found"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Action not found: {name!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"action_name": self.name}
class ActionAlreadyExistsError(ConflictError):
"""Raised when trying to create an action whose `.conf` or `.local` already exists."""
error_code: str = "action_already_exists"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Action already exists: {name!r}")
def get_error_metadata(self) -> ErrorMetadata:
return {"action_name": self.name}
class ActionNameError(BadRequestError):
"""Raised when an action name contains invalid characters."""
error_code: str = "action_name_invalid"
class ActionReadonlyError(ConflictError):
"""Raised when trying to delete a shipped `.conf` action with no `.local` override."""
error_code: str = "action_readonly"
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
def get_error_metadata(self) -> ErrorMetadata:
return {"action_name": self.name}
class SetupAlreadyCompleteError(ConflictError):
"""Raised when attempting to run setup when it has already been completed."""
error_code: str = "setup_already_complete"
def __init__(self) -> None:
super().__init__("Setup has already been completed.")
class BlocklistSourceNotFoundError(NotFoundError):
"""Raised when a blocklist source is not found."""
error_code: str = "blocklist_source_not_found"
def __init__(self, source_id: int) -> None:
self.source_id = source_id
super().__init__(f"Blocklist source not found: {source_id}")
def get_error_metadata(self) -> ErrorMetadata:
return {"source_id": self.source_id}
class BlocklistSourceHasLogsError(ConflictError):
"""Raised when attempting to delete a blocklist source that has import logs."""
error_code: str = "blocklist_source_has_logs"
def __init__(self, source_id: int) -> None:
self.source_id = source_id
super().__init__(
f"Blocklist source {source_id} cannot be deleted because it has import logs. Delete the import logs first."
)
def get_error_metadata(self) -> ErrorMetadata:
return {"source_id": self.source_id}
class BlocklistSourceAlreadyExistsError(ConflictError):
"""Raised when a blocklist source with the same URL already exists."""
error_code: str = "blocklist_source_already_exists"
def __init__(self, url: str) -> None:
self.url = url
super().__init__(f"Blocklist source with URL already exists: {url}")
def get_error_metadata(self) -> ErrorMetadata:
return {"url": self.url}
class HistoryNotFoundError(NotFoundError):
"""Raised when no history is found for the given IP."""
error_code: str = "history_not_found"
def __init__(self, ip: str) -> None:
self.ip = ip
super().__init__(f"No history found for IP: {ip}")
def get_error_metadata(self) -> ErrorMetadata:
return {"ip": self.ip}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
"""Response mappers.
Convert domain models (from services) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from app.mappers.ban_mappers import (
map_domain_active_ban_list_to_response,
map_domain_active_ban_to_response,
map_domain_ban_trend_to_response,
map_domain_bans_by_country_to_response,
map_domain_bans_by_jail_to_response,
map_domain_dashboard_ban_item_to_response,
map_domain_dashboard_ban_list_to_response,
)
__all__ = [
"map_domain_active_ban_to_response",
"map_domain_active_ban_list_to_response",
"map_domain_dashboard_ban_item_to_response",
"map_domain_dashboard_ban_list_to_response",
"map_domain_bans_by_country_to_response",
"map_domain_ban_trend_to_response",
"map_domain_bans_by_jail_to_response",
]

View File

@@ -0,0 +1,119 @@
"""Ban response mappers.
Convert domain models (from ban_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.ban import (
ActiveBan,
ActiveBanListResponse,
BansByCountryResponse,
BansByJailResponse,
BanTrendBucket,
BanTrendResponse,
DashboardBanItem,
DashboardBanListResponse,
JailBanCount,
)
from app.models.ban_domain import (
DomainActiveBan,
DomainActiveBanList,
DomainBansByCountry,
DomainBansByJail,
DomainBanTrend,
DomainDashboardBanItem,
DomainDashboardBanList,
)
from app.utils.pagination import create_pagination_metadata
def map_domain_active_ban_to_response(domain_ban: DomainActiveBan) -> ActiveBan:
"""Convert a domain active ban to a response model."""
return ActiveBan(
ip=domain_ban.ip,
jail=domain_ban.jail,
banned_at=domain_ban.banned_at,
expires_at=domain_ban.expires_at,
ban_count=domain_ban.ban_count,
country=domain_ban.country,
)
def map_domain_active_ban_list_to_response(
domain_list: DomainActiveBanList,
) -> ActiveBanListResponse:
"""Convert a domain active ban list to a response model."""
return ActiveBanListResponse(
items=[map_domain_active_ban_to_response(ban) for ban in domain_list.bans],
total=domain_list.total,
)
def map_domain_dashboard_ban_item_to_response(
domain_item: DomainDashboardBanItem,
) -> DashboardBanItem:
"""Convert a domain dashboard ban item to a response model."""
return DashboardBanItem(
ip=domain_item.ip,
jail=domain_item.jail,
banned_at=domain_item.banned_at,
service=domain_item.service,
country_code=domain_item.country_code,
country_name=domain_item.country_name,
asn=domain_item.asn,
org=domain_item.org,
ban_count=domain_item.ban_count,
origin=domain_item.origin,
)
def map_domain_dashboard_ban_list_to_response(
domain_list: DomainDashboardBanList,
) -> DashboardBanListResponse:
"""Convert a domain dashboard ban list to a response model."""
return DashboardBanListResponse(
items=[
map_domain_dashboard_ban_item_to_response(item) for item in domain_list.items
],
pagination=create_pagination_metadata(domain_list.total, domain_list.page, domain_list.page_size),
)
def map_domain_bans_by_country_to_response(
domain_data: DomainBansByCountry,
) -> BansByCountryResponse:
"""Convert domain bans-by-country data to a response model."""
return BansByCountryResponse(
countries=domain_data.countries,
country_names=domain_data.country_names,
bans=[map_domain_dashboard_ban_item_to_response(item) for item in domain_data.items],
total=domain_data.total,
)
def map_domain_ban_trend_to_response(domain_trend: DomainBanTrend) -> BanTrendResponse:
"""Convert domain ban trend data to a response model."""
return BanTrendResponse(
buckets=[
BanTrendBucket(timestamp=bucket.timestamp, count=bucket.count)
for bucket in domain_trend.buckets
],
bucket_size=domain_trend.bucket_size,
)
def map_domain_bans_by_jail_to_response(
domain_data: DomainBansByJail,
) -> BansByJailResponse:
"""Convert domain bans-by-jail data to a response model."""
return BansByJailResponse(
jails=[
JailBanCount(jail=jail_count.jail, count=jail_count.count)
for jail_count in domain_data.jails
],
total=domain_data.total,
)

View File

@@ -0,0 +1,141 @@
"""Blocklist response mappers.
Convert domain models (from blocklist_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.blocklist import (
BlocklistSource,
ImportLogEntry,
ImportLogListResponse,
ImportRunResult,
ImportSourceResult,
PreviewResponse,
ScheduleConfig,
ScheduleFrequency,
ScheduleInfo,
)
from app.models.blocklist_domain import (
DomainBlocklistSource,
DomainImportLogEntry,
DomainImportLogList,
DomainImportRunResult,
DomainImportSourceResult,
DomainPreviewResult,
DomainScheduleConfig,
DomainScheduleFrequency,
DomainScheduleInfo,
)
from app.utils.pagination import create_pagination_metadata
def map_domain_blocklist_source_to_response(
domain: DomainBlocklistSource,
) -> BlocklistSource:
"""Convert domain blocklist source to response model."""
return BlocklistSource(
id=domain.id,
name=domain.name,
url=domain.url,
enabled=domain.enabled,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
def map_domain_import_log_entry_to_response(
domain: DomainImportLogEntry,
) -> ImportLogEntry:
"""Convert domain import log entry to response model."""
return ImportLogEntry(
id=domain.id,
source_id=domain.source_id,
source_url=domain.source_url,
timestamp=domain.timestamp,
ips_imported=domain.ips_imported,
ips_skipped=domain.ips_skipped,
errors=domain.errors,
)
def map_domain_import_log_list_to_response(
domain_list: DomainImportLogList,
) -> ImportLogListResponse:
"""Convert domain import log list to response model."""
return ImportLogListResponse(
items=[map_domain_import_log_entry_to_response(i) for i in domain_list.items],
pagination=create_pagination_metadata(
domain_list.total, domain_list.page, domain_list.page_size
),
)
def map_domain_schedule_frequency_to_response(
domain: DomainScheduleFrequency,
) -> ScheduleFrequency:
"""Convert domain schedule frequency to response model."""
return ScheduleFrequency(domain.value)
def map_domain_schedule_config_to_response(
domain: DomainScheduleConfig,
) -> ScheduleConfig:
"""Convert domain schedule config to response model."""
return ScheduleConfig(
frequency=map_domain_schedule_frequency_to_response(domain.frequency),
interval_hours=domain.interval_hours,
hour=domain.hour,
minute=domain.minute,
day_of_week=domain.day_of_week,
)
def map_domain_schedule_info_to_response(domain: DomainScheduleInfo) -> ScheduleInfo:
"""Convert domain schedule info to response model."""
return ScheduleInfo(
config=map_domain_schedule_config_to_response(domain.config),
next_run_at=domain.next_run_at,
last_run_at=domain.last_run_at,
last_run_errors=domain.last_run_errors,
)
def map_domain_preview_result_to_response(domain: DomainPreviewResult) -> PreviewResponse:
"""Convert domain preview result to response model."""
return PreviewResponse(
entries=domain.entries,
total_lines=domain.total_lines,
valid_count=domain.valid_count,
skipped_count=domain.skipped_count,
)
def map_domain_import_source_result_to_response(
domain: DomainImportSourceResult,
) -> ImportSourceResult:
"""Convert domain import source result to response model."""
return ImportSourceResult(
source_id=domain.source_id,
source_url=domain.source_url,
ips_imported=domain.ips_imported,
ips_skipped=domain.ips_skipped,
error=domain.error,
)
def map_domain_import_run_result_to_response(
domain: DomainImportRunResult,
) -> ImportRunResult:
"""Convert domain import run result to response model."""
return ImportRunResult(
results=[
map_domain_import_source_result_to_response(r) for r in domain.results
],
total_imported=domain.total_imported,
total_skipped=domain.total_skipped,
errors_count=domain.errors_count,
)

View File

@@ -0,0 +1,151 @@
"""Config response mappers.
Convert domain models (from config_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.config import (
BantimeEscalation,
FilterConfig,
FilterListResponse,
GlobalConfigResponse,
JailConfig,
JailConfigListResponse,
MapColorThresholdsResponse,
RegexTestResponse,
ServiceStatusResponse,
)
from app.models.config_domain import (
DomainBantimeEscalation,
DomainFilterConfig,
DomainFilterList,
DomainGlobalConfig,
DomainJailConfig,
DomainJailConfigList,
DomainMapColorThresholds,
DomainRegexTest,
DomainServiceStatus,
)
def _map_domain_bantime_escalation(domain: DomainBantimeEscalation) -> BantimeEscalation:
"""Convert domain bantime escalation to response model."""
return BantimeEscalation(
increment=domain.increment,
factor=domain.factor,
formula=domain.formula,
multipliers=domain.multipliers,
max_time=domain.max_time,
rnd_time=domain.rnd_time,
overall_jails=domain.overall_jails,
)
def map_domain_jail_config_to_response(domain: DomainJailConfig) -> JailConfig:
"""Convert domain jail config to response model."""
return JailConfig(
name=domain.name,
ban_time=domain.ban_time,
max_retry=domain.max_retry,
find_time=domain.find_time,
fail_regex=domain.fail_regex,
ignore_regex=domain.ignore_regex,
log_paths=domain.log_paths,
date_pattern=domain.date_pattern,
log_encoding=domain.log_encoding,
backend=domain.backend,
use_dns=domain.use_dns,
prefregex=domain.prefregex,
actions=domain.actions,
bantime_escalation=(
_map_domain_bantime_escalation(domain.bantime_escalation) if domain.bantime_escalation else None
),
)
def map_domain_jail_config_list_to_response(
domain_list: DomainJailConfigList,
) -> JailConfigListResponse:
"""Convert domain jail config list to response model."""
return JailConfigListResponse(
items=[map_domain_jail_config_to_response(c) for c in domain_list.items],
total=domain_list.total,
)
def map_domain_global_config_to_response(domain: DomainGlobalConfig) -> GlobalConfigResponse:
"""Convert domain global config to response model."""
return GlobalConfigResponse(
log_level=domain.log_level,
log_target=domain.log_target,
db_purge_age=domain.db_purge_age,
db_max_matches=domain.db_max_matches,
)
def map_domain_service_status_to_response(
domain: DomainServiceStatus,
) -> ServiceStatusResponse:
"""Convert domain service status to response model."""
return ServiceStatusResponse(
online=domain.online,
version=domain.version or "",
jail_count=domain.jail_count,
total_bans=domain.total_bans,
total_failures=domain.total_failures,
log_level=domain.log_level or "UNKNOWN",
log_target=domain.log_target or "UNKNOWN",
)
def map_domain_map_color_thresholds_to_response(
domain: DomainMapColorThresholds,
) -> MapColorThresholdsResponse:
"""Convert domain map color thresholds to response model."""
return MapColorThresholdsResponse(
threshold_high=domain.threshold_high,
threshold_medium=domain.threshold_medium,
threshold_low=domain.threshold_low,
)
def map_domain_regex_test_to_response(domain: DomainRegexTest) -> RegexTestResponse:
"""Convert domain regex test to response model."""
return RegexTestResponse(
matched=domain.matched,
groups=domain.groups,
error=domain.error,
)
def map_domain_filter_config_to_response(domain: DomainFilterConfig) -> FilterConfig:
"""Convert domain filter config to response model."""
return FilterConfig(
name=domain.name,
filename=domain.filename,
before=domain.before,
after=domain.after,
variables=domain.variables or {},
prefregex=domain.prefregex,
failregex=domain.failregex or [],
ignoreregex=domain.ignoreregex or [],
maxlines=domain.maxlines,
datepattern=domain.datepattern,
journalmatch=domain.journalmatch,
active=domain.active,
used_by_jails=domain.used_by_jails or [],
source_file=domain.source_file,
has_local_override=domain.has_local_override,
)
def map_domain_filter_list_to_response(domain_list: DomainFilterList) -> FilterListResponse:
"""Convert domain filter list to response model."""
return FilterListResponse(
filters=[map_domain_filter_config_to_response(f) for f in domain_list.items],
total=domain_list.total,
)

View File

@@ -0,0 +1,23 @@
"""Health response mappers.
Convert domain models (from health_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.health_domain import DomainServerStatus
from app.models.server import ServerStatus
def map_domain_server_status_to_response(domain: DomainServerStatus) -> ServerStatus:
"""Convert domain server status to response model."""
return ServerStatus(
online=domain.online,
version=domain.version,
active_jails=domain.active_jails,
total_bans=domain.total_bans,
total_failures=domain.total_failures,
)

View File

@@ -0,0 +1,81 @@
"""History response mappers.
Convert domain models (from history_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.history import (
HistoryBanItem,
HistoryListResponse,
IpDetailResponse,
IpTimelineEvent,
)
from app.models.history_domain import (
DomainHistoryBanItem,
DomainHistoryList,
DomainIpDetail,
DomainIpTimelineEvent,
)
from app.utils.pagination import create_pagination_metadata
def map_domain_history_ban_item_to_response(
domain: DomainHistoryBanItem,
) -> HistoryBanItem:
"""Convert domain history ban item to response model."""
return HistoryBanItem(
ip=domain.ip,
jail=domain.jail,
banned_at=domain.banned_at,
ban_count=domain.ban_count,
failures=domain.failures,
matches=domain.matches or [],
country_code=domain.country_code,
country_name=domain.country_name,
asn=domain.asn,
org=domain.org,
)
def map_domain_history_list_to_response(domain: DomainHistoryList) -> HistoryListResponse:
"""Convert domain history list to response model."""
return HistoryListResponse(
items=[map_domain_history_ban_item_to_response(i) for i in domain.items],
pagination=create_pagination_metadata(
domain.total, domain.page, domain.page_size
),
)
def map_domain_ip_timeline_event_to_response(
domain: DomainIpTimelineEvent,
) -> IpTimelineEvent:
"""Convert domain IP timeline event to response model."""
return IpTimelineEvent(
jail=domain.jail,
banned_at=domain.banned_at,
ban_count=domain.ban_count,
failures=domain.failures,
matches=domain.matches or [],
)
def map_domain_ip_detail_to_response(domain: DomainIpDetail) -> IpDetailResponse:
"""Convert domain IP detail to response model."""
return IpDetailResponse(
ip=domain.ip,
total_bans=domain.total_bans,
total_failures=domain.total_failures,
last_ban_at=domain.last_ban_at,
country_code=domain.country_code,
country_name=domain.country_name,
asn=domain.asn,
org=domain.org,
timeline=[
map_domain_ip_timeline_event_to_response(t) for t in (domain.timeline or [])
],
)

View File

@@ -0,0 +1,133 @@
"""Jail response mappers.
Convert domain models (from jail_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.ban import ActiveBan, JailBannedIpsResponse
from app.models.ban_domain import DomainActiveBan
from app.models.jail import (
Jail,
JailDetailResponse,
JailListResponse,
JailStatus,
JailSummary,
)
from app.models.jail_domain import (
DomainJailBannedIps,
DomainBantimeEscalation,
DomainJail,
DomainJailDetail,
DomainJailList,
DomainJailStatus,
DomainJailSummary,
)
from app.utils.pagination import create_pagination_metadata
def _map_domain_jail_status(domain: DomainJailStatus) -> JailStatus:
"""Convert domain jail status to response model."""
return JailStatus(
currently_banned=domain.currently_banned,
total_banned=domain.total_banned,
currently_failed=domain.currently_failed,
total_failed=domain.total_failed,
)
def _map_domain_bantime_escalation(domain: DomainBantimeEscalation) -> object:
"""Convert domain bantime escalation to response model."""
from app.models.config import BantimeEscalation
return BantimeEscalation(
increment=domain.increment,
factor=domain.factor,
formula=domain.formula,
multipliers=domain.multipliers,
max_time=domain.max_time,
rnd_time=domain.rnd_time,
overall_jails=domain.overall_jails,
)
def map_domain_jail_summary_to_response(domain: DomainJailSummary) -> JailSummary:
"""Convert domain jail summary to response model."""
return JailSummary(
name=domain.name,
enabled=domain.enabled,
running=domain.running,
idle=domain.idle,
backend=domain.backend,
find_time=domain.find_time,
ban_time=domain.ban_time,
max_retry=domain.max_retry,
status=_map_domain_jail_status(domain.status) if domain.status else None,
)
def map_domain_jail_list_to_response(domain_list: DomainJailList) -> JailListResponse:
"""Convert domain jail list to response model."""
return JailListResponse(
items=[map_domain_jail_summary_to_response(j) for j in domain_list.items],
total=domain_list.total,
)
def map_domain_jail_to_response(domain: DomainJail) -> Jail:
"""Convert domain jail to response model."""
return Jail(
name=domain.name,
enabled=domain.enabled,
running=domain.running,
idle=domain.idle,
backend=domain.backend,
log_paths=domain.log_paths,
fail_regex=domain.fail_regex,
ignore_regex=domain.ignore_regex,
ignore_ips=domain.ignore_ips,
date_pattern=domain.date_pattern,
log_encoding=domain.log_encoding,
find_time=domain.find_time,
ban_time=domain.ban_time,
max_retry=domain.max_retry,
actions=domain.actions,
bantime_escalation=(
_map_domain_bantime_escalation(domain.bantime_escalation)
if domain.bantime_escalation
else None
),
status=_map_domain_jail_status(domain.status) if domain.status else None,
)
def map_domain_jail_detail_to_response(domain: DomainJailDetail) -> JailDetailResponse:
"""Convert domain jail detail to response model."""
return JailDetailResponse(
jail=map_domain_jail_to_response(domain.jail),
ignore_list=domain.ignore_list,
ignore_self=domain.ignore_self,
)
def map_domain_jail_banned_ips_to_response(
domain: DomainJailBannedIps,
) -> JailBannedIpsResponse:
"""Convert domain jail banned IPs to response model."""
return JailBannedIpsResponse(
items=[
ActiveBan(
ip=ban.ip,
jail=ban.jail,
banned_at=ban.banned_at,
expires_at=ban.expires_at,
ban_count=ban.ban_count,
country=ban.country,
)
for ban in domain.items
],
pagination=create_pagination_metadata(domain.total, domain.page, domain.page_size),
)

View File

@@ -0,0 +1,37 @@
"""Server response mappers.
Convert domain models (from server_service) to response models (for HTTP API).
This is the mapping layer at the router boundary, ensuring the service layer
remains independent of HTTP response shapes.
"""
from __future__ import annotations
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
from app.models.server_domain import DomainServerSettings, DomainServerSettingsResult
from app.utils.pagination import create_pagination_metadata
def map_domain_server_settings_to_response(
domain_settings: DomainServerSettings,
) -> ServerSettings:
"""Convert domain server settings to response model."""
return ServerSettings(
log_level=domain_settings.log_level,
log_target=domain_settings.log_target,
syslog_socket=domain_settings.syslog_socket,
db_path=domain_settings.db_path,
db_purge_age=domain_settings.db_purge_age,
db_max_matches=domain_settings.db_max_matches,
)
def map_domain_server_settings_result_to_response(
domain_result: DomainServerSettingsResult,
) -> ServerSettingsResponse:
"""Convert domain server settings result to response model."""
return ServerSettingsResponse(
settings=map_domain_server_settings_to_response(domain_result.settings),
warnings=domain_result.warnings,
)

View File

@@ -0,0 +1,3 @@
"""Application middleware."""
from __future__ import annotations

View File

@@ -0,0 +1,96 @@
"""Correlation ID middleware for distributed tracing.
This middleware generates or extracts a correlation ID from each request,
stores it in request state, and includes it in error responses.
This enables correlating logs across frontend and backend for a single
user action or request flow.
Correlation IDs flow through the request lifecycle:
1. Frontend generates/passes via `X-Correlation-ID` header
2. Middleware extracts or generates a UUID4
3. Stores on request.state for use by error handlers and log filters
4. Error responses include the correlation ID for client-side correlation
Processing order
-----------------
This middleware must be the outermost in the security-critical chain so it
executes first on incoming requests (outermost = first to see request,
last to see response). In the required chain:
CorrelationIdMiddleware → CsrfMiddleware → RateLimitMiddleware
The registration order in ``main.py`` must be:
RateLimitMiddleware, CsrfMiddleware, CorrelationIdMiddleware
(last registered = outermost in Starlette's reverse application).
"""
from __future__ import annotations
from app.utils.logging_compat import get_logger
import uuid
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse
log = get_logger(__name__)
# Standard header name for correlation IDs (follows W3C Trace Context conventions)
_CORRELATION_ID_HEADER: str = "X-Correlation-ID"
# Key name for storing correlation ID in request state
CORRELATION_ID_CONTEXT_KEY: str = "correlation_id"
class CorrelationIdMiddleware(BaseHTTPMiddleware):
"""Extract or generate correlation ID and store on request state.
For each request, this middleware:
1. Checks for `X-Correlation-ID` header (trusted from frontend)
2. Generates a new UUID4 if header not present
3. Stores on request.state for use by error handlers and log filters
The correlation ID enables tracing a single user action or request flow
across both frontend and backend systems using structured logs.
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""Intercept requests to extract or generate correlation ID.
Args:
request: The incoming HTTP request.
call_next: The next middleware / router handler.
Returns:
The response from the next middleware / router, with correlation ID
in the request state for use by exception handlers.
"""
# Extract correlation ID from request header, or generate a new one
correlation_id: str = request.headers.get(
_CORRELATION_ID_HEADER,
str(uuid.uuid4()),
)
# Store on request.state for use by exception handlers
request.state.correlation_id = correlation_id
log.debug(
"request_received",
extra={"method": request.method, "path": request.url.path},
)
response: StarletteResponse = await call_next(request)
# Add correlation ID to response header so frontend can correlate errors
response.headers[_CORRELATION_ID_HEADER] = correlation_id
return response

View File

@@ -0,0 +1,99 @@
"""CSRF protection middleware for cookie-authenticated state-mutating requests.
This middleware enforces explicit CSRF protection on POST, PUT, DELETE, and PATCH
requests that use cookie-based authentication. Requests must include the custom
header `X-BanGUI-Request: 1` to proceed.
Bearer token authentication (via Authorization header) bypasses this check as it
is not CSRF-vulnerable. GET, HEAD, and OPTIONS requests are also exempt.
Cross-site requests cannot set custom headers without CORS preflight, which the
backend rejects for non-allowed origins, providing defense-in-depth.
Processing order
----------------
This middleware must be the middle component in the security-critical chain:
CorrelationIdMiddleware → CsrfMiddleware → RateLimitMiddleware
It runs after CorrelationIdMiddleware has attached a correlation ID (so rate-limit
errors can include it in their log context), and before RateLimitMiddleware
(so rate-limit counters are only incremented for requests that pass CSRF checks).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from app.utils.logging_compat import get_logger
from fastapi import status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, SESSION_COOKIE_NAME
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse
log = get_logger(__name__)
# HTTP methods that require CSRF protection.
_CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"})
class CsrfMiddleware(BaseHTTPMiddleware):
"""Protect cookie-authenticated state-mutating requests with custom header check.
For requests using POST, PUT, DELETE, or PATCH methods that are authenticated
via the session cookie (not Bearer token), this middleware requires the presence
of a custom header to prevent CSRF attacks. Bearer token requests and safe
HTTP methods are exempt.
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""Intercept requests to enforce CSRF protection.
Args:
request: The incoming HTTP request.
call_next: The next middleware / router handler.
Returns:
Either a 403 Forbidden response if CSRF validation fails, or the
normal router response.
"""
# Skip check for safe methods.
if request.method not in _CSRF_PROTECTED_METHODS:
return await call_next(request)
# Skip check if using Bearer token authentication (not CSRF-vulnerable).
auth_header: str = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return await call_next(request)
# Skip check if not using cookie-based authentication.
if SESSION_COOKIE_NAME not in request.cookies:
return await call_next(request)
# Enforce CSRF header for cookie-authenticated state-mutating requests.
csrf_header: str | None = request.headers.get(CSRF_HEADER_NAME)
if csrf_header != CSRF_HEADER_VALUE:
log.warning(
"csrf_validation_failed",
method=request.method,
path=request.url.path,
has_cookie=True,
csrf_header_present=csrf_header is not None,
)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "CSRF validation failed. Request rejected."},
)
return await call_next(request)

View File

@@ -0,0 +1,107 @@
"""Deprecation header middleware for versioned API endpoints.
Adds ``Deprecation``, ``Sunset``, and ``Link`` response headers to endpoints
that have been scheduled for removal, following RFC 8599 and the BanGUI
API_VERSIONING.md lifecycle policy.
"""
from __future__ import annotations
from datetime import datetime # noqa: TC003 # Used in stringized type annotations (future annotations)
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response as StarletteResponse
class DeprecatedEndpoint:
"""Describes a deprecated API endpoint and its removal schedule."""
__slots__ = ("path_prefix", "sunset_date", "successor_url")
def __init__(
self,
path_prefix: str,
sunset_date: datetime,
successor_url: str | None = None,
) -> None:
self.path_prefix = path_prefix
self.sunset_date = sunset_date
self.successor_url = successor_url
# Registry of deprecated endpoints.
# Add entries here when an endpoint is scheduled for removal.
# sunset_date must be a timezone-aware datetime in UTC.
_DEPRECATED_ENDPOINTS: list[DeprecatedEndpoint] = []
def register_deprecated_endpoint(
path_prefix: str,
sunset_date: datetime,
successor_url: str | None = None,
) -> None:
"""Register a deprecated endpoint for deprecation header injection."""
_DEPRECATED_ENDPOINTS.append(
DeprecatedEndpoint(path_prefix, sunset_date, successor_url)
)
def _is_deprecated(path: str) -> DeprecatedEndpoint | None:
for endpoint in _DEPRECATED_ENDPOINTS:
if path.startswith(endpoint.path_prefix):
return endpoint
return None
def _format_rfc5322(dt: datetime) -> str:
return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
class DeprecationHeaderMiddleware(BaseHTTPMiddleware):
"""Inject deprecation headers on responses from deprecated endpoints.
For any response from a path registered in ``_DEPRECATED_ENDPOINTS``,
this middleware appends:
- ``Deprecation: true``
- ``Sunset: <RFC-5322 date>``
- ``Link: <<successor_url>>; rel="successor-version"`` (if successor_url set)
The middleware runs after the response is generated so it has access
to the final status code and can choose to only add headers for 2xx
responses (non-error responses from a deprecated endpoint are what
clients need to be warned about).
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
response: StarletteResponse = await call_next(request)
# Add deprecation headers for 2xx and 3xx responses from deprecated paths.
# Skipping 4xx/5xx avoids polluting error responses with deprecation headers.
if response.status_code < 200 or response.status_code >= 400:
return response
deprecated = _is_deprecated(request.url.path)
if deprecated is None:
return response
# All deprecation dates are stored in UTC.
sunset_str = _format_rfc5322(deprecated.sunset_date)
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = sunset_str
if deprecated.successor_url:
response.headers["Link"] = f'<{deprecated.successor_url}>; rel="successor-version"'
return response

View File

@@ -0,0 +1,95 @@
"""Metrics collection middleware for BanGUI.
Tracks HTTP request count, latency, and active requests.
Excludes the /metrics endpoint to prevent recursive metrics collection.
"""
from __future__ import annotations
import re
import time
from typing import TYPE_CHECKING
from app.utils.logging_compat import get_logger
from starlette.middleware.base import BaseHTTPMiddleware
from app.utils.metrics import http_active_requests, http_request_count, http_request_latency
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from starlette.responses import Response
log = get_logger(__name__)
# Paths excluded from detailed metrics (to avoid cardinality explosion)
EXCLUDED_PATHS = {"/metrics", "/health", "/api/health"}
# Pattern to normalize endpoint paths (convert IDs to placeholders)
PATH_PATTERN = re.compile(r"/api/[^/]+/[a-f0-9\-]{36}|/api/[^/]+/\d+")
def _normalize_path(path: str) -> str:
"""Normalize path by replacing IDs with placeholders.
Converts paths like /api/resource/123 to /api/resource/{id}
to prevent cardinality explosion from dynamic IDs.
Args:
path: The request path.
Returns:
Normalized path with IDs replaced by {id}.
"""
return PATH_PATTERN.sub(r"/api/{id}", path)
class MetricsMiddleware(BaseHTTPMiddleware):
"""Middleware to collect Prometheus metrics for HTTP requests."""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Collect metrics for the request and response.
Args:
request: The incoming request.
call_next: The next middleware/route handler.
Returns:
The response.
"""
# Skip metrics for excluded paths
if request.url.path in EXCLUDED_PATHS:
return await call_next(request)
method: str = request.method
endpoint: str = _normalize_path(request.url.path)
# Track active requests
http_active_requests.labels(method=method, endpoint=endpoint).inc()
start_time = time.perf_counter()
status_code = 500
try:
response: Response = await call_next(request)
status_code = response.status_code
return response
finally:
# Record metrics
duration: float = time.perf_counter() - start_time
http_request_latency.labels(method=method, endpoint=endpoint).observe(duration)
http_request_count.labels(method=method, endpoint=endpoint, status_code=status_code).inc()
http_active_requests.labels(method=method, endpoint=endpoint).dec()
log.debug(
"http_request_recorded",
method=method,
endpoint=endpoint,
status_code=status_code,
duration_ms=duration * 1000,
)

View File

@@ -0,0 +1,178 @@
"""Global rate limiting middleware.
Implements per-IP request rate limiting for all endpoints using a configurable
sliding window algorithm. Intercepts requests before they reach route handlers
and blocks those exceeding the per-IP limit with a 429 response.
Rate limits can be customized per endpoint or use a global default.
IP addresses are extracted using the same trusted-proxy-aware logic as
authentication to ensure consistent behavior across all rate limiting.
**Process-local implementation** — Each worker process maintains its own
independent counter store. In multi-worker deployments (N workers), an
attacker can send up to N × limit requests before any single worker triggers
the limit. This is a fundamental limitation of in-process stores.
**Short-term mitigation:** Deploy with a single worker (enforced by the
scheduler lock). The startup warning log documents this constraint.
**Long-term solution:** Replace the in-process GlobalRateLimiter with a
Redis-backed adapter that uses atomic INCR + EXPIRE semantics. The
check_allowed() and check_allowed_for_bucket() interfaces are designed
to make this swap-in without touching middleware or router code.
Processing order
----------------
This middleware must be the innermost in the security-critical chain:
CorrelationIdMiddleware → CsrfMiddleware → RateLimitMiddleware
Rate limiting is last so that requests blocked by CsrfMiddleware do not
consume rate-limit budget, and so that rate-limit log entries (which are
unusual and potentially suspicious) always carry a correlation ID for tracing.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, Response
from app.exceptions import RateLimitError
from app.utils.client_ip import get_client_ip
from app.utils.logging_compat import get_logger
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from app.config import Settings
from app.utils.rate_limiter import GlobalRateLimiter
log = get_logger(__name__)
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Enforce per-IP request rate limiting on matching endpoints.
Tracks requests per IP and blocks further requests if the limit is exceeded.
Uses the application's GlobalRateLimiter instance and trusted-proxy settings
for consistent IP extraction.
Each middleware instance is scoped to a set of path prefixes (or all paths
if no prefixes are given). This allows multiple instances to coexist
without double-counting requests.
"""
def __init__(
self,
app: object,
rate_limiter: GlobalRateLimiter,
settings: Settings,
bucket_override: str | None = None,
bucket_max_requests: int | None = None,
bucket_window_seconds: int | None = None,
path_prefixes: list[str] | None = None,
skip_paths: list[str] | None = None,
) -> None:
"""Initialize the rate limit middleware.
Args:
app: The FastAPI application.
rate_limiter: The GlobalRateLimiter instance to use for checking limits.
settings: Application settings (used for trusted proxies).
bucket_override: Optional named bucket to use instead of the default limiter.
bucket_max_requests: Max requests for the bucket override.
bucket_window_seconds: Window for the bucket override.
path_prefixes: If provided, only apply rate limiting to paths that
start with one of these prefixes. If ``None``, all paths are
matched.
skip_paths: If provided, do not apply rate limiting to paths that
start with one of these prefixes. Evaluated after
``path_prefixes``.
"""
super().__init__(app) # type: ignore[arg-type]
self.rate_limiter: GlobalRateLimiter = rate_limiter
self.settings: Settings = settings
self.bucket_override = bucket_override
self.bucket_max_requests = bucket_max_requests
self.bucket_window_seconds = bucket_window_seconds
self.path_prefixes = path_prefixes or []
self.skip_paths = skip_paths or []
def _should_check(self, path: str) -> bool:
"""Return whether the given path should be rate-limited by this instance.
Args:
path: The request URL path.
Returns:
``True`` if this instance should enforce its limit on the path.
"""
if self.skip_paths and any(path.startswith(p) for p in self.skip_paths):
return False
if self.path_prefixes:
return any(path.startswith(p) for p in self.path_prefixes)
return True
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Check rate limit before passing request to next middleware/handler.
If the client IP has exceeded the request limit, returns a 429 response
immediately. Otherwise passes the request through normally.
Args:
request: The incoming HTTP request.
call_next: Callable to pass the request to the next middleware/handler.
Returns:
A response object (either rate limit response or from handler).
"""
path = request.url.path
if not self._should_check(path):
return await call_next(request)
client_ip = get_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.bucket_override and self.bucket_max_requests and self.bucket_window_seconds:
is_allowed, retry_after = self.rate_limiter.check_allowed_for_bucket(
self.bucket_override,
client_ip,
self.bucket_max_requests,
self.bucket_window_seconds,
)
else:
is_allowed, retry_after = self.rate_limiter.check_allowed(client_ip)
if not is_allowed:
log.warning(
"global_rate_limit_exceeded",
client_ip=client_ip,
path=path,
method=request.method,
retry_after=retry_after,
)
rate_limit_error = RateLimitError(
"Too many requests. Please try again later.",
retry_after_seconds=retry_after,
)
return JSONResponse(
status_code=429,
content={
"code": "rate_limit_exceeded",
"detail": str(rate_limit_error),
"metadata": rate_limit_error.get_error_metadata(),
"correlation_id": getattr(request.state, "correlation_id", None),
},
headers={"Retry-After": str(int(retry_after))},
)
response: Response = await call_next(request)
return response

View File

@@ -0,0 +1,49 @@
"""Shared types and constants used across multiple model modules.
This module defines types and constants that are used by multiple
model modules, ensuring a single source of truth for cross-model types.
"""
import math
from typing import Literal
#: The four supported time-range presets for the dashboard views.
TimeRange = Literal["24h", "7d", "30d", "365d"]
#: Number of seconds represented by each preset.
TIME_RANGE_SECONDS: dict[str, int] = {
"24h": 24 * 3600,
"7d": 7 * 24 * 3600,
"30d": 30 * 24 * 3600,
"365d": 365 * 24 * 3600,
}
#: Bucket size in seconds for each time-range preset.
BUCKET_SECONDS: dict[str, int] = {
"24h": 3_600, # 1 hour → 24 buckets
"7d": 6 * 3_600, # 6 hours → 28 buckets
"30d": 86_400, # 1 day → 30 buckets
"365d": 7 * 86_400, # 7 days → ~53 buckets
}
#: Human-readable bucket size label for each time-range preset.
BUCKET_SIZE_LABEL: dict[str, str] = {
"24h": "1h",
"7d": "6h",
"30d": "1d",
"365d": "7d",
}
def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely.
Args:
range_: One of the supported time-range presets.
Returns:
Ceiling division of the range duration by the bucket size so that
the last bucket is included even when the window is not an exact
multiple of the bucket size.
"""
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])

View File

@@ -3,43 +3,48 @@
Request, response, and domain models used by the auth router and service.
"""
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.response import BanGuiBaseModel
class LoginRequest(BaseModel):
class LoginRequest(BanGuiBaseModel):
"""Payload for ``POST /api/auth/login``."""
model_config = ConfigDict(strict=True)
password: str = Field(
...,
max_length=72,
description="Master password to authenticate with (max 72 bytes due to bcrypt truncation).",
)
password: str = Field(..., description="Master password to authenticate with.")
class LoginResponse(BaseModel):
class LoginResponse(BanGuiBaseModel):
"""Successful login response.
The session token is also set as an ``HttpOnly`` cookie by the router.
This model documents the JSON body for API-first consumers.
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie by the
router, protecting it from JavaScript access. The JSON body contains only
the expiry timestamp, allowing the frontend to know when to prompt for
re-authentication.
For programmatic API clients that require a token in the response body,
use ``POST /api/auth/token`` instead, which does not set a cookie.
"""
model_config = ConfigDict(strict=True)
token: str = Field(..., description="Session token for use in subsequent requests.")
expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.")
class LogoutResponse(BaseModel):
class LogoutResponse(BanGuiBaseModel):
"""Response body for ``POST /api/auth/logout``."""
model_config = ConfigDict(strict=True)
message: str = Field(default="Logged out successfully.")
class SessionValidResponse(BanGuiBaseModel):
"""Response for ``GET /api/auth/session`` confirming session validity."""
class Session(BaseModel):
valid: bool = Field(default=True, description="Whether the session is valid and active.")
class Session(BanGuiBaseModel):
"""Internal domain model representing a persisted session record."""
model_config = ConfigDict(strict=True)
id: int = Field(..., description="Auto-incremented row ID.")
token: str = Field(..., description="Opaque session token.")
created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.")

View File

@@ -3,41 +3,22 @@
Request, response, and domain models used by the ban router and service.
"""
import math
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field, field_validator
# ---------------------------------------------------------------------------
# Time-range selector
# ---------------------------------------------------------------------------
#: The four supported time-range presets for the dashboard views.
TimeRange = Literal["24h", "7d", "30d", "365d"]
#: Number of seconds represented by each preset.
TIME_RANGE_SECONDS: dict[str, int] = {
"24h": 24 * 3600,
"7d": 7 * 24 * 3600,
"30d": 30 * 24 * 3600,
"365d": 365 * 24 * 3600,
}
from app.models.response import BanGuiBaseModel, CollectionResponse, PaginatedListResponse
class BanRequest(BaseModel):
class BanRequest(BanGuiBaseModel):
"""Payload for ``POST /api/bans`` (ban an IP)."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address to ban.")
jail: str = Field(..., description="Jail in which to apply the ban.")
class UnbanRequest(BaseModel):
class UnbanRequest(BanGuiBaseModel):
"""Payload for ``DELETE /api/bans`` (unban an IP)."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address to unban.")
jail: str | None = Field(
default=None,
@@ -48,14 +29,12 @@ class UnbanRequest(BaseModel):
description="When ``true`` the IP is unbanned from every jail.",
)
#: Discriminator literal for the origin of a ban.
BanOrigin = Literal["blocklist", "selfblock"]
#: Jail name used by the blocklist import service.
BLOCKLIST_JAIL: str = "blocklist-import"
def _derive_origin(jail: str) -> BanOrigin:
"""Derive the ban origin from the jail name.
@@ -68,12 +47,9 @@ def _derive_origin(jail: str) -> BanOrigin:
"""
return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock"
class Ban(BaseModel):
class Ban(BanGuiBaseModel):
"""Domain model representing a single active or historical ban record."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -91,29 +67,38 @@ class Ban(BaseModel):
description="Whether this ban came from a blocklist import or fail2ban itself.",
)
@field_validator("country")
@classmethod
def _normalize_empty_country(cls, v: str | None) -> str | None:
"""Coerce empty strings to None for country.
class BanResponse(BaseModel):
Geo enrichment may produce an empty string instead of None for
unresolved IPs, which breaks frontend truthiness checks.
"""
if v == "":
return None
return v
class BanResponse(BanGuiBaseModel):
"""Response containing a single ban record."""
model_config = ConfigDict(strict=True)
ban: Ban
class BanListResponse(PaginatedListResponse[Ban]):
"""Paginated list of ban records.
class BanListResponse(BaseModel):
"""Paginated list of ban records."""
Request: `GET /api/bans` with optional pagination and filter parameters.
Response: Paginated collection of ban records with total count.
model_config = ConfigDict(strict=True)
Note: Unlike most list endpoints, this endpoint uses `page` and `page_size`
for pagination. When using this response, ensure the router provides these fields.
"""
bans: list[Ban] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total number of matching records.")
pass
class ActiveBan(BaseModel):
class ActiveBan(BanGuiBaseModel):
"""A currently active ban entry returned by ``GET /api/bans/active``."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail holding the ban.")
banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.")
@@ -124,38 +109,46 @@ class ActiveBan(BaseModel):
ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.")
country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.")
@field_validator("country")
@classmethod
def _normalize_empty_country(cls, v: str | None) -> str | None:
"""Coerce empty strings to None for country.
class ActiveBanListResponse(BaseModel):
"""List of all currently active bans across all jails."""
Geo enrichment may produce an empty string instead of None for
unresolved IPs, which breaks frontend truthiness checks.
"""
if v == "":
return None
return v
model_config = ConfigDict(strict=True)
class ActiveBanListResponse(CollectionResponse[ActiveBan]):
"""List of all currently active bans across all jails.
bans: list[ActiveBan] = Field(default_factory=list)
total: int = Field(..., ge=0)
Request: `GET /api/bans/active` with optional filter parameters.
Response: Non-paginated collection of currently active bans with total count.
Note: This endpoint does not support pagination. All matching bans are returned.
For paginated results, use individual jail endpoints or the dashboard ban-list view.
"""
class UnbanAllResponse(BaseModel):
pass
class UnbanAllResponse(BanGuiBaseModel):
"""Response for ``DELETE /api/bans/all``."""
model_config = ConfigDict(strict=True)
message: str = Field(..., description="Human-readable summary of the operation.")
count: int = Field(..., ge=0, description="Number of IPs that were unbanned.")
# ---------------------------------------------------------------------------
# Dashboard ban-list view models
# ---------------------------------------------------------------------------
class DashboardBanItem(BaseModel):
class DashboardBanItem(BanGuiBaseModel):
"""A single row in the dashboard ban-list table.
Populated from the fail2ban database and enriched with geo data.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -185,19 +178,30 @@ class DashboardBanItem(BaseModel):
description="Whether this ban came from a blocklist import or fail2ban itself.",
)
@field_validator("country_code")
@classmethod
def _normalize_empty_country_code(cls, v: str | None) -> str | None:
"""Coerce empty strings to None for country_code.
class DashboardBanListResponse(BaseModel):
"""Paginated dashboard ban-list response."""
The geo enrichment layer may produce an empty string instead of None
for unresolved IPs. Frontend type narrowing uses truthiness, so an
empty string would slip through ``if (ban.country_code)`` checks and
appear as a falsy-but-not-null value — breaking UI rendering.
"""
if v == "":
return None
return v
model_config = ConfigDict(strict=True)
class DashboardBanListResponse(PaginatedListResponse[DashboardBanItem]):
"""Paginated dashboard ban-list response.
items: list[DashboardBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total bans in the selected time window.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
Request: `GET /api/dashboard/bans` with time range, page, and filter parameters.
Response: Paginated collection of dashboard ban items with geo-enrichment.
"""
pass
class BansByCountryResponse(BaseModel):
class BansByCountryResponse(BanGuiBaseModel):
"""Response for the bans-by-country aggregation endpoint.
Contains a per-country ban count, a human-readable country name map, and
@@ -206,8 +210,6 @@ class BansByCountryResponse(BaseModel):
single request.
"""
model_config = ConfigDict(strict=True)
countries: dict[str, int] = Field(
default_factory=dict,
description="ISO 3166-1 alpha-2 country code → ban count.",
@@ -222,56 +224,19 @@ class BansByCountryResponse(BaseModel):
)
total: int = Field(..., ge=0, description="Total ban count in the window.")
# ---------------------------------------------------------------------------
# Trend endpoint models
# ---------------------------------------------------------------------------
#: Bucket size in seconds for each time-range preset.
BUCKET_SECONDS: dict[str, int] = {
"24h": 3_600, # 1 hour → 24 buckets
"7d": 6 * 3_600, # 6 hours → 28 buckets
"30d": 86_400, # 1 day → 30 buckets
"365d": 7 * 86_400, # 7 days → ~53 buckets
}
#: Human-readable bucket size label for each time-range preset.
BUCKET_SIZE_LABEL: dict[str, str] = {
"24h": "1h",
"7d": "6h",
"30d": "1d",
"365d": "7d",
}
def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely.
Args:
range_: One of the supported time-range presets.
Returns:
Ceiling division of the range duration by the bucket size so that
the last bucket is included even when the window is not an exact
multiple of the bucket size.
"""
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
class BanTrendBucket(BaseModel):
class BanTrendBucket(BanGuiBaseModel):
"""A single time bucket in the ban trend series."""
model_config = ConfigDict(strict=True)
timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.")
count: int = Field(..., ge=0, description="Number of bans that started in this bucket.")
class BanTrendResponse(BaseModel):
class BanTrendResponse(BanGuiBaseModel):
"""Response for the ``GET /api/dashboard/bans/trend`` endpoint."""
model_config = ConfigDict(strict=True)
buckets: list[BanTrendBucket] = Field(
default_factory=list,
description="Time-ordered list of ban-count buckets covering the full window.",
@@ -281,55 +246,37 @@ class BanTrendResponse(BaseModel):
description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').",
)
# ---------------------------------------------------------------------------
# By-jail endpoint models
# ---------------------------------------------------------------------------
class JailBanCount(BaseModel):
class JailBanCount(BanGuiBaseModel):
"""A single jail entry in the bans-by-jail aggregation."""
model_config = ConfigDict(strict=True)
jail: str = Field(..., description="Jail name.")
count: int = Field(..., ge=0, description="Number of bans recorded in this jail.")
class BansByJailResponse(BaseModel):
class BansByJailResponse(BanGuiBaseModel):
"""Response for the ``GET /api/dashboard/bans/by-jail`` endpoint."""
model_config = ConfigDict(strict=True)
jails: list[JailBanCount] = Field(
default_factory=list,
description="Jails ordered by ban count descending.",
)
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
# ---------------------------------------------------------------------------
# Jail-specific paginated bans
# ---------------------------------------------------------------------------
class JailBannedIpsResponse(BaseModel):
class JailBannedIpsResponse(PaginatedListResponse[ActiveBan]):
"""Paginated response for ``GET /api/jails/{name}/banned``.
Contains only the current page of active ban entries for a single jail,
geo-enriched exclusively for the page slice to avoid rate-limit issues.
Request: `GET /api/jails/{name}/banned` with page and page_size parameters.
Response: Paginated collection of active bans for the specified jail.
"""
model_config = ConfigDict(strict=True)
items: list[ActiveBan] = Field(
default_factory=list,
description="Active ban entries for the current page.",
)
total: int = Field(
...,
ge=0,
description="Total matching entries (after applying the search filter).",
)
page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.")
pass

View File

@@ -0,0 +1,110 @@
"""Ban domain models (DTOs).
Internal domain-focused models used by ban_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.ban` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
# Domain-specific ban origin type
BanOriginDomain = Literal["blocklist", "selfblock"]
@dataclass(frozen=True)
class DomainActiveBan:
"""A currently active ban entry (domain model).
This is the service-layer representation, independent of API response shape.
"""
ip: str
jail: str
banned_at: str | None = None
expires_at: str | None = None
ban_count: int = 1
country: str | None = None
@dataclass(frozen=True)
class DomainActiveBanList:
"""List of currently active bans (domain model)."""
bans: list[DomainActiveBan]
total: int
@dataclass(frozen=True)
class DomainDashboardBanItem:
"""A single row in the dashboard ban-list table (domain model).
Populated from the fail2ban database and enriched with geo data.
"""
ip: str
jail: str
banned_at: str
service: str | None = None
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
ban_count: int = 1
origin: BanOriginDomain = "selfblock"
@dataclass(frozen=True)
class DomainDashboardBanList:
"""Paginated dashboard ban-list (domain model)."""
items: list[DomainDashboardBanItem]
total: int
page: int
page_size: int
@dataclass(frozen=True)
class DomainBansByCountry:
"""Bans aggregated by country (domain model)."""
countries: dict[str, int]
country_names: dict[str, str]
items: list[DomainDashboardBanItem]
total: int
@dataclass(frozen=True)
class DomainBanTrendBucket:
"""A single time bucket in the ban trend series (domain model)."""
timestamp: str
count: int
@dataclass(frozen=True)
class DomainBanTrend:
"""Ban trend data over time (domain model)."""
buckets: list[DomainBanTrendBucket]
bucket_size: str
@dataclass(frozen=True)
class DomainJailBanCount:
"""Ban count for a single jail (domain model)."""
jail: str
count: int
@dataclass(frozen=True)
class DomainBansByJail:
"""Bans aggregated by jail (domain model)."""
jails: list[DomainJailBanCount]
total: int

View File

@@ -8,18 +8,18 @@ from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from pydantic import AnyHttpUrl, ConfigDict, Field
from app.models.response import BanGuiBaseModel, PaginatedListResponse
# ---------------------------------------------------------------------------
# Blocklist source
# ---------------------------------------------------------------------------
class BlocklistSource(BaseModel):
class BlocklistSource(BanGuiBaseModel):
"""Domain model for a blocklist source definition."""
model_config = ConfigDict(strict=True)
id: int
name: str
url: str
@@ -28,31 +28,33 @@ class BlocklistSource(BaseModel):
updated_at: str
class BlocklistSourceCreate(BaseModel):
"""Payload for ``POST /api/blocklists``."""
class BlocklistSourceCreate(BanGuiBaseModel):
"""Payload for ``POST /api/blocklists``.
model_config = ConfigDict(strict=True)
URL must use http/https scheme. The hostname must resolve to a public IP
(not private, loopback, link-local, or reserved). Validation happens
asynchronously in the service layer.
"""
name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.")
url: str = Field(..., min_length=1, description="URL of the blocklist file.")
url: AnyHttpUrl = Field(..., description="URL of the blocklist file (http/https only).")
enabled: bool = Field(default=True)
class BlocklistSourceUpdate(BaseModel):
"""Payload for ``PUT /api/blocklists/{id}``. All fields are optional."""
class BlocklistSourceUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/blocklists/{id}``. All fields are optional.
model_config = ConfigDict(strict=True)
If URL is provided, it must use http/https scheme.
"""
name: str | None = Field(default=None, min_length=1, max_length=100)
url: str | None = Field(default=None)
url: AnyHttpUrl | None = Field(default=None)
enabled: bool | None = Field(default=None)
class BlocklistListResponse(BaseModel):
class BlocklistListResponse(BanGuiBaseModel):
"""Response for ``GET /api/blocklists``."""
model_config = ConfigDict(strict=True)
sources: list[BlocklistSource] = Field(default_factory=list)
@@ -61,30 +63,49 @@ class BlocklistListResponse(BaseModel):
# ---------------------------------------------------------------------------
class ImportLogEntry(BaseModel):
class ImportLogEntry(BanGuiBaseModel):
"""A single blocklist import run record."""
model_config = ConfigDict(strict=True)
id: int
source_id: int | None
source_url: str
timestamp: str
timestamp: int
ips_imported: int
ips_skipped: int
errors: str | None
class ImportLogListResponse(BaseModel):
"""Response for ``GET /api/blocklists/log``."""
class ImportLogListResponse(PaginatedListResponse[ImportLogEntry]):
"""Response for ``GET /api/blocklists/log``.
model_config = ConfigDict(strict=True)
Paginated list of all blocklist import runs with timestamps, source info,
and per-source import/skip counts.
"""
items: list[ImportLogEntry] = Field(default_factory=list)
total: int = Field(..., ge=0)
page: int = Field(default=1, ge=1)
page_size: int = Field(default=50, ge=1)
total_pages: int = Field(default=1, ge=1)
pass
# ---------------------------------------------------------------------------
# Import run tracking (for idempotency)
# ---------------------------------------------------------------------------
class ImportRunEntry(BanGuiBaseModel):
"""Tracks a unique blocklist import run by source and content hash.
Used to detect re-runs and prevent duplicate bans when the scheduler
retries after a crash.
"""
id: int
source_id: int
content_hash: str
status: str # 'pending' | 'completed' | 'failed'
imported_count: int
skipped_count: int
error_message: str | None
created_at: str
updated_at: str
# ---------------------------------------------------------------------------
@@ -100,7 +121,7 @@ class ScheduleFrequency(StrEnum):
weekly = "weekly"
class ScheduleConfig(BaseModel):
class ScheduleConfig(BanGuiBaseModel):
"""Import schedule configuration.
The interpretation of fields depends on *frequency*:
@@ -110,8 +131,10 @@ class ScheduleConfig(BaseModel):
- ``weekly``: additionally uses ``day_of_week`` (0=Monday … 6=Sunday).
"""
# No strict=True here: FastAPI and json.loads() both supply enum values as
# plain strings; strict mode would reject string→enum coercion.
# FastAPI and json.loads() both supply enum values as plain strings;
# strict mode would reject string→enum coercion, so we override the
# base model_config for this model only.
model_config = ConfigDict(strict=False)
frequency: ScheduleFrequency = ScheduleFrequency.daily
interval_hours: int = Field(default=24, ge=1, le=168, description="Used when frequency=hourly")
@@ -125,11 +148,9 @@ class ScheduleConfig(BaseModel):
)
class ScheduleInfo(BaseModel):
class ScheduleInfo(BanGuiBaseModel):
"""Current schedule configuration together with runtime metadata."""
model_config = ConfigDict(strict=True)
config: ScheduleConfig
next_run_at: str | None
last_run_at: str | None
@@ -142,11 +163,9 @@ class ScheduleInfo(BaseModel):
# ---------------------------------------------------------------------------
class ImportSourceResult(BaseModel):
class ImportSourceResult(BanGuiBaseModel):
"""Result of importing a single blocklist source."""
model_config = ConfigDict(strict=True)
source_id: int | None
source_url: str
ips_imported: int
@@ -154,11 +173,9 @@ class ImportSourceResult(BaseModel):
error: str | None
class ImportRunResult(BaseModel):
class ImportRunResult(BanGuiBaseModel):
"""Aggregated result from a full import run across all enabled sources."""
model_config = ConfigDict(strict=True)
results: list[ImportSourceResult] = Field(default_factory=list)
total_imported: int
total_skipped: int
@@ -170,11 +187,9 @@ class ImportRunResult(BaseModel):
# ---------------------------------------------------------------------------
class PreviewResponse(BaseModel):
class PreviewResponse(BanGuiBaseModel):
"""Response for ``GET /api/blocklists/{id}/preview``."""
model_config = ConfigDict(strict=True)
entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries")
total_lines: int
valid_count: int

View File

@@ -0,0 +1,108 @@
"""Blocklist domain models.
Internal domain-focused models used by blocklist_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.blocklist` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
@dataclass(frozen=True)
class DomainBlocklistSource:
"""Blocklist source definition (domain model)."""
id: int
name: str
url: str
enabled: bool
created_at: str
updated_at: str
@dataclass(frozen=True)
class DomainImportLogEntry:
"""A single blocklist import run record (domain model)."""
id: int
source_id: int | None
source_url: str
timestamp: str
ips_imported: int
ips_skipped: int
errors: str | None
@dataclass(frozen=True)
class DomainImportLogList:
"""Paginated list of import log entries (domain model)."""
items: list[DomainImportLogEntry]
total: int
page: int
page_size: int
class DomainScheduleFrequency(StrEnum):
"""Available import schedule frequency presets (domain model)."""
hourly = "hourly"
daily = "daily"
weekly = "weekly"
@dataclass(frozen=True)
class DomainScheduleConfig:
"""Import schedule configuration (domain model)."""
frequency: DomainScheduleFrequency
interval_hours: int = 24
hour: int = 3
minute: int = 0
day_of_week: int = 0
@dataclass(frozen=True)
class DomainScheduleInfo:
"""Current schedule configuration with runtime metadata (domain model)."""
config: DomainScheduleConfig
next_run_at: str | None = None
last_run_at: str | None = None
last_run_errors: bool | None = None
@dataclass(frozen=True)
class DomainPreviewResult:
"""Result of previewing a blocklist URL (domain model)."""
entries: list[str]
total_lines: int
valid_count: int
skipped_count: int
@dataclass(frozen=True)
class DomainImportSourceResult:
"""Result of importing a single blocklist source (domain model)."""
source_id: int | None
source_url: str
ips_imported: int
ips_skipped: int
error: str | None
@dataclass(frozen=True)
class DomainImportRunResult:
"""Aggregated result from a full import run (domain model)."""
results: list[DomainImportSourceResult]
total_imported: int
total_skipped: int
errors_count: int

View File

@@ -4,19 +4,25 @@ Request, response, and domain models for the config router and service.
"""
import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.response import BanGuiBaseModel, CollectionResponse
DNSMode = Literal["yes", "warn", "no", "raw"]
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
BackendType = Literal["auto", "polling", "pyinotify", "systemd", "gamin"]
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]
LogTarget = Literal["STDOUT", "STDERR", "SYSLOG"]
# ---------------------------------------------------------------------------
# Ban-time escalation
# ---------------------------------------------------------------------------
class BantimeEscalation(BaseModel):
class BantimeEscalation(BanGuiBaseModel):
"""Incremental ban-time escalation configuration for a jail."""
model_config = ConfigDict(strict=True)
increment: bool = Field(
default=False,
description="Whether incremental banning is enabled.",
@@ -46,12 +52,9 @@ class BantimeEscalation(BaseModel):
description="Count repeat offences across all jails, not just the current one.",
)
class BantimeEscalationUpdate(BaseModel):
class BantimeEscalationUpdate(BanGuiBaseModel):
"""Partial update payload for ban-time escalation settings."""
model_config = ConfigDict(strict=True)
increment: bool | None = Field(default=None)
factor: float | None = Field(default=None)
formula: str | None = Field(default=None)
@@ -60,17 +63,13 @@ class BantimeEscalationUpdate(BaseModel):
rnd_time: int | None = Field(default=None)
overall_jails: bool | None = Field(default=None)
# ---------------------------------------------------------------------------
# Jail configuration models
# ---------------------------------------------------------------------------
class JailConfig(BaseModel):
class JailConfig(BanGuiBaseModel):
"""Configuration snapshot of a single jail (editable fields)."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name as configured in fail2ban.")
ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.")
max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.")
@@ -79,9 +78,9 @@ class JailConfig(BaseModel):
ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.")
log_paths: list[str] = Field(default_factory=list, description="Monitored log files.")
date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.")
log_encoding: str = Field(default="UTF-8", description="Log file encoding.")
backend: str = Field(default="polling", description="Log monitoring backend.")
use_dns: str = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.")
log_encoding: LogEncoding = Field(default="UTF-8", description="Log file encoding.")
backend: BackendType = Field(default="polling", description="Log monitoring backend.")
use_dns: DNSMode = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.")
prefregex: str = Field(default="", description="Prefix regex prepended to every failregex; empty means disabled.")
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
bantime_escalation: BantimeEscalation | None = Field(
@@ -89,29 +88,22 @@ class JailConfig(BaseModel):
description="Incremental ban-time escalation settings, or None if not configured.",
)
class JailConfigResponse(BaseModel):
class JailConfigResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/jails/{name}``."""
model_config = ConfigDict(strict=True)
jail: JailConfig
class JailConfigListResponse(CollectionResponse[JailConfig]):
"""Response for ``GET /api/config/jails``.
class JailConfigListResponse(BaseModel):
"""Response for ``GET /api/config/jails``."""
Returns a non-paginated collection of jail configurations.
"""
model_config = ConfigDict(strict=True)
pass
jails: list[JailConfig] = Field(default_factory=list)
total: int = Field(..., ge=0)
class JailConfigUpdate(BaseModel):
class JailConfigUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/config/jails/{name}``."""
model_config = ConfigDict(strict=True)
ban_time: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
max_retry: int | None = Field(default=None, ge=1)
find_time: int | None = Field(default=None, ge=1)
@@ -119,35 +111,28 @@ class JailConfigUpdate(BaseModel):
ignore_regex: list[str] | None = Field(default=None)
prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.")
date_pattern: str | None = Field(default=None)
dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
backend: str | None = Field(default=None, description="Log monitoring backend.")
log_encoding: str | None = Field(default=None, description="Log file encoding.")
dns_mode: DNSMode | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
backend: BackendType | None = Field(default=None, description="Log monitoring backend.")
log_encoding: LogEncoding | None = Field(default=None, description="Log file encoding.")
enabled: bool | None = Field(default=None)
bantime_escalation: BantimeEscalationUpdate | None = Field(
default=None,
description="Incremental ban-time escalation settings to update.",
)
# ---------------------------------------------------------------------------
# Regex tester models
# ---------------------------------------------------------------------------
class RegexTestRequest(BaseModel):
class RegexTestRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/regex-test``."""
model_config = ConfigDict(strict=True)
log_line: str = Field(..., description="Sample log line to test against.")
fail_regex: str = Field(..., description="Regex pattern to match.")
class RegexTestResponse(BaseModel):
class RegexTestResponse(BanGuiBaseModel):
"""Result of a regex test."""
model_config = ConfigDict(strict=True)
matched: bool = Field(..., description="Whether the pattern matched the log line.")
groups: list[str] = Field(
default_factory=list,
@@ -158,98 +143,74 @@ class RegexTestResponse(BaseModel):
description="Compilation error message if the regex is invalid.",
)
# ---------------------------------------------------------------------------
# Global config models
# ---------------------------------------------------------------------------
class GlobalConfigResponse(BaseModel):
class GlobalConfigResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/global``."""
model_config = ConfigDict(strict=True)
log_level: str
log_target: str
log_level: LogLevel
log_target: str = Field(..., description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.")
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
class GlobalConfigUpdate(BaseModel):
class GlobalConfigUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/config/global``."""
model_config = ConfigDict(strict=True)
log_level: str | None = Field(
log_level: LogLevel | None = Field(
default=None,
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG.",
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, or DEBUG.",
)
log_target: str | None = Field(
default=None,
description="Log target: STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL, or a file path.",
description="Log target: STDOUT, STDERR, SYSLOG, or a validated file path.",
)
db_purge_age: int | None = Field(default=None, ge=0)
db_max_matches: int | None = Field(default=None, ge=0)
# ---------------------------------------------------------------------------
# Log observation / preview models
# ---------------------------------------------------------------------------
class AddLogPathRequest(BaseModel):
class AddLogPathRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/jails/{name}/logpath``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Absolute path to the log file to monitor.")
tail: bool = Field(
default=True,
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
)
class LogPreviewRequest(BaseModel):
class LogPreviewRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/preview-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Absolute path to the log file to preview.")
fail_regex: str = Field(..., description="Regex pattern to test against log lines.")
num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.")
class LogPreviewLine(BaseModel):
class LogPreviewLine(BanGuiBaseModel):
"""A single log line with match information."""
model_config = ConfigDict(strict=True)
line: str
matched: bool
groups: list[str] = Field(default_factory=list)
class LogPreviewResponse(BaseModel):
class LogPreviewResponse(BanGuiBaseModel):
"""Response for ``POST /api/config/preview-log``."""
model_config = ConfigDict(strict=True)
lines: list[LogPreviewLine] = Field(default_factory=list)
total_lines: int = Field(..., ge=0)
matched_count: int = Field(..., ge=0)
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")
# ---------------------------------------------------------------------------
# Map color threshold models
# ---------------------------------------------------------------------------
class MapColorThresholdsResponse(BaseModel):
class MapColorThresholdsResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/map-thresholds``."""
model_config = ConfigDict(strict=True)
threshold_high: int = Field(
..., description="Ban count for red coloring."
)
@@ -260,37 +221,30 @@ class MapColorThresholdsResponse(BaseModel):
..., description="Ban count for green coloring."
)
class MapColorThresholdsUpdate(BaseModel):
class MapColorThresholdsUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/config/map-thresholds``."""
model_config = ConfigDict(strict=True)
threshold_high: int = Field(..., gt=0, description="Ban count for red.")
threshold_medium: int = Field(
..., gt=0, description="Ban count for yellow."
)
threshold_low: int = Field(..., gt=0, description="Ban count for green.")
# ---------------------------------------------------------------------------
# Parsed filter file models
# ---------------------------------------------------------------------------
class FilterConfig(BaseModel):
class FilterConfig(BanGuiBaseModel):
"""Structured representation of a ``filter.d/*.conf`` file.
The ``active``, ``used_by_jails``, ``source_file``, and
``has_local_override`` fields are populated by
:func:`~app.services.config_file_service.list_filters` and
:func:`~app.services.config_file_service.get_filter`. When the model is
:func:`~app.services.filter_config_service.list_filters` and
:func:`~app.services.filter_config_service.get_filter`. When the model is
returned from the raw file-based endpoints (``/filters/{name}/parsed``),
these fields carry their default values.
"""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Filter base name, e.g. ``sshd``.")
filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.")
# [INCLUDES]
@@ -326,7 +280,7 @@ class FilterConfig(BaseModel):
default=None,
description="Systemd journal match expression.",
)
# Active-status fields — populated by config_file_service.list_filters /
# Active-status fields — populated by filter_config_service.list_filters /
# get_filter; default to safe "inactive" values when not computed.
active: bool = Field(
default=False,
@@ -354,15 +308,12 @@ class FilterConfig(BaseModel):
),
)
class FilterConfigUpdate(BaseModel):
class FilterConfigUpdate(BanGuiBaseModel):
"""Partial update payload for a parsed filter file.
Only explicitly set (non-``None``) fields are written back.
"""
model_config = ConfigDict(strict=True)
before: str | None = Field(default=None)
after: str | None = Field(default=None)
variables: dict[str, str] | None = Field(default=None)
@@ -373,8 +324,7 @@ class FilterConfigUpdate(BaseModel):
datepattern: str | None = Field(default=None)
journalmatch: str | None = Field(default=None)
class FilterUpdateRequest(BaseModel):
class FilterUpdateRequest(BanGuiBaseModel):
"""Payload for ``PUT /api/config/filters/{name}``.
Accepts only the user-editable ``[Definition]`` fields. Fields left as
@@ -382,8 +332,6 @@ class FilterUpdateRequest(BaseModel):
preserved.
"""
model_config = ConfigDict(strict=True)
failregex: list[str] | None = Field(
default=None,
description="Updated failure-detection regex patterns. ``None`` = keep existing.",
@@ -401,15 +349,12 @@ class FilterUpdateRequest(BaseModel):
description="Systemd journal match expression. ``None`` = keep existing.",
)
class FilterCreateRequest(BaseModel):
class FilterCreateRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/filters``.
Creates a new user-defined filter at ``filter.d/{name}.local``.
"""
model_config = ConfigDict(strict=True)
name: str = Field(
...,
description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.",
@@ -435,23 +380,17 @@ class FilterCreateRequest(BaseModel):
description="Systemd journal match expression.",
)
class AssignFilterRequest(BaseModel):
class AssignFilterRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/jails/{jail_name}/filter``."""
model_config = ConfigDict(strict=True)
filter_name: str = Field(
...,
description="Filter base name to assign to the jail (e.g. ``sshd``).",
)
class FilterListResponse(BaseModel):
class FilterListResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/filters``."""
model_config = ConfigDict(strict=True)
filters: list[FilterConfig] = Field(
default_factory=list,
description=(
@@ -461,17 +400,13 @@ class FilterListResponse(BaseModel):
)
total: int = Field(..., ge=0, description="Total number of filters found.")
# ---------------------------------------------------------------------------
# Parsed action file models
# ---------------------------------------------------------------------------
class ActionConfig(BaseModel):
class ActionConfig(BanGuiBaseModel):
"""Structured representation of an ``action.d/*.conf`` file."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Action base name, e.g. ``iptables``.")
filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.")
# [INCLUDES]
@@ -512,7 +447,7 @@ class ActionConfig(BaseModel):
default_factory=dict,
description="Runtime parameters that can be overridden per jail.",
)
# Active-status fields — populated by config_file_service.list_actions /
# Active-status fields — populated by action_config_service.list_actions /
# get_action; default to safe "inactive" values when not computed.
active: bool = Field(
default=False,
@@ -540,12 +475,9 @@ class ActionConfig(BaseModel):
),
)
class ActionConfigUpdate(BaseModel):
class ActionConfigUpdate(BanGuiBaseModel):
"""Partial update payload for a parsed action file."""
model_config = ConfigDict(strict=True)
before: str | None = Field(default=None)
after: str | None = Field(default=None)
actionstart: str | None = Field(default=None)
@@ -557,12 +489,9 @@ class ActionConfigUpdate(BaseModel):
definition_vars: dict[str, str] | None = Field(default=None)
init_vars: dict[str, str] | None = Field(default=None)
class ActionListResponse(BaseModel):
class ActionListResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/actions``."""
model_config = ConfigDict(strict=True)
actions: list[ActionConfig] = Field(
default_factory=list,
description=(
@@ -572,16 +501,13 @@ class ActionListResponse(BaseModel):
)
total: int = Field(..., ge=0, description="Total number of actions found.")
class ActionUpdateRequest(BaseModel):
class ActionUpdateRequest(BanGuiBaseModel):
"""Payload for ``PUT /api/config/actions/{name}``.
Accepts only the user-editable ``[Definition]`` lifecycle fields and
``[Init]`` parameters. Fields left as ``None`` are not changed.
"""
model_config = ConfigDict(strict=True)
actionstart: str | None = Field(
default=None,
description="Updated ``actionstart`` command. ``None`` = keep existing.",
@@ -615,15 +541,12 @@ class ActionUpdateRequest(BaseModel):
description="``[Init]`` parameters to set. ``None`` = keep existing.",
)
class ActionCreateRequest(BaseModel):
class ActionCreateRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/actions``.
Creates a new user-defined action at ``action.d/{name}.local``.
"""
model_config = ConfigDict(strict=True)
name: str = Field(
...,
description="Action base name (e.g. ``my-custom-action``). Must not already exist.",
@@ -643,12 +566,9 @@ class ActionCreateRequest(BaseModel):
description="``[Init]`` runtime parameters.",
)
class AssignActionRequest(BaseModel):
class AssignActionRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/jails/{jail_name}/action``."""
model_config = ConfigDict(strict=True)
action_name: str = Field(
...,
description="Action base name to add to the jail (e.g. ``iptables-multiport``).",
@@ -661,17 +581,13 @@ class AssignActionRequest(BaseModel):
),
)
# ---------------------------------------------------------------------------
# Jail file config models (Task 6.1)
# ---------------------------------------------------------------------------
class JailSectionConfig(BaseModel):
class JailSectionConfig(BanGuiBaseModel):
"""Settings within a single [jailname] section of a jail.d file."""
model_config = ConfigDict(strict=True)
enabled: bool | None = Field(default=None, description="Whether this jail is enabled.")
port: str | None = Field(default=None, description="Port(s) to monitor (e.g. 'ssh' or '22,2222').")
filter: str | None = Field(default=None, description="Filter name to use (e.g. 'sshd').")
@@ -680,39 +596,31 @@ class JailSectionConfig(BaseModel):
findtime: int | None = Field(default=None, ge=1, description="Time window in seconds for counting failures.")
bantime: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.")
action: list[str] = Field(default_factory=list, description="Action references.")
backend: str | None = Field(default=None, description="Log monitoring backend.")
backend: BackendType | None = Field(default=None, description="Log monitoring backend.")
extra: dict[str, str] = Field(default_factory=dict, description="Additional settings not captured by named fields.")
class JailFileConfig(BaseModel):
class JailFileConfig(BanGuiBaseModel):
"""Structured representation of a jail.d/*.conf file."""
model_config = ConfigDict(strict=True)
filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').")
jails: dict[str, JailSectionConfig] = Field(
default_factory=dict,
description="Mapping of jail name → settings for each [section] in the file.",
)
class JailFileConfigUpdate(BaseModel):
class JailFileConfigUpdate(BanGuiBaseModel):
"""Partial update payload for a jail.d file."""
model_config = ConfigDict(strict=True)
jails: dict[str, JailSectionConfig] | None = Field(
default=None,
description="Jail section updates. Only jails present in this dict are updated.",
)
# ---------------------------------------------------------------------------
# Inactive jail models (Stage 1)
# ---------------------------------------------------------------------------
class InactiveJail(BaseModel):
class InactiveJail(BanGuiBaseModel):
"""A jail defined in fail2ban config files that is not currently active.
A jail is considered inactive when its ``enabled`` key is ``false`` (or
@@ -721,8 +629,6 @@ class InactiveJail(BaseModel):
running.
"""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name from the config section header.")
filter: str = Field(
...,
@@ -764,11 +670,11 @@ class InactiveJail(BaseModel):
default=600,
description="Failure-counting window in seconds, parsed from findtime string.",
)
log_encoding: str = Field(
log_encoding: LogEncoding = Field(
default="auto",
description="Log encoding, e.g. ``utf-8`` or ``auto``.",
)
backend: str = Field(
backend: BackendType = Field(
default="auto",
description="Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.",
)
@@ -776,7 +682,7 @@ class InactiveJail(BaseModel):
default=None,
description="Date pattern for log parsing, or None for auto-detect.",
)
use_dns: str = Field(
use_dns: DNSMode = Field(
default="warn",
description="DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.",
)
@@ -816,17 +722,15 @@ class InactiveJail(BaseModel):
),
)
class InactiveJailListResponse(CollectionResponse[InactiveJail]):
"""Response for ``GET /api/config/jails/inactive``.
class InactiveJailListResponse(BaseModel):
"""Response for ``GET /api/config/jails/inactive``."""
Returns a non-paginated collection of inactive jail configurations.
"""
model_config = ConfigDict(strict=True)
pass
jails: list[InactiveJail] = Field(default_factory=list)
total: int = Field(..., ge=0)
class ActivateJailRequest(BaseModel):
class ActivateJailRequest(BanGuiBaseModel):
"""Optional override values when activating an inactive jail.
All fields are optional. Omitted fields are not written to the
@@ -834,8 +738,6 @@ class ActivateJailRequest(BaseModel):
values.
"""
model_config = ConfigDict(strict=True)
bantime: str | None = Field(
default=None,
description="Override ban duration, e.g. ``1h`` or ``3600``.",
@@ -858,12 +760,9 @@ class ActivateJailRequest(BaseModel):
description="Override log file paths.",
)
class JailActivationResponse(BaseModel):
class JailActivationResponse(BanGuiBaseModel):
"""Response for jail activation and deactivation endpoints."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Name of the affected jail.")
active: bool = Field(
...,
@@ -892,29 +791,22 @@ class JailActivationResponse(BaseModel):
),
)
# ---------------------------------------------------------------------------
# Jail validation models (Task 3)
# ---------------------------------------------------------------------------
class JailValidationIssue(BaseModel):
class JailValidationIssue(BanGuiBaseModel):
"""A single issue found during pre-activation validation of a jail config."""
model_config = ConfigDict(strict=True)
field: str = Field(
...,
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
)
message: str = Field(..., description="Human-readable description of the issue.")
class JailValidationResult(BaseModel):
class JailValidationResult(BanGuiBaseModel):
"""Result of pre-activation validation of a single jail configuration."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the validated jail.")
valid: bool = Field(..., description="True when no issues were found.")
issues: list[JailValidationIssue] = Field(
@@ -922,17 +814,13 @@ class JailValidationResult(BaseModel):
description="Validation issues found. Empty when valid=True.",
)
# ---------------------------------------------------------------------------
# Rollback response model (Task 3)
# ---------------------------------------------------------------------------
class RollbackResponse(BaseModel):
class RollbackResponse(BanGuiBaseModel):
"""Response for ``POST /api/config/jails/{name}/rollback``."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the jail that was disabled.")
disabled: bool = Field(
...,
@@ -949,17 +837,13 @@ class RollbackResponse(BaseModel):
)
message: str = Field(..., description="Human-readable result message.")
# ---------------------------------------------------------------------------
# Pending recovery model (Task 3)
# ---------------------------------------------------------------------------
class PendingRecovery(BaseModel):
class PendingRecovery(BanGuiBaseModel):
"""Records a probable activation-caused fail2ban crash pending user action."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(
...,
description="Name of the jail whose activation likely caused the crash.",
@@ -977,29 +861,22 @@ class PendingRecovery(BaseModel):
description="Whether fail2ban has been successfully restarted.",
)
# ---------------------------------------------------------------------------
# fail2ban log viewer models
# ---------------------------------------------------------------------------
class Fail2BanLogResponse(BaseModel):
class Fail2BanLogResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/fail2ban-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
log_level: str = Field(..., description="Current fail2ban log level.")
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
class ServiceStatusResponse(BaseModel):
class ServiceStatusResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/service-status``."""
model_config = ConfigDict(strict=True)
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="BanGUI application version (or None when offline).")
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
@@ -1007,3 +884,21 @@ class ServiceStatusResponse(BaseModel):
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")
# ---------------------------------------------------------------------------
# Security headers
# ---------------------------------------------------------------------------
class SecurityHeadersResponse(BanGuiBaseModel):
"""Security-relevant header names and values used by the frontend."""
csrf_header_name: str = Field(
...,
description="Name of the custom header required for state-mutating requests.",
)
csrf_header_value: str = Field(
...,
description="Required value of the CSRF header to pass validation.",
)

View File

@@ -0,0 +1,130 @@
"""Config domain models.
Internal domain-focused models used by config_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.config` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
DNSMode = Literal["yes", "warn", "no", "raw"]
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
BackendType = Literal["auto", "polling", "pyinotify", "systemd", "gamin"]
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"]
@dataclass(frozen=True)
class DomainBantimeEscalation:
"""Incremental ban-time escalation configuration (domain model)."""
increment: bool = False
factor: float | None = None
formula: str | None = None
multipliers: str | None = None
max_time: int | None = None
rnd_time: int | None = None
overall_jails: bool = False
@dataclass(frozen=True)
class DomainJailConfig:
"""Configuration snapshot of a single jail (domain model)."""
name: str
ban_time: int
max_retry: int
find_time: int
fail_regex: list[str]
ignore_regex: list[str]
log_paths: list[str]
actions: list[str]
date_pattern: str | None = None
log_encoding: LogEncoding = "UTF-8"
backend: BackendType = "polling"
use_dns: DNSMode = "warn"
prefregex: str = ""
bantime_escalation: DomainBantimeEscalation | None = None
@dataclass(frozen=True)
class DomainJailConfigList:
"""List of jail configurations (domain model)."""
items: list[DomainJailConfig]
total: int
@dataclass(frozen=True)
class DomainGlobalConfig:
"""Global fail2ban settings (domain model)."""
log_level: LogLevel
log_target: str
db_purge_age: int
db_max_matches: int
@dataclass(frozen=True)
class DomainServiceStatus:
"""Fail2ban service health status (domain model)."""
online: bool
version: str | None = None
jail_count: int = 0
total_bans: int = 0
total_failures: int = 0
log_level: str | None = None
log_target: str | None = None
@dataclass(frozen=True)
class DomainMapColorThresholds:
"""Map color threshold configuration (domain model)."""
threshold_high: int
threshold_medium: int
threshold_low: int
@dataclass(frozen=True)
class DomainRegexTest:
"""Result of a regex test (domain model)."""
matched: bool
groups: list[str]
error: str | None = None
@dataclass(frozen=True)
class DomainFilterConfig:
"""Structured representation of a filter.d/*.conf file (domain model)."""
name: str
filename: str
before: str | None = None
after: str | None = None
variables: dict[str, str] | None = None
prefregex: str | None = None
failregex: list[str] | None = None
ignoreregex: list[str] | None = None
maxlines: int | None = None
datepattern: str | None = None
journalmatch: str | None = None
active: bool = False
used_by_jails: list[str] | None = None
source_file: str = ""
has_local_override: bool = False
@dataclass(frozen=True)
class DomainFilterList:
"""List of filter configurations (domain model)."""
items: list[DomainFilterConfig]
total: int

View File

@@ -4,18 +4,18 @@ Covers jail config files (``jail.d/``), filter definitions (``filter.d/``),
and action definitions (``action.d/``).
"""
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field, field_validator
from app.models.response import BanGuiBaseModel
from app.utils.constants import FAIL2BAN_RESERVED_JAIL_NAMES
# ---------------------------------------------------------------------------
# Jail config file models (Task 4a)
# ---------------------------------------------------------------------------
class JailConfigFile(BaseModel):
class JailConfigFile(BanGuiBaseModel):
"""Metadata for a single jail configuration file in ``jail.d/``."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name (file stem, e.g. ``sshd``).")
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
enabled: bool = Field(
@@ -26,84 +26,71 @@ class JailConfigFile(BaseModel):
),
)
class JailConfigFilesResponse(BaseModel):
class JailConfigFilesResponse(BanGuiBaseModel):
"""Response for ``GET /api/config/jail-files``."""
model_config = ConfigDict(strict=True)
files: list[JailConfigFile] = Field(default_factory=list)
total: int = Field(..., ge=0)
class JailConfigFileContent(BaseModel):
class JailConfigFileContent(BanGuiBaseModel):
"""Single jail config file with its raw content."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name (file stem).")
filename: str = Field(..., description="Actual filename.")
enabled: bool = Field(..., description="Whether the jail is enabled.")
content: str = Field(..., description="Raw file content.")
class JailConfigFileEnabledUpdate(BaseModel):
class JailConfigFileEnabledUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/config/jail-files/{filename}/enabled``."""
model_config = ConfigDict(strict=True)
enabled: bool = Field(..., description="New enabled state for this jail.")
# ---------------------------------------------------------------------------
# Generic conf-file entry (shared by filter.d and action.d)
# ---------------------------------------------------------------------------
class ConfFileEntry(BaseModel):
class ConfFileEntry(BanGuiBaseModel):
"""Metadata for a single ``.conf`` or ``.local`` file."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Base name without extension (e.g. ``sshd``).")
filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).")
class ConfFilesResponse(BaseModel):
class ConfFilesResponse(BanGuiBaseModel):
"""Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``)."""
model_config = ConfigDict(strict=True)
files: list[ConfFileEntry] = Field(default_factory=list)
total: int = Field(..., ge=0)
class ConfFileContent(BaseModel):
class ConfFileContent(BanGuiBaseModel):
"""A conf file with its raw text content."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Base name without extension.")
filename: str = Field(..., description="Actual filename.")
content: str = Field(..., description="Raw file content.")
class ConfFileUpdateRequest(BaseModel):
class ConfFileUpdateRequest(BanGuiBaseModel):
"""Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``."""
model_config = ConfigDict(strict=True)
content: str = Field(..., description="New raw file content (must not exceed 512 KB).")
class ConfFileCreateRequest(BaseModel):
class ConfFileCreateRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``."""
model_config = ConfigDict(strict=True)
name: str = Field(
...,
description="New file base name (without extension). Must contain only "
"alphanumeric characters, hyphens, underscores, and dots.",
)
content: str = Field(..., description="Initial raw file content (must not exceed 512 KB).")
@field_validator("name", mode="after")
@classmethod
def _reject_reserved_jail_name(cls, v: str) -> str:
"""Reject fail2ban reserved jail names."""
if v in FAIL2BAN_RESERVED_JAIL_NAMES:
valid_names = ", ".join(sorted(FAIL2BAN_RESERVED_JAIL_NAMES))
raise ValueError(
f"Jail name {v!r} is reserved by fail2ban ({valid_names})."
)
return v

View File

@@ -9,21 +9,20 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.response import BanGuiBaseModel
if TYPE_CHECKING:
import aiohttp
import aiosqlite
class GeoDetail(BaseModel):
class GeoDetail(BanGuiBaseModel):
"""Enriched geolocation data for an IP address.
Populated from the ip-api.com free API.
"""
model_config = ConfigDict(strict=True)
country_code: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 country code.",
@@ -41,30 +40,60 @@ class GeoDetail(BaseModel):
description="Organisation associated with the ASN.",
)
class GeoCacheEntry(BanGuiBaseModel):
"""A single cached geolocation entry for an IP address.
class GeoCacheStatsResponse(BaseModel):
Represents a row from the ``geo_cache`` table in the application database.
"""
ip: str = Field(..., description="IP address (IPv4 or IPv6).")
country_code: str | None = Field(
default=None,
description="ISO 3166-1 alpha-2 country code.",
)
country_name: str | None = Field(
default=None,
description="Human-readable country name.",
)
asn: str | None = Field(
default=None,
description="Autonomous System Number (e.g. ``'AS3320'``).",
)
org: str | None = Field(
default=None,
description="Organisation associated with the ASN.",
)
class GeoCacheStatsResponse(BanGuiBaseModel):
"""Response for ``GET /api/geo/stats``.
Exposes diagnostic counters of the geo cache subsystem so operators
can assess resolution health from the UI or CLI.
"""
model_config = ConfigDict(strict=True)
cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.")
unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.")
neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.")
dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.")
hits: int = Field(default=0, description="Number of cache hits since last clear.")
misses: int = Field(default=0, description="Number of cache misses since last clear.")
class GeoReResolveResponse(BanGuiBaseModel):
"""Response for ``POST /api/geo/re-resolve``.
class IpLookupResponse(BaseModel):
Reports how many previously unresolved IPs were retried and how many
gained a resolved country code after the re-resolve operation.
"""
resolved: int = Field(..., description="Number of IPs successfully resolved.")
total: int = Field(..., description="Number of IPs retried.")
class IpLookupResponse(BanGuiBaseModel):
"""Response for ``GET /api/geo/lookup/{ip}``.
Aggregates current ban status and geographical information for an IP.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="The queried IP address.")
currently_banned_in: list[str] = Field(
default_factory=list,
@@ -75,12 +104,10 @@ class IpLookupResponse(BaseModel):
description="Enriched geographical and network information.",
)
# ---------------------------------------------------------------------------
# shared service types
# ---------------------------------------------------------------------------
@dataclass
class GeoInfo:
"""Geo resolution result used throughout backend services."""
@@ -90,7 +117,6 @@ class GeoInfo:
asn: str | None
org: str | None
GeoEnricher = Callable[[str], Awaitable[GeoInfo | None]]
GeoBatchLookup = Callable[
[list[str], "aiohttp.ClientSession", "aiosqlite.Connection | None"],

View File

@@ -0,0 +1,23 @@
"""Health domain models.
Internal domain-focused models used by health_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.config` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DomainServerStatus:
"""Cached fail2ban server health snapshot (domain model)."""
online: bool
version: str | None = None
active_jails: int = 0
total_bans: int = 0
total_failures: int = 0

View File

@@ -5,9 +5,10 @@ Request, response, and domain models used by the history router and service.
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.ban import TimeRange
from app.models._common import TimeRange
from app.models.response import BanGuiBaseModel, PaginatedListResponse
__all__ = [
"HistoryBanItem",
@@ -17,16 +18,13 @@ __all__ = [
"TimeRange",
]
class HistoryBanItem(BaseModel):
class HistoryBanItem(BanGuiBaseModel):
"""A single row in the history ban-list table.
Populated from the fail2ban database and optionally enriched with
geolocation data.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="Banned IP address.")
jail: str = Field(..., description="Jail that issued the ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
@@ -57,31 +55,26 @@ class HistoryBanItem(BaseModel):
description="Organisation name associated with the IP.",
)
class HistoryListResponse(PaginatedListResponse[HistoryBanItem]):
"""Paginated history ban-list response.
class HistoryListResponse(BaseModel):
"""Paginated history ban-list response."""
model_config = ConfigDict(strict=True)
items: list[HistoryBanItem] = Field(default_factory=list)
total: int = Field(..., ge=0, description="Total matching records.")
page: int = Field(..., ge=1)
page_size: int = Field(..., ge=1)
Request: ``GET /api/history`` with optional time-range, jail, IP, and
origin filters plus pagination parameters.
Response: Paginated collection of historical ban records with geolocation.
"""
pass
# ---------------------------------------------------------------------------
# Per-IP timeline
# ---------------------------------------------------------------------------
class IpTimelineEvent(BaseModel):
class IpTimelineEvent(BanGuiBaseModel):
"""A single ban event in a per-IP timeline.
Represents one row from the fail2ban ``bans`` table for a specific IP.
"""
model_config = ConfigDict(strict=True)
jail: str = Field(..., description="Jail that triggered this ban.")
banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.")
ban_count: int = Field(
@@ -99,16 +92,13 @@ class IpTimelineEvent(BaseModel):
description="Matched log lines that triggered the ban.",
)
class IpDetailResponse(BaseModel):
class IpDetailResponse(BanGuiBaseModel):
"""Full historical record for a single IP address.
Contains aggregated totals and a chronological timeline of all ban events
recorded in the fail2ban database for the given IP.
"""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="The IP address.")
total_bans: int = Field(..., ge=0, description="Total number of ban records.")
total_failures: int = Field(

View File

@@ -0,0 +1,64 @@
"""History domain models.
Internal domain-focused models used by history_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.history` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DomainHistoryBanItem:
"""A single row in the history ban-list table (domain model)."""
ip: str
jail: str
banned_at: str
ban_count: int
failures: int = 0
matches: list[str] | None = None
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
@dataclass(frozen=True)
class DomainHistoryList:
"""Paginated history ban-list (domain model)."""
items: list[DomainHistoryBanItem]
total: int
page: int
page_size: int
@dataclass(frozen=True)
class DomainIpTimelineEvent:
"""A single ban event in a per-IP timeline (domain model)."""
jail: str
banned_at: str
ban_count: int
failures: int = 0
matches: list[str] | None = None
@dataclass(frozen=True)
class DomainIpDetail:
"""Full historical record for a single IP address (domain model)."""
ip: str
total_bans: int
total_failures: int
last_ban_at: str | None = None
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
timeline: list[DomainIpTimelineEvent] | None = None

View File

@@ -3,27 +3,22 @@
Request, response, and domain models used by the jails router and service.
"""
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.config import BantimeEscalation
from app.models.response import BanGuiBaseModel, CommandResponse, CollectionResponse
class JailStatus(BaseModel):
class JailStatus(BanGuiBaseModel):
"""Runtime metrics for a single jail."""
model_config = ConfigDict(strict=True)
currently_banned: int = Field(..., ge=0)
total_banned: int = Field(..., ge=0)
currently_failed: int = Field(..., ge=0)
total_failed: int = Field(..., ge=0)
class Jail(BaseModel):
class Jail(BanGuiBaseModel):
"""Domain model for a single fail2ban jail with its full configuration."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name as configured in fail2ban.")
enabled: bool = Field(..., description="Whether the jail is currently active.")
running: bool = Field(..., description="Whether the jail backend is running.")
@@ -45,12 +40,9 @@ class Jail(BaseModel):
)
status: JailStatus | None = Field(default=None, description="Runtime counters.")
class JailSummary(BaseModel):
class JailSummary(BanGuiBaseModel):
"""Lightweight jail entry for the overview list."""
model_config = ConfigDict(strict=True)
name: str
enabled: bool
running: bool
@@ -61,36 +53,48 @@ class JailSummary(BaseModel):
max_retry: int
status: JailStatus | None = None
class JailListResponse(CollectionResponse[JailSummary]):
"""Response for ``GET /api/jails``.
class JailListResponse(BaseModel):
"""Response for ``GET /api/jails``."""
Returns a non-paginated collection of jail summaries with their current status.
"""
model_config = ConfigDict(strict=True)
pass
jails: list[JailSummary] = Field(default_factory=list)
total: int = Field(..., ge=0)
class IgnoreListResponse(CollectionResponse[str]):
"""Response for ``GET /api/jails/{name}/ignoreip``.
Returns the jailed ignore list as a standard collection response.
"""
class JailDetailResponse(BaseModel):
"""Response for ``GET /api/jails/{name}``."""
pass
model_config = ConfigDict(strict=True)
class JailDetailResponse(BanGuiBaseModel):
"""Response for ``GET /api/jails/{name}``.
Includes the primary jail object together with supplemental metadata
required by the UI.
"""
jail: Jail
ignore_list: list[str] = Field(
default_factory=list,
description="List of IP addresses and networks currently ignored by the jail.",
)
ignore_self: bool = Field(
default=False,
description="Whether the jail ignores the server's own IP addresses.",
)
class JailCommandResponse(CommandResponse):
"""Generic response for jail control commands (start, stop, reload, idle).
class JailCommandResponse(BaseModel):
"""Generic response for jail control commands (start, stop, reload, idle)."""
Extends the base CommandResponse with a jail field to identify the target.
"""
model_config = ConfigDict(strict=True)
jail: str = Field(..., description="Target jail name, or '*' for operations on all jails.")
message: str
jail: str
class IgnoreIpRequest(BaseModel):
class IgnoreIpRequest(BanGuiBaseModel):
"""Payload for adding an IP or network to a jail's ignore list."""
model_config = ConfigDict(strict=True)
ip: str = Field(..., description="IP address or CIDR network to ignore.")

View File

@@ -0,0 +1,112 @@
"""Jail domain models.
Internal domain-focused models used by jail_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.jail` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DomainJailStatus:
"""Runtime metrics for a single jail (domain model)."""
currently_banned: int
total_banned: int
currently_failed: int
total_failed: int
@dataclass(frozen=True)
class DomainBantimeEscalation:
"""Incremental ban-time escalation configuration (domain model)."""
increment: bool = False
factor: float | None = None
formula: str | None = None
multipliers: str | None = None
max_time: int | None = None
rnd_time: int | None = None
overall_jails: bool = False
@dataclass(frozen=True)
class DomainJailSummary:
"""Lightweight jail entry for the overview list (domain model)."""
name: str
enabled: bool
running: bool
idle: bool
backend: str
find_time: int
ban_time: int
max_retry: int
status: DomainJailStatus | None = None
@dataclass(frozen=True)
class DomainJailList:
"""List of active jails (domain model)."""
items: list[DomainJailSummary]
total: int
@dataclass(frozen=True)
class DomainJail:
"""Full jail configuration (domain model)."""
name: str
enabled: bool
running: bool
idle: bool
backend: str
log_paths: list[str]
fail_regex: list[str]
ignore_regex: list[str]
ignore_ips: list[str]
find_time: int
ban_time: int
max_retry: int
actions: list[str]
date_pattern: str | None = None
log_encoding: str = "UTF-8"
bantime_escalation: DomainBantimeEscalation | None = None
status: DomainJailStatus | None = None
@dataclass(frozen=True)
class DomainActiveBan:
"""A currently active ban entry from a jail (domain model)."""
ip: str
jail: str
banned_at: str | None = None
expires_at: str | None = None
ban_count: int = 1
country: str | None = None
@dataclass(frozen=True)
class DomainJailBannedIps:
"""Paginated list of currently banned IPs for a jail (domain model)."""
items: list[DomainActiveBan]
total: int
page: int
page_size: int
@dataclass(frozen=True)
class DomainJailDetail:
"""Full jail with supplemental metadata (domain model)."""
jail: DomainJail
ignore_list: list[str]
ignore_self: bool

View File

@@ -0,0 +1,545 @@
"""Base response wrapper models for standardized API envelopes.
All API endpoints should wrap their responses using the base classes defined here.
This ensures a consistent response shape across the entire API, reducing frontend
branching logic and integration bugs.
Response Patterns:
1. **Paginated List** — Use `PaginatedListResponse[T]` for endpoints returning paginated items.
Example: GET /api/jails, GET /api/dashboard/bans
```python
class MyListResponse(PaginatedListResponse[MyItem]):
pass
# Returns:
{
"items": [...],
"pagination": {
"page": 1,
"page_size": 20,
"total": 100,
"total_pages": 5,
"has_next_page": true,
"has_prev_page": false
}
}
```
2. **Simple Collection** — Use `CollectionResponse[T]` for non-paginated collections.
Example: GET /api/bans/active
```python
class MyCollectionResponse(CollectionResponse[MyItem]):
pass
# Returns:
{
"items": [...],
"total": 50
}
```
3. **Single Item Detail** — Use domain model directly wrapped in a named field.
Example: GET /api/jails/{name}, GET /api/dashboard/status
```python
class MyDetailResponse(BaseModel):
jail: Jail # or: status: ServerStatus, settings: ServerSettings
# Optional extra fields (ignore_list, warnings, etc.)
# Returns:
{
"jail": {...},
"ignore_list": [...]
}
```
4. **Command/Action Result** — Use `CommandResponse` for success/acknowledgement.
Example: POST /api/jails/{name}/start, POST /api/bans
```python
class MyCommandResponse(CommandResponse):
jail: str # Optional: target identifier
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd"
}
```
5. **Aggregated Data** — Use domain-specific aggregation models with metadata.
Example: GET /api/dashboard/bans/by-jail
```python
class MyAggregationResponse(BaseModel):
jails: list[JailBanCount] # or: countries, buckets, etc.
total: int
# Optional: filters, time_range metadata
# Returns:
{
"jails": [...],
"total": 1234
}
```
Note on field naming:
- Paginated/collection responses always use "items" for the data array.
- Detail responses use domain-specific field names (jail, status, settings).
- Aggregation responses use domain-specific field names (jails, countries, buckets).
- All responses with multiple items include a "total" field.
"""
from typing import Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from typing_extensions import TypedDict
T = TypeVar("T")
class BanGuiBaseModel(BaseModel):
"""Project-wide Pydantic base model.
Enforces the canonical **snake_case** API field naming policy:
all JSON wire-format field names use ``snake_case`` on both the backend
(Python) and the frontend (TypeScript interfaces). No ``alias_generator``
is applied — field names are serialized exactly as written.
Rules:
- Every model in ``app/models/`` must inherit from this class.
- Field names must be ``snake_case`` in Python *and* in the JSON payload.
- The corresponding TypeScript interface fields must also be ``snake_case``.
- Never add a ``camelCase`` alias generator to individual models — any
serialization change must go through this base class so all models
update at once.
"""
model_config = ConfigDict(strict=True)
class PaginationMetadata(BanGuiBaseModel):
"""Pagination metadata embedded in paginated list responses.
Contains page information and computed fields to support frontend pagination controls.
Supports both offset-based and cursor-based pagination modes.
Fields:
page: Current page number (1-based). Set to 1 for cursor pagination.
page_size: Number of items per page.
total: Total number of items matching the query (across all pages).
For cursor pagination, this is -1 (unknown without full scan).
total_pages: Computed total number of pages.
For cursor pagination, this is -1 (unknown without full scan).
has_next_page: Whether there is a next page after this one.
has_prev_page: Whether there is a previous page before this one.
Always False for cursor pagination (cannot navigate backward without storing history).
cursor: Opaque cursor token for fetching the next page (cursor pagination only).
None for offset pagination or when there are no more pages.
pagination_mode: Pagination mode used by the endpoint. 'offset' uses page/page_size;
'cursor' uses cursor tokens for navigation.
Example (offset pagination):
```python
pagination = PaginationMetadata(
page=2,
page_size=50,
total=150,
total_pages=3,
has_next_page=True,
has_prev_page=True,
cursor=None,
pagination_mode="offset",
)
```
Example (cursor pagination):
```python
pagination = PaginationMetadata(
page=1,
page_size=50,
total=-1,
total_pages=-1,
has_next_page=True,
has_prev_page=False,
cursor="eyJpZCI6IDQyN30=",
pagination_mode="cursor",
)
```
"""
page: int = Field(..., ge=1, description="Current page number (1-based). Set to 1 for cursor pagination.")
page_size: int = Field(..., ge=1, description="Number of items per page.")
total: int = Field(..., description="Total number of items matching the query. -1 if unknown (cursor pagination).")
total_pages: int = Field(..., description="Computed total number of pages. -1 if unknown (cursor pagination).")
has_next_page: bool = Field(..., description="Whether there is a next page after this one.")
has_prev_page: bool = Field(..., description="Whether there is a previous page before this one.")
cursor: str | None = Field(
default=None,
description="Opaque cursor token for fetching the next page (cursor pagination only).",
)
pagination_mode: Literal["offset", "cursor"] = Field(
default="offset",
description="Pagination mode used by the endpoint. 'offset' uses page/page_size; 'cursor' uses cursor tokens.",
)
class PaginatedListResponse(BanGuiBaseModel, Generic[T]):
"""Standardized paginated list response.
Use this as a base for all endpoints that return paginated collections.
Automatically includes pagination metadata to support frontend paging UIs.
Fields:
items: The data items for the current page.
pagination: Pagination metadata with computed derived fields.
Example:
```python
class UserListResponse(PaginatedListResponse[User]):
pass
# Returns:
{
"items": [...],
"pagination": {
"page": 2,
"page_size": 50,
"total": 150,
"total_pages": 3,
"has_next_page": true,
"has_prev_page": true
}
}
```
"""
items: list[T] = Field(default_factory=list, description="Data items for the current page.")
pagination: PaginationMetadata = Field(..., description="Pagination metadata with computed derived fields.")
class CollectionResponse(BanGuiBaseModel, Generic[T]):
"""Standardized non-paginated collection response.
Use this for endpoints that return a collection without pagination support.
Simpler than PaginatedListResponse, but still provides consistent wrapping.
Fields:
items: The data items in the collection.
total: Total number of items.
Example:
```python
class ActiveBansResponse(CollectionResponse[ActiveBan]):
pass
# Returns:
{
"items": [...],
"total": 42
}
```
"""
items: list[T] = Field(default_factory=list, description="Collection items.")
total: int = Field(..., ge=0, description="Total number of items.")
class CommandResponse(BanGuiBaseModel):
"""Standardized command/action result response.
Use this for endpoints that execute commands (start, stop, reload, ban, unban, etc.).
Always includes a success indicator and human-readable message.
Fields:
message: Human-readable result message or error description.
success: Whether the command succeeded (default True).
Example:
```python
class StartJailResponse(CommandResponse):
jail: str # Optional: target identifier
# Returns:
{
"message": "Jail 'sshd' started.",
"success": true,
"jail": "sshd"
}
```
"""
message: str = Field(..., description="Human-readable result or error message.")
success: bool = Field(
default=True,
description="Whether the command succeeded (false for errors in non-exception handlers).",
)
class ErrorResponse(BanGuiBaseModel):
"""Standardized error response envelope for all API errors.
Use this for all error responses to ensure consistent client-side error handling.
The error code enables machine-readable branching, while detail provides
human-readable context. Metadata offers optional structured context.
The correlation_id field enables tracing this error back through logs on both
frontend and backend, enabling correlation across distributed systems.
Fields:
code: Machine-readable error code (e.g., "jail_not_found", "invalid_input").
detail: Human-readable error description for display to users.
metadata: Optional structured context (e.g., field names, constraint violations).
correlation_id: Unique ID for correlating this error with request logs.
Example:
```python
# 404 Not Found
{
"code": "jail_not_found",
"detail": "Jail 'sshd' not found",
"metadata": {"jail_name": "sshd"},
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
# 400 Bad Request - Validation Error
{
"code": "invalid_input",
"detail": "Invalid IP address format",
"metadata": {"field": "ip", "value": "999.999.999.999"},
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
# 409 Conflict
{
"code": "jail_already_active",
"detail": "Jail is already active: 'sshd'",
"metadata": {"jail_name": "sshd", "current_status": "active"},
"correlation_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
"""
code: str = Field(..., description="Machine-readable error code for client-side branching.")
detail: str = Field(..., description="Human-readable error description.")
metadata: "ErrorMetadata" = Field(
default_factory=dict,
description="Optional structured context for the error.",
)
correlation_id: str | None = Field(
default=None,
description="Unique ID for correlating this error with request logs on both frontend and backend.",
)
# ErrorMetadata must be defined after ErrorResponse due to Pydantic forward-ref resolution
# but before use at type-check time. This ordering is intentional.
class ErrorMetadata(TypedDict, total=False):
"""Typed metadata fields for error responses.
Allows type-safe access to known metadata keys in exception handlers.
Keys are optional — exceptions return only relevant fields.
Fields:
jail_name: Name of the jail involved in the error.
filename: Config filename involved in the error.
filter_name: Name of the filter involved in the error.
action_name: Name of the action involved in the error.
source_id: ID of a blocklist source involved in the error.
url: URL involved in a blocklist error.
ip: IP address involved in the error.
pattern: Regex pattern that caused an error.
error: Regex compilation error message.
pattern_length: Actual length of an oversized pattern.
max_length: Maximum allowed length for a pattern.
timeout_seconds: Timeout value for regex compilation.
retry_after_seconds: Seconds to wait before retrying (rate limit errors).
socket_path: fail2ban socket path for connection errors.
current_status: Current jail status for conflict errors.
actual_length: Actual pattern length (alias for pattern_length).
message: Generic error message string.
"""
jail_name: str
filename: str
filter_name: str
action_name: str
source_id: int
url: str
ip: str
pattern: str
error: str
pattern_length: int
max_length: int
timeout_seconds: int
retry_after_seconds: float
socket_path: str
current_status: str
actual_length: int
message: str
field_errors: int
first_field: str
class ComponentHealth(BanGuiBaseModel):
"""Health status of a single application component.
Fields:
name: Human-readable component name.
healthy: True when the component is operational.
message: Optional detail message (e.g., error description).
"""
name: str = Field(..., description="Component name.")
healthy: bool = Field(..., description="True when the component is operational.")
message: str | None = Field(
default=None,
description="Optional detail message, e.g. error description.",
)
class HealthResponse(BanGuiBaseModel):
"""Standardized response for the health check endpoint.
Fields:
status: Application health status — 'ok' when all components are healthy,
'degraded' when some components are unhealthy but the service can still
handle requests, 'unavailable' when fail2ban is offline.
fail2ban: fail2ban daemon status — 'online' or 'offline'.
database: Database connectivity — 'ok' or 'error'.
scheduler: Background scheduler status — 'running', 'stopped', or 'unknown'.
cache: Cache initialization status — 'initialised' or 'uninitialised'.
external_logging: External logging handler status — 'ok', 'error', or 'disabled'.
components: Per-component health detail list (empty when all healthy).
Example:
```python
# Healthy (HTTP 200)
{
"status": "ok",
"fail2ban": "online",
"database": "ok",
"scheduler": "running",
"cache": "initialised",
"external_logging": "disabled",
"components": []
}
# Unhealthy (HTTP 503)
{
"status": "unavailable",
"fail2ban": "offline",
"database": "ok",
"scheduler": "running",
"cache": "initialised",
"external_logging": "ok",
"components": [{"name": "fail2ban", "healthy": false, "message": "Socket not reachable"}]
}
```
"""
status: Literal["ok", "degraded", "unavailable"] = Field(
...,
description=(
"Application health status: 'ok' when healthy, 'degraded' when some "
"components are unhealthy, 'unavailable' when fail2ban is offline."
),
)
fail2ban: Literal["online", "offline"] = Field(
...,
description="fail2ban daemon status: 'online' when reachable, 'offline' otherwise.",
)
database: Literal["ok", "error"] = Field(
...,
description="Database connectivity: 'ok' when accessible, 'error' when not.",
)
scheduler: Literal["running", "stopped", "unknown"] = Field(
...,
description="Background scheduler status: 'running', 'stopped', or 'unknown'.",
)
cache: Literal["initialised", "uninitialised"] = Field(
...,
description="Cache initialization status: 'initialised' when ready, 'uninitialised' when not.",
)
external_logging: Literal["ok", "error", "disabled"] = Field(
...,
description=(
"External logging handler status: 'ok' when operational, 'error' when "
"initialization failed, 'disabled' when external logging is not configured."
),
)
components: list[ComponentHealth] = Field(
default_factory=list,
description="Per-component health detail list. Empty when status is 'ok'.",
)
class FlushLogsResponse(BanGuiBaseModel):
"""Standardized response for the flush-logs command endpoint.
Fields:
message: Human-readable result message from fail2ban.
Example:
```python
{"message": "Success: fail2ban log files were flushed."}
```
"""
message: str = Field(..., description="Human-readable result message from fail2ban.")
class ReadyCheck(BanGuiBaseModel):
"""Result of a single readiness subsystem check.
Fields:
name: Subsystem name (e.g., "database", "fail2ban", "config_dir").
healthy: True when the subsystem is reachable/operational.
message: Optional error message describing the failure.
"""
name: str = Field(..., description="Subsystem name.")
healthy: bool = Field(..., description="True when the subsystem is operational.")
message: str | None = Field(
default=None,
description="Error detail when the check fails.",
)
class ReadyResponse(BanGuiBaseModel):
"""Structured readiness check response for the ``/health/ready`` endpoint.
Fields:
status: "ok" when all checks pass, "error" when at least one failed.
checks: Per-subsystem result list.
failed_count: Number of checks that returned healthy=False.
Example:
```python
# All healthy (HTTP 200)
{"status": "ok", "checks": [...], "failed_count": 0}
# Some failed (HTTP 503)
{"status": "error", "checks": [...], "failed_count": 2}
```
"""
status: Literal["ok", "error"] = Field(
...,
description="'ok' when all checks pass, 'error' when at least one fails.",
)
checks: list[ReadyCheck] = Field(
default_factory=list,
description="Per-subsystem check results.",
)
failed_count: int = Field(
...,
ge=0,
description="Number of checks that returned healthy=False.",
)

View File

@@ -1,16 +1,21 @@
"""Server status and health-check Pydantic models.
Used by the dashboard router, health service, and server settings router.
All models inherit from :class:`~app.models.response.BanGuiBaseModel` which
enforces the project-wide **snake_case** API field naming policy: field names
are identical in Python, JSON wire format, and the corresponding TypeScript
interfaces.
"""
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field
from app.models.response import BanGuiBaseModel
class ServerStatus(BaseModel):
class ServerStatus(BanGuiBaseModel):
"""Cached fail2ban server health snapshot."""
model_config = ConfigDict(strict=True)
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="fail2ban version string.")
active_jails: int = Field(default=0, ge=0, description="Number of currently active jails.")
@@ -18,19 +23,15 @@ class ServerStatus(BaseModel):
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
class ServerStatusResponse(BaseModel):
class ServerStatusResponse(BanGuiBaseModel):
"""Response for ``GET /api/dashboard/status``."""
model_config = ConfigDict(strict=True)
status: ServerStatus
class ServerSettings(BaseModel):
class ServerSettings(BanGuiBaseModel):
"""Domain model for fail2ban server-level settings."""
model_config = ConfigDict(strict=True)
log_level: str = Field(..., description="fail2ban daemon log level.")
log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.")
syslog_socket: str | None = Field(default=None)
@@ -39,22 +40,18 @@ class ServerSettings(BaseModel):
db_max_matches: int = Field(..., description="Maximum stored matches per ban record.")
class ServerSettingsUpdate(BaseModel):
class ServerSettingsUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/server/settings``."""
model_config = ConfigDict(strict=True)
log_level: str | None = Field(default=None)
log_target: str | None = Field(default=None)
db_purge_age: int | None = Field(default=None, ge=0)
db_max_matches: int | None = Field(default=None, ge=0)
class ServerSettingsResponse(BaseModel):
class ServerSettingsResponse(BanGuiBaseModel):
"""Response for ``GET /api/server/settings``."""
model_config = ConfigDict(strict=True)
settings: ServerSettings
warnings: dict[str, bool] = Field(
default_factory=dict,

View File

@@ -0,0 +1,32 @@
"""Server domain models.
Internal domain-focused models used by server_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.server` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DomainServerSettings:
"""Fail2ban server-level settings (domain model)."""
log_level: str
log_target: str
db_path: str
db_purge_age: int
db_max_matches: int
syslog_socket: str | None = None
@dataclass(frozen=True)
class DomainServerSettingsResult:
"""Server settings with warnings (domain model)."""
settings: DomainServerSettings
warnings: dict[str, bool]

View File

@@ -3,19 +3,106 @@
Request, response, and domain models for the first-run configuration wizard.
"""
from pydantic import BaseModel, ConfigDict, Field
from pydantic import Field, field_validator
from app.models.response import BanGuiBaseModel
# Top-50 most-common plaintext passwords (lower-case).
# Source: aggregated public breach compilations (Have I Been Pwned, Wikipedia).
# Covers passwords that pass structural checks (uppercase + digit + special char)
# but are trivial to guess.
_COMMON_PASSWORDS: frozenset[str] = frozenset(
{
"password",
"password1",
"password123",
"password1234",
"password!",
"letmein",
"welcome",
"admin",
"admin123",
"administrator",
"qwerty",
"qwerty123",
"qwerty1234",
"abc123",
"abcdef",
"123456",
"1234567",
"12345678",
"123456789",
"1234567890",
"iloveyou",
"iloveyou1",
"monkey",
"dragon",
"master",
"login",
"login123",
"passw0rd",
"passw0rd!",
"changeme",
"default",
"guest",
"guest123",
"fuckyou",
"fuckyou1",
"shit",
"asshole",
"hello",
"hello123",
"hello!",
"world",
"pass",
"test",
"test123",
"test!",
"root",
"root123",
"p@ssword",
"p@ssword1",
"p@ssw0rd",
"p@ssw0rd!",
"sunshine",
"princess",
"shadow",
"shadow123",
"access",
"access123",
"mypass",
"mypass123",
}
)
class SetupRequest(BaseModel):
class SetupRequest(BanGuiBaseModel):
"""Payload for ``POST /api/setup``."""
model_config = ConfigDict(strict=True)
master_password: str = Field(
...,
min_length=8,
description="Master password that protects the BanGUI interface.",
max_length=72,
description="Master password that protects the BanGUI interface (max 72 bytes due to bcrypt truncation).",
)
@field_validator("master_password")
@classmethod
def validate_master_password(cls, value: str) -> str:
if len(value) < 8:
raise ValueError("Password must be at least 8 characters long.")
if len(value) > 72:
raise ValueError("Password must not exceed 72 bytes (bcrypt limitation).")
if not any(char.isupper() for char in value):
raise ValueError("Password must include at least one uppercase letter.")
if not any(char.isdigit() for char in value):
raise ValueError("Password must include at least one number.")
if not any(char in "!@#$%^&*()" for char in value):
raise ValueError("Password must include at least one special character (!@#$%^&*()).")
if value.lower() in _COMMON_PASSWORDS:
raise ValueError("Password is too common. Choose something more unique.")
return value
database_path: str = Field(
default="bangui.db",
description="Filesystem path to the BanGUI SQLite application database.",
@@ -35,29 +122,23 @@ class SetupRequest(BaseModel):
)
class SetupResponse(BaseModel):
class SetupResponse(BanGuiBaseModel):
"""Response returned after a successful initial setup."""
model_config = ConfigDict(strict=True)
message: str = Field(
default="Setup completed successfully. Please log in.",
)
class SetupTimezoneResponse(BaseModel):
class SetupTimezoneResponse(BanGuiBaseModel):
"""Response for ``GET /api/setup/timezone``."""
model_config = ConfigDict(strict=True)
timezone: str = Field(..., description="Configured IANA timezone identifier.")
class SetupStatusResponse(BaseModel):
class SetupStatusResponse(BanGuiBaseModel):
"""Response indicating whether setup has been completed."""
model_config = ConfigDict(strict=True)
completed: bool = Field(
...,
description="``True`` if the initial setup has already been performed.",

View File

@@ -13,6 +13,37 @@ if TYPE_CHECKING:
import aiosqlite
async def create_source_in_tx(
db: aiosqlite.Connection,
name: str,
url: str,
*,
enabled: bool = True,
) -> int:
"""Insert a new blocklist source without committing.
Caller is responsible for committing or rolling back the transaction.
Use this variant when validation must be atomic with insert.
Args:
db: Active aiosqlite connection with an open transaction.
name: Human-readable display name.
url: URL of the blocklist text file.
enabled: Whether the source is active. Defaults to ``True``.
Returns:
The ``ROWID`` / primary key of the new row.
"""
cursor = await db.execute(
"""
INSERT INTO blocklist_sources (name, url, enabled)
VALUES (?, ?, ?)
""",
(name, url, int(enabled)),
)
return int(cursor.lastrowid) # type: ignore[arg-type]
async def create_source(
db: aiosqlite.Connection,
name: str,

View File

@@ -10,12 +10,16 @@ service layers can focus on business logic and formatting.
from __future__ import annotations
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
import aiosqlite
from app.utils.fail2ban_db_utils import escape_like
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import Iterable
from app.models.ban import BanOrigin
@@ -70,6 +74,53 @@ def _make_db_uri(db_path: str) -> str:
return f"file:{db_path}?mode=ro"
async def _acquire_readonly_connection(
db_path: str,
) -> aiosqlite.Connection:
"""Open a read-only connection to the fail2ban database.
Defense-in-depth: both the ``?mode=ro`` URI flag AND the SQLite-level
``PRAGMA query_only = ON`` are applied. The URI flag is a library-level hint
that can be bypassed by malformed URIs or version inconsistencies;
``query_only`` is a connection-level enforcement that makes all write
operations fail. We verify enforcement by reading back the PRAGMA value.
Args:
db_path: Path to the fail2ban SQLite database.
Returns:
An aiosqlite connection in guaranteed read-only mode.
Raises:
AssertionError: If PRAGMA query_only is not confirmed as enabled.
"""
conn = await aiosqlite.connect(_make_db_uri(db_path), uri=True)
# Set connection-level read-only enforcement and verify in one statement.
# Even if the ?mode=ro URI flag is bypassed, this PRAGMA blocks writes.
cursor = await conn.execute("PRAGMA query_only = ON")
await cursor.close()
# Verify the PRAGMA took effect.
cursor = await conn.execute("PRAGMA query_only")
row = await cursor.fetchone()
await cursor.close()
if not row or row[0] != 1:
await conn.close()
raise AssertionError(
"PRAGMA query_only is not enabled; connection may be writable"
)
return conn
@asynccontextmanager
async def _readonly_connection(db_path: str) -> AsyncIterator[aiosqlite.Connection]:
"""Async context manager that yields a read-only fail2ban DB connection."""
conn = await _acquire_readonly_connection(db_path)
try:
yield conn
finally:
await conn.close()
def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
"""Return a SQL fragment and parameters for the origin filter."""
@@ -114,7 +165,7 @@ def _rows_to_history_records(rows: Iterable[aiosqlite.Row]) -> list[HistoryRecor
async def check_db_nonempty(db_path: str) -> bool:
"""Return True if the fail2ban database contains at least one ban row."""
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db, db.execute(
async with _readonly_connection(db_path) as db, db.execute(
"SELECT 1 FROM bans LIMIT 1"
) as cur:
row = await cur.fetchone()
@@ -153,7 +204,7 @@ async def get_currently_banned(
placeholder = ", ".join("?" for _ in ip_filter)
ip_filter_clause = f" AND ip IN ({placeholder})"
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
@@ -193,7 +244,7 @@ async def get_ban_counts_by_bucket(
origin_clause, origin_params = _origin_sql_filter(origin)
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
@@ -223,7 +274,7 @@ async def get_ban_event_counts(
origin_clause, origin_params = _origin_sql_filter(origin)
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT ip, COUNT(*) AS event_count "
@@ -248,7 +299,7 @@ async def get_bans_by_jail(
origin_clause, origin_params = _origin_sql_filter(origin)
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
@@ -281,7 +332,7 @@ async def get_bans_table_summary(
empty the min/max values will be ``None``.
"""
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT COUNT(*), MIN(timeofban), MAX(timeofban) FROM bans"
@@ -321,8 +372,8 @@ async def get_history_page(
params.append(jail)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
origin_clause, origin_params = _origin_sql_filter(origin)
if origin_clause:
@@ -335,7 +386,7 @@ async def get_history_page(
effective_page_size: int = page_size
offset: int = (page - 1) * effective_page_size
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
@@ -360,7 +411,7 @@ async def get_history_page(
async def get_history_for_ip(db_path: str, ip: str) -> list[HistoryRecord]:
"""Return the full ban timeline for a specific IP."""
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT jail, ip, timeofban, bancount, data "

View File

@@ -9,22 +9,17 @@ connection lifetimes.
from __future__ import annotations
from typing import TYPE_CHECKING, TypedDict
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
import aiosqlite
from app.models.geo import GeoCacheEntry
class GeoCacheRow(TypedDict):
"""A single row from the ``geo_cache`` table."""
ip: str
country_code: str | None
country_name: str | None
asn: str | None
org: str | None
# Alias for backward compatibility with protocols
GeoCacheRow = GeoCacheEntry
async def load_all(db: aiosqlite.Connection) -> list[GeoCacheRow]:
@@ -98,20 +93,60 @@ async def upsert_entry(
country_name = excluded.country_name,
asn = excluded.asn,
org = excluded.org,
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
""",
(ip, country_code, country_name, asn, org),
)
async def upsert_entry_and_commit(
db: aiosqlite.Connection,
ip: str,
country_code: str | None,
country_name: str | None,
asn: str | None,
org: str | None,
) -> None:
"""Insert or update a resolved geo cache entry and commit.
Wraps the upsert in an explicit transaction to ensure atomicity.
"""
try:
await db.execute("BEGIN IMMEDIATE")
await upsert_entry(db, ip, country_code, country_name, asn, org)
await db.commit()
except Exception:
await db.rollback()
raise
async def upsert_neg_entry(db: aiosqlite.Connection, ip: str) -> None:
"""Record a failed lookup attempt as a negative entry."""
await db.execute(
"INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)",
"""
INSERT INTO geo_cache (ip) VALUES (?)
ON CONFLICT(ip) DO UPDATE SET
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
""",
(ip,),
)
async def upsert_neg_entry_and_commit(db: aiosqlite.Connection, ip: str) -> None:
"""Record a failed lookup attempt and commit the transaction.
Wraps the upsert in an explicit transaction to ensure atomicity.
"""
try:
await db.execute("BEGIN IMMEDIATE")
await upsert_neg_entry(db, ip)
await db.commit()
except Exception:
await db.rollback()
raise
async def bulk_upsert_entries(
db: aiosqlite.Connection,
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
@@ -129,7 +164,8 @@ async def bulk_upsert_entries(
country_name = excluded.country_name,
asn = excluded.asn,
org = excluded.org,
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
""",
rows,
)
@@ -146,3 +182,91 @@ async def bulk_upsert_neg_entries(db: aiosqlite.Connection, ips: list[str]) -> i
[(ip,) for ip in ips],
)
return len(ips)
async def bulk_upsert_entries_and_commit(
db: aiosqlite.Connection,
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
) -> int:
"""Bulk insert or update multiple geo cache entries and commit.
Wraps the bulk upsert in an explicit transaction to ensure atomicity.
"""
try:
await db.execute("BEGIN IMMEDIATE")
count = await bulk_upsert_entries(db, rows)
await db.commit()
return count
except Exception:
await db.rollback()
raise
async def bulk_upsert_neg_entries_and_commit(db: aiosqlite.Connection, ips: list[str]) -> int:
"""Bulk insert negative lookup entries and commit.
Wraps the bulk upsert in an explicit transaction to ensure atomicity.
"""
try:
await db.execute("BEGIN IMMEDIATE")
count = await bulk_upsert_neg_entries(db, ips)
await db.commit()
return count
except Exception:
await db.rollback()
raise
async def bulk_upsert_entries_and_neg_entries_and_commit(
db: aiosqlite.Connection,
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
ips: list[str],
) -> tuple[int, int]:
"""Persist positive and negative geo cache rows together, then commit.
Wraps both upserts in a single transaction to ensure atomicity.
Either all rows are persisted or none are.
Args:
db: Active aiosqlite connection.
rows: Sequence of (ip, country_code, country_name, asn, org) tuples.
ips: List of IP strings for negative entries (failed lookups).
Returns:
A tuple (positive_count, negative_count) of rows persisted.
"""
positive_count = 0
negative_count = 0
try:
await db.execute("BEGIN IMMEDIATE")
if rows:
positive_count = await bulk_upsert_entries(db, rows)
if ips:
negative_count = await bulk_upsert_neg_entries(db, ips)
if rows or ips:
await db.commit()
except Exception:
await db.rollback()
raise
return positive_count, negative_count
async def delete_stale_entries(db: aiosqlite.Connection, cutoff_iso: str) -> int:
"""Delete geo cache entries not referenced since the cutoff timestamp.
Args:
db: Open BanGUI application database connection.
cutoff_iso: ISO 8601 timestamp (e.g., '2024-01-01T00:00:00Z'). Entries with
``last_seen`` before this time will be deleted.
Returns:
The number of rows deleted.
"""
async with db.execute(
"DELETE FROM geo_cache WHERE last_seen < ?",
(cutoff_iso,),
) as cur:
return cur.rowcount if cur.rowcount is not None else 0

View File

@@ -2,14 +2,23 @@
Provides persistence APIs for the BanGUI archival history table in the
application database.
Supports both offset-based and cursor-based pagination:
- **Offset pagination** (legacy): ``get_archived_history(page=2, page_size=100)``
- convenient for small datasets but degrades on large offsets.
- **Cursor pagination** (recommended): ``get_archived_history_keyset(page_size=100, last_ban_id=None)``
- constant-time performance regardless of dataset size.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
from app.utils.fail2ban_db_utils import escape_like
if TYPE_CHECKING:
import aiosqlite
@@ -36,6 +45,15 @@ async def archive_ban_event(
return inserted
async def get_max_timeofban(db: aiosqlite.Connection) -> int | None:
"""Return the latest archived ban timestamp or ``None`` when empty."""
async with db.execute("SELECT MAX(timeofban) FROM history_archive") as cursor:
row = await cursor.fetchone()
if row is None or row[0] is None:
return None
return int(row[0])
async def get_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
@@ -45,7 +63,7 @@ async def get_archived_history(
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict], int]:
) -> tuple[list[dict[str, Any]], int]:
"""Return a paginated archived history result set."""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return [], 0
@@ -67,8 +85,8 @@ async def get_archived_history(
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
@@ -89,7 +107,7 @@ async def get_archived_history(
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, ip, timeofban, bancount, data, action "
"SELECT id, jail, ip, timeofban, bancount, data, action "
"FROM history_archive "
f"{where_sql} "
"ORDER BY timeofban DESC LIMIT ? OFFSET ?",
@@ -99,12 +117,13 @@ async def get_archived_history(
records = [
{
"jail": str(r[0]),
"ip": str(r[1]),
"timeofban": int(r[2]),
"bancount": int(r[3]),
"data": str(r[4]),
"action": str(r[5]),
"id": int(r[0]),
"jail": str(r[1]),
"ip": str(r[2]),
"timeofban": int(r[3]),
"bancount": int(r[4]),
"data": str(r[5]),
"action": str(r[6]),
}
for r in rows
]
@@ -119,29 +138,59 @@ async def get_all_archived_history(
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict]:
"""Return all archived history rows for the given filters."""
page: int = 1
page_size: int = 500
all_rows: list[dict] = []
page_size: int = 1000,
max_rows: int = 50_000,
last_ban_id: int | None = None,
) -> list[dict[str, Any]]:
"""Return archived history rows for the given filters, bounded to *max_rows*.
Uses keyset pagination internally for constant-time performance regardless
of how deep into the result set we go. The caller must provide *last_ban_id*
from the previous call to continue pagination; ``None`` starts fresh.
Args:
page_size: Number of rows to fetch per internal batch (default 1000).
max_rows: Hard cap on total rows returned (default 50 000). When
reached the function returns even if more rows exist. Pass ``0``
to request zero rows (useful for count-only callers).
last_ban_id: Cursor from the previous call. ``None`` for the first
call — the result set will start from the newest row.
"""
if max_rows <= 0:
return []
all_rows: list[dict[str, Any]] = []
current_last_ban_id: int | None = last_ban_id
while True:
rows, total = await get_archived_history(
batch, has_more = await get_archived_history_keyset(
db=db,
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
action=action,
page=page,
page_size=page_size,
last_ban_id=current_last_ban_id,
)
all_rows.extend(rows)
if len(rows) < page_size:
if not batch:
break
all_rows.extend(batch)
if len(all_rows) >= max_rows:
break
if not has_more:
break
# Use the id of the last row in the batch as the next cursor.
# Rows are ordered id DESC, so the last row has the smallest id
# seen in this batch and is the correct keyset anchor.
last_row = batch[-1]
current_last_ban_id = last_row.get("id")
if current_last_ban_id is None:
# Fallback: determine id from the WHERE clause of the previous query.
# If we somehow cannot determine the id, stop to avoid an infinite loop.
break
page += 1
return all_rows
return all_rows[:max_rows]
async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) -> int:
@@ -154,3 +203,302 @@ async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) ->
deleted = cursor.rowcount
await db.commit()
return deleted
async def get_archived_history_keyset(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page_size: int = 100,
last_ban_id: int | None = None,
) -> tuple[list[dict[str, Any]], bool]:
"""Return cursor-paginated archived history using keyset pagination.
Uses keyset pagination (WHERE id < last_id) for constant-time performance
regardless of result set size. This is the recommended pagination method
for large result sets.
Ordering is by timeofban DESC (newest first), with id DESC as tiebreaker for
events with identical timestamps. This ensures stable, deterministic pagination.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
jail: If given, filter to events for this jail.
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
page_size: Number of items per page (max returned is page_size + 1 to detect overflow).
last_ban_id: The ID of the last item from the previous page (for cursor).
None for the first page.
Returns:
A 2-tuple ``(records, has_more)`` where:
- *records* is a list of up to page_size dicts with ban details
- *has_more* is True if there are additional pages beyond this one
"""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return [], False
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
if last_ban_id is not None:
wheres.append("id < ?")
params.append(last_ban_id)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
# Fetch page_size + 1 to detect if there are more pages
fetch_limit = page_size + 1
params.append(fetch_limit)
async with db.execute(
"SELECT id, jail, ip, timeofban, bancount, data, action "
"FROM history_archive "
f"{where_sql} "
"ORDER BY id DESC "
"LIMIT ?", # noqa: S608
params,
) as cur:
rows_iterable = await cur.fetchall()
rows = list(rows_iterable)
records = [
{
"id": int(r[0]),
"jail": str(r[1]),
"ip": str(r[2]),
"timeofban": int(r[3]),
"bancount": int(r[4]),
"data": str(r[5]),
"action": str(r[6]),
}
for r in rows[:page_size]
]
has_more = len(rows) > page_size
return records, has_more
async def get_ip_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict[str, Any]]:
"""Return ban event counts grouped by IP using SQL aggregation.
Uses SQL GROUP BY to aggregate in the database rather than loading
all rows into Python memory. Returns lightweight {ip, event_count} dicts
suitable for downstream aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
jail: If given, filter to events for this jail.
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of {ip: str, event_count: int} dicts.
"""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return []
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
"SELECT ip, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY ip",
params,
) as cur:
rows = await cur.fetchall()
return [
{"ip": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_jail_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
"""Return per-jail ban counts and total using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
A 2-tuple (total_count, jail_counts) where jail_counts is a list
of {jail: str, event_count: int} dicts sorted descending by count.
"""
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
f"SELECT COUNT(*) FROM history_archive {where_sql}", params
) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY jail "
"ORDER BY event_count DESC",
params,
) as cur:
rows = await cur.fetchall()
return total, [
{"jail": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_ban_counts_by_bucket(
db: aiosqlite.Connection,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[int]:
"""Return ban counts bucketed by time using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: Start of the time window (Unix timestamp).
bucket_secs: Width of each bucket in seconds.
num_buckets: Total number of buckets in the window.
origin: If given, filter by ban origin.
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of int counts, one per bucket, indexed by bucket index.
"""
wheres: list[str] = ["timeofban >= ?"]
params: list[object] = [since]
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres)
async with db.execute(
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
"COUNT(*) AS cnt "
"FROM history_archive "
f"{where_sql} GROUP BY bucket_idx ORDER BY bucket_idx",
(since, bucket_secs, *params),
) as cur:
rows = await cur.fetchall()
counts: list[int] = [0] * num_buckets
for row in rows:
idx: int = int(row[0])
if 0 <= idx < num_buckets:
counts[idx] = int(row[1])
return counts

View File

@@ -3,31 +3,30 @@
Persists and queries blocklist import run records in the ``import_log``
table. All methods are plain async functions that accept a
:class:`aiosqlite.Connection`.
Supports both offset-based and cursor-based pagination:
- **Offset pagination** (legacy): ``list_logs(page=2, page_size=50)`` - query-efficient
but degrades on large offsets.
- **Cursor pagination** (recommended): ``list_logs_keyset(page_size=50, last_log_id=None)``
- constant-time performance regardless of dataset size.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, TypedDict, cast
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from collections.abc import Mapping
import aiosqlite
from app.models.blocklist import ImportLogEntry
class ImportLogRow(TypedDict):
"""Row shape returned by queries on the import_log table."""
id: int
source_id: int | None
source_url: str
timestamp: str
ips_imported: int
ips_skipped: int
errors: str | None
# Alias for backward compatibility with protocols
ImportLogRow = ImportLogEntry
async def add_log(
db: aiosqlite.Connection,
*,
@@ -51,12 +50,15 @@ async def add_log(
Returns:
Primary key of the inserted row.
"""
import time
timestamp_unix: int = int(time.time())
cursor = await db.execute(
"""
INSERT INTO import_log (source_id, source_url, ips_imported, ips_skipped, errors)
VALUES (?, ?, ?, ?, ?)
INSERT INTO import_log (source_id, source_url, timestamp, ips_imported, ips_skipped, errors)
VALUES (?, ?, ?, ?, ?, ?)
""",
(source_id, source_url, ips_imported, ips_skipped, errors),
(source_id, source_url, timestamp_unix, ips_imported, ips_skipped, errors),
)
await db.commit()
return int(cursor.lastrowid) # type: ignore[arg-type]
@@ -152,19 +154,80 @@ def compute_total_pages(total: int, page_size: int) -> int:
return math.ceil(total / page_size)
async def list_logs_keyset(
db: aiosqlite.Connection,
*,
source_id: int | None = None,
page_size: int = 50,
last_log_id: int | None = None,
) -> tuple[list[ImportLogRow], bool]:
"""Return a cursor-paginated list of import log entries.
Uses keyset pagination (WHERE id < last_id) for constant-time performance
regardless of result set size. This is the recommended pagination method
for large result sets.
Args:
db: Active aiosqlite connection.
source_id: If given, filter to logs for this source only.
page_size: Number of items per page (max returned is page_size + 1 to detect overflow).
last_log_id: The ID of the last item from the previous page (for cursor).
None for the first page.
Returns:
A 2-tuple ``(items, has_more)`` where:
- *items* is a list of up to page_size ImportLogEntry objects
- *has_more* is True if there are additional pages beyond this one
"""
where = ""
params: list[object] = []
if source_id is not None:
where = " WHERE source_id = ?"
params.append(source_id)
if last_log_id is not None:
if where:
where += " AND id < ?"
else:
where = " WHERE id < ?"
params.append(last_log_id)
# Fetch page_size + 1 to detect if there are more pages
fetch_limit = page_size + 1
params.append(fetch_limit)
async with db.execute(
f"""
SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors
FROM import_log{where}
ORDER BY id DESC
LIMIT ?
""", # noqa: S608
params,
) as cursor:
rows_iterable = await cursor.fetchall()
rows = list(rows_iterable)
items = [_row_to_dict(r) for r in rows[:page_size]]
has_more = len(rows) > page_size
return items, has_more
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _row_to_dict(row: object) -> ImportLogRow:
"""Convert an aiosqlite row to a plain Python dict.
"""Convert an aiosqlite row to an ImportLogEntry Pydantic model.
Args:
row: An :class:`aiosqlite.Row` or similar mapping returned by a cursor.
Returns:
Dict mapping column names to Python values.
ImportLogEntry Pydantic model instance.
"""
mapping = cast("Mapping[str, object]", row)
return cast("ImportLogRow", dict(mapping))
from typing import Any as AnyType
mapping = cast("Mapping[str, AnyType]", row)
return ImportLogEntry.model_validate(dict(mapping))

View File

@@ -0,0 +1,163 @@
"""Import run repository for blocklist import idempotency tracking.
Persists and queries import run records in the ``import_runs`` table.
Enables detection of duplicate import attempts and prevents re-running bans
on scheduler retry after a crash.
All methods are plain async functions that accept an :class:`aiosqlite.Connection`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiosqlite
from app.models.blocklist import ImportRunEntry
async def get_by_source_and_hash(
db: aiosqlite.Connection,
source_id: int,
content_hash: str,
) -> ImportRunEntry | None:
"""Check if a specific import (by source and content hash) already exists.
Args:
db: Active aiosqlite connection.
source_id: FK to ``blocklist_sources.id``.
content_hash: SHA256 hash of the downloaded blocklist content.
Returns:
ImportRunEntry if found, None otherwise.
"""
async with db.execute(
"""
SELECT
id, source_id, content_hash, status,
imported_count, skipped_count, error_message,
created_at, updated_at
FROM import_runs
WHERE source_id = ? AND content_hash = ?
""",
(source_id, content_hash),
) as cursor:
row = await cursor.fetchone()
if not row:
return None
return ImportRunEntry(
id=row[0],
source_id=row[1],
content_hash=row[2],
status=row[3],
imported_count=row[4],
skipped_count=row[5],
error_message=row[6],
created_at=row[7],
updated_at=row[8],
)
async def upsert_pending(
db: aiosqlite.Connection,
source_id: int,
content_hash: str,
) -> int:
"""Atomically insert or reset a pending import run entry.
Uses ``INSERT ... ON CONFLICT`` to make the operation fully atomic —
no window between check and insert where a concurrent request can create
a duplicate row. If a row for ``(source_id, content_hash)`` already exists,
its status is reset to ``pending`` and its ID is returned.
Args:
db: Active aiosqlite connection.
source_id: FK to ``blocklist_sources.id``.
content_hash: SHA256 hash of the downloaded blocklist content.
Returns:
Primary key of the inserted or updated row.
"""
cursor = await db.execute(
"""
INSERT INTO import_runs (source_id, content_hash, status)
VALUES (?, ?, 'pending')
ON CONFLICT(source_id, content_hash) DO UPDATE SET
status = 'pending',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
RETURNING id;
""",
(source_id, content_hash),
)
row = await cursor.fetchone()
return int(row[0]) # type: ignore[arg-type]
async def mark_completed(
db: aiosqlite.Connection,
run_id: int,
imported_count: int,
skipped_count: int,
) -> None:
"""Mark an import run as completed with final counts.
Wraps the update in an explicit transaction to ensure atomicity.
Args:
db: Active aiosqlite connection.
run_id: Primary key of the import run.
imported_count: Number of IPs successfully banned.
skipped_count: Number of entries skipped (invalid or CIDR).
"""
try:
await db.execute("BEGIN IMMEDIATE")
await db.execute(
"""
UPDATE import_runs
SET status = 'completed',
imported_count = ?,
skipped_count = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?
""",
(imported_count, skipped_count, run_id),
)
await db.commit()
except Exception:
await db.rollback()
raise
async def mark_failed(
db: aiosqlite.Connection,
run_id: int,
error_message: str,
) -> None:
"""Mark an import run as failed with error details.
Wraps the update in an explicit transaction to ensure atomicity.
Args:
db: Active aiosqlite connection.
run_id: Primary key of the import run.
error_message: Error description.
"""
try:
await db.execute("BEGIN IMMEDIATE")
await db.execute(
"""
UPDATE import_runs
SET status = 'failed',
error_message = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?
""",
(error_message, run_id),
)
await db.commit()
except Exception:
await db.rollback()
raise

View File

@@ -0,0 +1,394 @@
"""Repository interface protocols for dependency injection.
Routers and services can depend on these abstractions instead of concrete
module implementations, making the backend easier to test and extend.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any, Protocol
import aiosqlite
from app.models.auth import Session
from app.models.ban import BanOrigin
from app.repositories.fail2ban_db_repo import BanIpCount, BanRecord, HistoryRecord, JailBanCount
from app.repositories.geo_cache_repo import GeoCacheRow
from app.repositories.import_log_repo import ImportLogRow
from app.models.blocklist import ImportRunEntry
class SessionRepository(Protocol):
"""Protocol for session persistence operations."""
async def create_session(
self,
db: aiosqlite.Connection,
token: str,
created_at: str,
expires_at: str,
) -> Session:
...
async def get_session(
self,
db: aiosqlite.Connection,
token: str,
) -> Session | None:
...
async def delete_session(
self,
db: aiosqlite.Connection,
token: str,
) -> None:
...
async def delete_expired_sessions(
self,
db: aiosqlite.Connection,
now_iso: str,
) -> int:
...
class SettingsRepository(Protocol):
"""Protocol for application settings persistence operations."""
async def get_setting(self, db: aiosqlite.Connection, key: str) -> str | None:
...
async def set_setting(self, db: aiosqlite.Connection, key: str, value: str) -> None:
...
async def delete_setting(self, db: aiosqlite.Connection, key: str) -> None:
...
async def get_all_settings(self, db: aiosqlite.Connection) -> dict[str, str]:
...
async def set_settings_batch(self, db: aiosqlite.Connection, settings: dict[str, str]) -> None:
...
class BlocklistRepository(Protocol):
async def create_source(
self,
db: aiosqlite.Connection,
name: str,
url: str,
*,
enabled: bool = True,
) -> int:
...
async def get_source(
self,
db: aiosqlite.Connection,
source_id: int,
) -> dict[str, Any] | None:
...
async def list_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
...
async def list_enabled_sources(self, db: aiosqlite.Connection) -> list[dict[str, Any]]:
...
async def update_source(
self,
db: aiosqlite.Connection,
source_id: int,
*,
name: str | None = None,
url: str | None = None,
enabled: bool | None = None,
) -> bool:
...
async def delete_source(self, db: aiosqlite.Connection, source_id: int) -> bool:
...
class ImportLogRepository(Protocol):
async def add_log(
self,
db: aiosqlite.Connection,
*,
source_id: int | None,
source_url: str,
ips_imported: int,
ips_skipped: int,
errors: str | None,
) -> int:
...
async def list_logs(
self,
db: aiosqlite.Connection,
*,
source_id: int | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[ImportLogRow], int]:
...
async def get_last_log(self, db: aiosqlite.Connection) -> ImportLogRow | None:
...
def compute_total_pages(self, total: int, page_size: int) -> int:
...
class ImportRunRepository(Protocol):
"""Protocol for tracking blocklist import runs for idempotency."""
async def get_by_source_and_hash(
self,
db: aiosqlite.Connection,
source_id: int,
content_hash: str,
) -> ImportRunEntry | None:
"""Check if a specific import (by source and content hash) has been completed."""
...
async def upsert_pending(
self,
db: aiosqlite.Connection,
source_id: int,
content_hash: str,
) -> int:
"""Atomically insert or reset a pending import run entry. Returns the id."""
...
async def mark_completed(
self,
db: aiosqlite.Connection,
run_id: int,
imported_count: int,
skipped_count: int,
) -> None:
"""Mark an import run as completed with final counts."""
...
async def mark_failed(
self,
db: aiosqlite.Connection,
run_id: int,
error_message: str,
) -> None:
"""Mark an import run as failed with error details."""
...
class GeoCacheRepository(Protocol):
async def load_all(self, db: aiosqlite.Connection) -> list[GeoCacheRow]:
...
async def get_unresolved_ips(self, db: aiosqlite.Connection) -> list[str]:
...
async def count_unresolved(self, db: aiosqlite.Connection) -> int:
...
async def upsert_entry(
self,
db: aiosqlite.Connection,
ip: str,
country_code: str | None,
country_name: str | None,
asn: str | None,
org: str | None,
) -> None:
...
async def upsert_entry_and_commit(
self,
db: aiosqlite.Connection,
ip: str,
country_code: str | None,
country_name: str | None,
asn: str | None,
org: str | None,
) -> None:
...
async def upsert_neg_entry(self, db: aiosqlite.Connection, ip: str) -> None:
...
async def upsert_neg_entry_and_commit(self, db: aiosqlite.Connection, ip: str) -> None:
...
async def bulk_upsert_entries(
self,
db: aiosqlite.Connection,
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
) -> int:
...
async def bulk_upsert_entries_and_commit(
self,
db: aiosqlite.Connection,
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
) -> int:
...
async def bulk_upsert_neg_entries(self, db: aiosqlite.Connection, ips: list[str]) -> int:
...
async def bulk_upsert_neg_entries_and_commit(self, db: aiosqlite.Connection, ips: list[str]) -> int:
...
async def bulk_upsert_entries_and_neg_entries_and_commit(
self,
db: aiosqlite.Connection,
rows: Sequence[tuple[str, str | None, str | None, str | None, str | None]],
ips: list[str],
) -> tuple[int, int]:
...
async def delete_stale_entries(self, db: aiosqlite.Connection, cutoff_iso: str) -> int:
...
class HistoryArchiveRepository(Protocol):
"""Protocol for archived ban history persistence operations."""
async def archive_ban_event(
self,
db: aiosqlite.Connection,
jail: str,
ip: str,
timeofban: int,
bancount: int,
data: str,
action: str = "ban",
) -> bool:
...
async def get_max_timeofban(self, db: aiosqlite.Connection) -> int | None:
...
async def get_archived_history(
self,
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict[str, Any]], int]:
...
async def get_all_archived_history(
self,
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page_size: int = 1000,
max_rows: int = 50_000,
last_ban_id: int | None = None,
) -> list[dict[str, Any]]:
...
async def purge_archived_history(self, db: aiosqlite.Connection, age_seconds: int) -> int:
...
async def get_ip_ban_counts(
self,
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict[str, Any]]:
...
async def get_jail_ban_counts(
self,
db: aiosqlite.Connection,
since: int | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
...
async def get_ban_counts_by_bucket(
self,
db: aiosqlite.Connection,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[int]:
...
class Fail2BanDbRepository(Protocol):
async def check_db_nonempty(self, db_path: str) -> bool:
...
async def get_currently_banned(
self,
db_path: str,
since: int,
origin: BanOrigin | None = None,
*,
ip_filter: list[str] | None = None,
limit: int | None = None,
offset: int | None = None,
) -> tuple[list[BanRecord], int]:
...
async def get_ban_counts_by_bucket(
self,
db_path: str,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
) -> list[int]:
...
async def get_ban_event_counts(
self,
db_path: str,
since: int,
origin: BanOrigin | None = None,
) -> list[BanIpCount]:
...
async def get_bans_by_jail(
self,
db_path: str,
since: int,
origin: BanOrigin | None = None,
) -> tuple[int, list[JailBanCount]]:
...
async def get_bans_table_summary(self, db_path: str) -> tuple[int, int | None, int | None]:
...
async def get_history_page(
self,
db_path: str,
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
origin: BanOrigin | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[HistoryRecord], int]:
...
async def get_history_for_ip(self, db_path: str, ip: str) -> list[HistoryRecord]:
...

View File

@@ -2,10 +2,15 @@
Provides storage, retrieval, and deletion of session records in the
``sessions`` table of the application SQLite database.
Session tokens are stored as one-way SHA256 hashes to ensure that if the
database is exposed, the session tokens themselves cannot be directly used.
The hash is computed from the raw token before all database operations.
"""
from __future__ import annotations
import hashlib
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -14,6 +19,18 @@ if TYPE_CHECKING:
from app.models.auth import Session
def _hash_token(token: str) -> str:
"""Return the SHA256 hash of a session token.
Args:
token: The raw session token to hash.
Returns:
The hexadecimal SHA256 digest of the token.
"""
return hashlib.sha256(token.encode()).hexdigest()
async def create_session(
db: aiosqlite.Connection,
token: str,
@@ -30,10 +47,15 @@ async def create_session(
Returns:
The newly created :class:`~app.models.auth.Session`.
Note:
The token is hashed before storage. The returned Session object
contains the original raw token for use in signing and response.
"""
token_hash = _hash_token(token)
cursor = await db.execute(
"INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)",
(token, created_at, expires_at),
"INSERT INTO sessions (token_hash, created_at, expires_at) VALUES (?, ?, ?)",
(token_hash, created_at, expires_at),
)
await db.commit()
return Session(
@@ -53,10 +75,14 @@ async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
Returns:
The :class:`~app.models.auth.Session` if found, else ``None``.
Note:
The token is hashed before the database lookup.
"""
token_hash = _hash_token(token)
async with db.execute(
"SELECT id, token, created_at, expires_at FROM sessions WHERE token = ?",
(token,),
"SELECT id, token_hash, created_at, expires_at FROM sessions WHERE token_hash = ?",
(token_hash,),
) as cursor:
row = await cursor.fetchone()
@@ -65,7 +91,7 @@ async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
return Session(
id=int(row[0]),
token=str(row[1]),
token=token,
created_at=str(row[2]),
expires_at=str(row[3]),
)
@@ -77,8 +103,12 @@ async def delete_session(db: aiosqlite.Connection, token: str) -> None:
Args:
db: Active aiosqlite connection.
token: The session token to remove.
Note:
The token is hashed before the database lookup.
"""
await db.execute("DELETE FROM sessions WHERE token = ?", (token,))
token_hash = _hash_token(token)
await db.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
await db.commit()

View File

@@ -57,6 +57,36 @@ async def delete_setting(db: aiosqlite.Connection, key: str) -> None:
await db.commit()
async def set_settings_batch(db: aiosqlite.Connection, settings: dict[str, str]) -> None:
"""Insert or replace multiple settings atomically in a single transaction.
Wraps all writes in a single BEGIN IMMEDIATE ... COMMIT transaction to ensure
atomicity. Either all settings are persisted or none are. This is useful for
operations that must succeed as a logical unit (e.g., setup initialization).
Args:
db: Active aiosqlite connection.
settings: A dictionary mapping setting keys to their values.
Raises:
Any exception from executing the SQL will cause a rollback.
"""
if not settings:
return
try:
await db.execute("BEGIN IMMEDIATE;")
for key, value in settings.items():
await db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
await db.commit()
except Exception:
await db.rollback()
raise
async def get_all_settings(db: aiosqlite.Connection) -> dict[str, str]:
"""Return all settings as a plain ``dict``.

View File

@@ -0,0 +1,357 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep
from app.models.config import (
ActionConfig,
ActionCreateRequest,
ActionListResponse,
ActionUpdateRequest,
)
from app.services import action_config_service
from app.utils.constants import (
RATE_LIMIT_ACTION_CREATE_REQUESTS,
RATE_LIMIT_ACTION_DELETE_REQUESTS,
RATE_LIMIT_ACTION_UPDATE_REQUESTS,
)
router: APIRouter = APIRouter(prefix="/actions", tags=["Action Config"])
_MINUTE = 60
_ACTION_UPDATE_BUCKET = "action:update"
_ACTION_CREATE_BUCKET = "action:create"
_ACTION_DELETE_BUCKET = "action:delete"
def _check_action_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for action update operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_ACTION_UPDATE_BUCKET, client_ip, RATE_LIMIT_ACTION_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"action_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for action update operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_action_create_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for action create operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_ACTION_CREATE_BUCKET, client_ip, RATE_LIMIT_ACTION_CREATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"action_create_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for action create operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_action_delete_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for action delete operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_ACTION_DELETE_BUCKET, client_ip, RATE_LIMIT_ACTION_DELETE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"action_delete_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for action delete operations. Please try again later.",
retry_after_seconds=retry_after,
)
_ActionNamePath = Annotated[
str,
Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'),
]
_NamePath = Annotated[
str,
Path(description='Jail name as configured in fail2ban.'),
]
@router.get(
"",
response_model=ActionListResponse,
summary="List all available actions with active/inactive status",
responses={
200: {"description": "Action list returned", "model": ActionListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def list_actions(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
) -> ActionListResponse:
"""Return all actions discovered in ``action.d/`` with active/inactive status.
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
corresponding ``.local`` overrides, and cross-references each action's
name against the ``action`` fields of currently running jails to determine
whether it is active.
Active actions (those used by at least one running jail) are sorted to the
top of the list; inactive actions follow. Both groups are sorted
alphabetically within themselves.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.ActionListResponse` with all discovered
actions.
"""
result = await action_config_service.list_actions(config_dir, socket_path)
result.actions.sort(key=lambda a: (not a.active, a.name.lower()))
return result
@router.get(
"/{name}",
response_model=ActionConfig,
summary="Return full parsed detail for a single action",
responses={
200: {"description": "Action config returned", "model": ActionConfig},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action not found in action.d/"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_action(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
name: _ActionNamePath,
) -> ActionConfig:
"""Return the full parsed configuration and active/inactive status for one action.
Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding
``.local`` override, and annotates the result with ``active``,
``used_by_jails``, ``source_file``, and ``has_local_override``.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
name: Action base name (with or without ``.conf`` extension).
Returns:
:class:`~app.models.config.ActionConfig`.
Raises:
HTTPException: 404 if the action is not found in ``action.d/``.
"""
return await action_config_service.get_action(config_dir, socket_path, name)
# ---------------------------------------------------------------------------
# Action write endpoints (Task 3.2)
# ---------------------------------------------------------------------------
@router.put(
"/{name}",
response_model=ActionConfig,
summary="Update an action's .local override with new lifecycle command values",
dependencies=[Depends(_check_action_update_rate_limit)],
responses={
200: {"description": "Action updated", "model": 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"},
},
)
async def update_action(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
name: _ActionNamePath,
body: ActionUpdateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
) -> ActionConfig:
"""Update an action's ``[Definition]`` fields by writing a ``.local`` override.
Only non-``null`` fields in the request body are written. The original
``.conf`` file is never modified.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Action base name (with or without ``.conf`` extension).
body: Partial update — lifecycle commands and ``[Init]`` parameters.
reload: When ``true``, trigger a fail2ban reload after writing.
Returns:
Updated :class:`~app.models.config.ActionConfig`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the action does not exist.
HTTPException: 500 if writing the ``.local`` file fails.
"""
return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload)
@router.post(
"",
response_model=ActionConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined action",
dependencies=[Depends(_check_action_create_rate_limit)],
responses={
201: {"description": "Action created", "model": ActionConfig},
400: {"description": "Invalid action name"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "Action already exists"},
429: {"description": "Rate limit exceeded for action create operations"},
500: {"description": "Failed to write .local file"},
},
)
async def create_action(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
body: ActionCreateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
) -> ActionConfig:
"""Create a new user-defined action at ``action.d/{name}.local``.
Returns 409 if a ``.conf`` or ``.local`` for the requested name already
exists.
Args:
request: FastAPI request object.
_auth: Validated session.
body: Action name and ``[Definition]`` lifecycle fields.
reload: When ``true``, trigger a fail2ban reload after creating.
Returns:
:class:`~app.models.config.ActionConfig` for the new action.
Raises:
HTTPException: 400 if the name contains invalid characters.
HTTPException: 409 if the action already exists.
HTTPException: 500 if writing fails.
"""
return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload)
@router.delete(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created action's .local file",
dependencies=[Depends(_check_action_delete_rate_limit)],
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"},
},
)
async def delete_action(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
name: _ActionNamePath,
) -> None:
"""Delete a user-created action's ``.local`` override file.
Shipped ``.conf``-only actions cannot be deleted (returns 409). When
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Action base name.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the action does not exist.
HTTPException: 409 if the action is a shipped default (conf-only).
HTTPException: 500 if deletion fails.
"""
await action_config_service.delete_action(config_dir, name)
# ---------------------------------------------------------------------------
# fail2ban log viewer endpoints
# ---------------------------------------------------------------------------

View File

@@ -3,86 +3,155 @@
``POST /api/auth/login`` — verify master password and issue a session.
``POST /api/auth/logout`` — revoke the current session.
The session token is returned both in the JSON body (for API-first
consumers) and as an ``HttpOnly`` cookie (for the browser SPA).
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie for
browser-based SPAs. The cookie is automatically included in all requests
and is inaccessible to JavaScript, protecting it from XSS attacks and
malicious scripts.
For programmatic API clients (non-browser), use ``POST /api/auth/token``
which returns a token in the response body for use in the ``Authorization``
header. This endpoint does not set a cookie.
"""
from __future__ import annotations
import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.utils.logging_compat import get_logger
from fastapi import APIRouter, Request, Response
from app.dependencies import DbDep, SettingsDep, invalidate_session_cache
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
from app.dependencies import (
AuthDep,
SessionCacheDep,
SessionServiceContextDep,
SettingsDep,
)
from app.exceptions import AuthenticationError
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse, SessionValidResponse
from app.services import auth_service
from app.utils.client_ip import get_client_ip
from app.utils.constants import SESSION_COOKIE_NAME
log: structlog.stdlib.BoundLogger = structlog.get_logger()
log = get_logger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
_COOKIE_NAME = "bangui_session"
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post(
"/login",
response_model=LoginResponse,
summary="Authenticate with the master password",
responses={
200: {"description": "Login successful", "model": LoginResponse},
401: {"description": "Invalid password"},
422: {"description": "Validation error — invalid request body"},
503: {"description": "Setup not complete"},
},
)
async def login(
body: LoginRequest,
response: Response,
db: DbDep,
request: Request,
session_ctx: SessionServiceContextDep,
settings: SettingsDep,
session_cache: SessionCacheDep,
) -> LoginResponse:
"""Verify the master password and return a session token.
On success the token is also set as an ``HttpOnly`` ``SameSite=Lax``
cookie so the browser SPA benefits from automatic credential handling.
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.
Args:
body: Login request validated by Pydantic.
response: FastAPI response object used to set the cookie.
db: Injected aiosqlite connection.
settings: Application settings (used for session duration).
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).
session_cache: Session cache for invalidating old sessions on login.
Returns:
:class:`~app.models.auth.LoginResponse` containing the token.
Raises:
HTTPException: 401 if the password is incorrect.
AuthenticationError: if the password is incorrect.
"""
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
try:
session = await auth_service.login(
db,
signed_token, expires_at, session = await auth_service.login(
session_ctx.db,
password=body.password,
session_duration_minutes=settings.session_duration_minutes,
session_secret=settings.session_secret,
session_repo=session_ctx.session_repo,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(exc),
) from exc
log.warning("login_failed", client_ip=client_ip, error=str(exc))
raise AuthenticationError(str(exc)) from exc
# Invalidate any cached sessions for the same user to prevent reuse of
# stale tokens (e.g., from a stolen device) beyond the cache TTL window.
session_cache.invalidate_by_user(session.id)
response.set_cookie(
key=_COOKIE_NAME,
value=session.token,
httponly=True,
samesite="lax",
secure=False, # Set to True in production behind HTTPS
key=SESSION_COOKIE_NAME,
value=signed_token,
httponly=settings.session_cookie_httponly,
samesite=settings.session_cookie_samesite,
secure=settings.session_cookie_secure,
max_age=settings.session_duration_minutes * 60,
)
return LoginResponse(token=session.token, expires_at=session.expires_at)
log.info("login_success", client_ip=client_ip)
return LoginResponse(expires_at=expires_at)
@router.get(
"/session",
response_model=SessionValidResponse,
summary="Validate the current session",
responses={
200: {"description": "Session valid", "model": SessionValidResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def validate_session(
_: AuthDep,
) -> SessionValidResponse:
"""Validate the current session.
This endpoint requires a valid session and returns 200 if the session is
valid and still active. If the session is invalid, expired, or missing,
FastAPI's ``require_auth`` dependency returns 401 automatically.
The frontend calls this on mount to bootstrap its authentication state
from the backend rather than relying solely on cached ``sessionStorage``.
Args:
_: The injected session object (unused, but its presence triggers validation).
Returns:
:class:`~app.models.auth.SessionValidResponse` confirming the session state.
"""
return SessionValidResponse(valid=True)
@router.post(
"/logout",
response_model=LogoutResponse,
summary="Revoke the current session",
responses={
200: {"description": "Logout successful", "model": LogoutResponse},
401: {"description": "Session missing or invalid (silently successful)"},
},
)
async def logout(
request: Request,
response: Response,
db: DbDep,
session_ctx: SessionServiceContextDep,
settings: SettingsDep,
session_cache: SessionCacheDep,
) -> LogoutResponse:
"""Invalidate the active session.
@@ -93,16 +162,26 @@ async def logout(
Args:
request: FastAPI request (used to extract the token).
response: FastAPI response (used to clear the cookie).
db: Injected aiosqlite connection.
session_ctx: Session service context containing db and repository.
settings: Application settings (used to unwrap signed tokens).
session_cache: Session cache for invalidation.
Returns:
:class:`~app.models.auth.LogoutResponse`.
"""
token = _extract_token(request)
if token:
await auth_service.logout(db, token)
invalidate_session_cache(token)
response.delete_cookie(key=_COOKIE_NAME)
raw_token = await auth_service.logout(
session_ctx.db,
token,
settings.session_secret,
settings.session_secret_previous,
session_repo=session_ctx.session_repo,
)
if raw_token:
session_cache.invalidate(raw_token)
session_cache.invalidate(token)
response.delete_cookie(key=SESSION_COOKIE_NAME)
return LogoutResponse()
@@ -120,7 +199,7 @@ def _extract_token(request: Request) -> str | None:
Returns:
The token string, or ``None`` if absent.
"""
token: str | None = request.cookies.get(_COOKIE_NAME)
token: str | None = request.cookies.get(SESSION_COOKIE_NAME)
if token:
return token
auth_header: str = request.headers.get("Authorization", "")

View File

@@ -10,35 +10,90 @@ Manual ban and unban operations and the active-bans overview:
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, Request, status
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, HTTPException, Request, status
from app.dependencies import AuthDep
from app.dependencies import (
AuthDep,
BanServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
GlobalRateLimiterDep,
HttpSessionDep,
)
from app.mappers import map_domain_active_ban_list_to_response
from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest
from app.models.jail import JailCommandResponse
from app.services import geo_service, jail_service
from app.exceptions import JailNotFoundError, JailOperationError
from app.utils.fail2ban_client import Fail2BanConnectionError
from app.services import ban_service, jail_service
from app.utils.constants import (
RATE_LIMIT_BANS_BAN_REQUESTS,
RATE_LIMIT_BANS_UNBAN_REQUESTS,
)
router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"])
router: APIRouter = APIRouter(prefix="/api/v1/bans", tags=["Bans"])
# Rate limit bucket constants
_BANS_BAN_BUCKET = "bans:ban"
_BANS_UNBAN_BUCKET = "bans:unban"
# 60 seconds per minute
_MINUTE = 60
def _bad_gateway(exc: Exception) -> HTTPException:
"""Return a 502 response when fail2ban is unreachable.
def _check_ban_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for ban operations."""
from app.utils.client_ip import get_client_ip
Args:
exc: The underlying connection error.
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_BANS_BAN_BUCKET, client_ip, RATE_LIMIT_BANS_BAN_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
Returns:
:class:`fastapi.HTTPException` with status 502.
"""
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
log = get_logger(__name__)
log.warning(
"bans_ban_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for ban operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_unban_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for unban operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_BANS_UNBAN_BUCKET, client_ip, RATE_LIMIT_BANS_UNBAN_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"bans_unban_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for unban operations. Please try again later.",
retry_after_seconds=retry_after,
)
@@ -46,10 +101,19 @@ def _bad_gateway(exc: Exception) -> HTTPException:
"/active",
response_model=ActiveBanListResponse,
summary="List all currently banned IPs across all jails",
responses={
200: {"description": "Active ban list returned", "model": ActiveBanListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_active_bans(
request: Request,
_auth: AuthDep,
ban_ctx: BanServiceContextDep,
socket_path: Fail2BanSocketDep,
http_session: HttpSessionDep,
geo_cache: GeoCacheDep,
) -> ActiveBanListResponse:
"""Return every IP that is currently banned across all fail2ban jails.
@@ -59,6 +123,10 @@ async def get_active_bans(
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
ban_ctx: Ban service context containing db and repository.
socket_path: Path to fail2ban Unix domain socket.
http_session: Shared HTTP session for geolocation.
geo_cache: Geolocation cache instance.
Returns:
:class:`~app.models.ban.ActiveBanListResponse` with all active bans.
@@ -66,19 +134,13 @@ async def get_active_bans(
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
app_db = request.app.state.db
try:
return await jail_service.get_active_bans(
domain_result = await ban_service.get_active_bans(
socket_path,
geo_batch_lookup=geo_service.lookup_batch,
geo_cache=geo_cache,
http_session=http_session,
app_db=app_db,
app_db=ban_ctx.db,
)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
return map_domain_active_ban_list_to_response(domain_result)
@router.post(
@@ -86,11 +148,22 @@ async def get_active_bans(
status_code=status.HTTP_201_CREATED,
response_model=JailCommandResponse,
summary="Ban an IP address in a specific jail",
dependencies=[Depends(_check_ban_rate_limit)],
responses={
201: {"description": "IP banned successfully", "model": 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"},
},
)
async def ban_ip(
request: Request,
_auth: AuthDep,
body: BanRequest,
socket_path: Fail2BanSocketDep,
) -> JailCommandResponse:
"""Ban an IP address in the specified fail2ban jail.
@@ -111,41 +184,33 @@ async def ban_ip(
HTTPException: 409 when fail2ban reports the ban failed.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await jail_service.ban_ip(socket_path, body.jail, body.ip)
await ban_service.ban_ip(socket_path, body.jail, body.ip)
return JailCommandResponse(
message=f"IP {body.ip!r} banned in jail {body.jail!r}.",
jail=body.jail,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except JailNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {body.jail!r}",
) from None
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.delete(
"",
response_model=JailCommandResponse,
summary="Unban an IP address from one or all jails",
dependencies=[Depends(_check_unban_rate_limit)],
responses={
200: {"description": "IP unbanned successfully", "model": 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"},
},
)
async def unban_ip(
request: Request,
_auth: AuthDep,
body: UnbanRequest,
socket_path: Fail2BanSocketDep,
) -> JailCommandResponse:
"""Unban an IP address from a specific jail or all jails.
@@ -168,45 +233,31 @@ async def unban_ip(
HTTPException: 409 when fail2ban reports the unban failed.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
# Determine target jail (None means all jails).
target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail
try:
await jail_service.unban_ip(socket_path, body.ip, jail=target_jail)
await ban_service.unban_ip(socket_path, body.ip, jail=target_jail)
scope = f"jail {target_jail!r}" if target_jail else "all jails"
return JailCommandResponse(
message=f"IP {body.ip!r} unbanned from {scope}.",
jail=target_jail or "*",
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except JailNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {target_jail!r}",
) from None
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.delete(
"/all",
response_model=UnbanAllResponse,
summary="Unban every currently banned IP across all jails",
responses={
200: {"description": "All bans cleared", "model": UnbanAllResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def unban_all(
request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
) -> UnbanAllResponse:
"""Remove all active bans from every fail2ban jail in a single operation.
@@ -224,12 +275,8 @@ async def unban_all(
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
count: int = await jail_service.unban_all_ips(socket_path)
return UnbanAllResponse(
message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.",
count=count,
)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -22,15 +22,26 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly.
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
from app.utils.logging_compat import get_logger
from fastapi import APIRouter, Depends, Query, Request, status
import aiosqlite
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from app.dependencies import AuthDep, get_db
from app.dependencies import (
AuthDep,
BlocklistServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
GlobalRateLimiterDep,
HttpSessionDep,
SchedulerDep,
SettingsDep,
)
from app.exceptions import (
BadRequestError,
BlocklistSourceAlreadyExistsError,
BlocklistSourceNotFoundError,
RateLimitError,
)
from app.mappers import blocklist_mappers
from app.models.blocklist import (
BlocklistListResponse,
BlocklistSource,
@@ -42,12 +53,43 @@ from app.models.blocklist import (
ScheduleConfig,
ScheduleInfo,
)
from app.services import blocklist_service, geo_service
from app.tasks import blocklist_import as blocklist_import_task
from app.services import ban_service, blocklist_service
from app.tasks.blocklist_import import run_import_with_resources
from app.utils.constants import DEFAULT_PAGE_SIZE, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS
router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"])
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
#: Rate limit bucket constants
_BLOCKLIST_IMPORT_BUCKET = "blocklist:import"
# 3600 seconds per hour
_HOUR = 3600
log = get_logger(__name__)
def _check_blocklist_import_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for blocklist import operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR
)
if not is_allowed:
log.warning(
"blocklist_import_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for blocklist import. Please try again later.",
retry_after_seconds=retry_after,
)
# ---------------------------------------------------------------------------
@@ -59,21 +101,25 @@ DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
"",
response_model=BlocklistListResponse,
summary="List all blocklist sources",
responses={
200: {"description": "Blocklist sources returned", "model": BlocklistListResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def list_blocklists(
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistListResponse:
"""Return all configured blocklist source definitions.
Args:
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
"""
sources = await blocklist_service.list_sources(db)
sources = await blocklist_service.list_sources(blocklist_ctx.db)
return BlocklistListResponse(sources=sources)
@@ -82,25 +128,39 @@ async def list_blocklists(
response_model=BlocklistSource,
status_code=status.HTTP_201_CREATED,
summary="Add a new blocklist source",
responses={
201: {"description": "Blocklist source created", "model": BlocklistSource},
400: {"description": "URL validation failed"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "A blocklist source with this URL already exists"},
},
)
async def create_blocklist(
payload: BlocklistSourceCreate,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Create a new blocklist source definition.
Args:
payload: New source data (name, url, enabled).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Returns:
The newly created :class:`~app.models.blocklist.BlocklistSource`.
Raises:
HTTPException: 400 if URL validation fails.
"""
try:
return await blocklist_service.create_source(
db, payload.name, payload.url, enabled=payload.enabled
blocklist_ctx.db, payload.name, str(payload.url), enabled=payload.enabled
)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
except BlocklistSourceAlreadyExistsError as exc:
raise exc
# ---------------------------------------------------------------------------
@@ -112,33 +172,41 @@ async def create_blocklist(
"/import",
response_model=ImportRunResult,
summary="Trigger a manual blocklist import",
dependencies=[Depends(_check_blocklist_import_rate_limit)],
responses={
200: {"description": "Import completed", "model": ImportRunResult},
401: {"description": "Session missing, expired, or invalid"},
429: {"description": "Rate limit exceeded for blocklist import"},
},
)
async def run_import_now(
request: Request,
db: DbDep,
http_session: HttpSessionDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
geo_cache: GeoCacheDep,
) -> ImportRunResult:
"""Download and apply all enabled blocklist sources immediately.
Args:
request: Incoming request (used to access shared HTTP session).
db: Application database connection (injected).
http_session: Shared HTTP session (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
socket_path: Path to fail2ban Unix domain socket.
geo_cache: Geolocation cache instance.
Returns:
:class:`~app.models.blocklist.ImportRunResult` with per-source
results and aggregated counters.
"""
http_session: aiohttp.ClientSession = request.app.state.http_session
socket_path: str = request.app.state.settings.fail2ban_socket
from app.services import jail_service
return await blocklist_service.import_all(
db,
blocklist_ctx.db,
http_session,
socket_path,
geo_is_cached=geo_service.is_cached,
geo_batch_lookup=geo_service.lookup_batch,
geo_is_cached=geo_cache.is_cached,
geo_cache=geo_cache,
ban_ip=ban_service.ban_ip,
)
@@ -146,84 +214,94 @@ async def run_import_now(
"/schedule",
response_model=ScheduleInfo,
summary="Get the current import schedule",
responses={
200: {"description": "Schedule info returned", "model": ScheduleInfo},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_schedule(
request: Request,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
scheduler: SchedulerDep,
) -> ScheduleInfo:
"""Return the current schedule configuration and runtime metadata.
The ``next_run_at`` field is read from APScheduler if the job is active.
Args:
request: Incoming request (used to query the scheduler).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
scheduler: APScheduler instance.
Returns:
:class:`~app.models.blocklist.ScheduleInfo` with config and run
times.
"""
scheduler = request.app.state.scheduler
job = scheduler.get_job(blocklist_import_task.JOB_ID)
next_run_at: str | None = None
if job is not None and job.next_run_time is not None:
next_run_at = job.next_run_time.isoformat()
return await blocklist_service.get_schedule_info(db, next_run_at)
return await blocklist_service.get_schedule_info_with_runtime(blocklist_ctx.db, scheduler)
@router.put(
"/schedule",
response_model=ScheduleInfo,
summary="Update the import schedule",
responses={
200: {"description": "Schedule updated", "model": ScheduleInfo},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def update_schedule(
payload: ScheduleConfig,
request: Request,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
scheduler: SchedulerDep,
http_session: HttpSessionDep,
settings: SettingsDep,
) -> ScheduleInfo:
"""Persist a new schedule configuration and reschedule the import job.
Args:
payload: New :class:`~app.models.blocklist.ScheduleConfig`.
request: Incoming request (used to access the scheduler).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
scheduler: Shared APScheduler instance (injected).
http_session: Shared HTTP session used by the scheduler job.
settings: Current application settings used by the scheduler job.
Returns:
Updated :class:`~app.models.blocklist.ScheduleInfo`.
"""
await blocklist_service.set_schedule(db, payload)
# Reschedule the background job immediately.
blocklist_import_task.reschedule(request.app)
job = request.app.state.scheduler.get_job(blocklist_import_task.JOB_ID)
next_run_at: str | None = None
if job is not None and job.next_run_time is not None:
next_run_at = job.next_run_time.isoformat()
return await blocklist_service.get_schedule_info(db, next_run_at)
return await blocklist_service.update_schedule(
blocklist_ctx.db,
scheduler,
http_session,
settings,
payload,
run_import_with_resources,
)
@router.get(
"/log",
response_model=ImportLogListResponse,
summary="Get the paginated import log",
responses={
200: {"description": "Import log returned", "model": ImportLogListResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_import_log(
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
source_id: int | None = Query(default=None, description="Filter by source id"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(
default=DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page (max 500)."
),
) -> ImportLogListResponse:
"""Return a paginated log of all import runs.
Args:
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
source_id: Optional filter — only show logs for this source.
page: 1-based page number.
@@ -233,7 +311,7 @@ async def get_import_log(
:class:`~app.models.blocklist.ImportLogListResponse`.
"""
return await blocklist_service.list_import_logs(
db, source_id=source_id, page=page, page_size=page_size
blocklist_ctx.db, source_id=source_id, page=page, page_size=page_size
)
@@ -246,25 +324,30 @@ async def get_import_log(
"/{source_id}",
response_model=BlocklistSource,
summary="Get a single blocklist source",
responses={
200: {"description": "Blocklist source returned", "model": BlocklistSource},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def get_blocklist(
source_id: int,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Return a single blocklist source by id.
Args:
source_id: Primary key of the source.
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
"""
source = await blocklist_service.get_source(db, source_id)
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
return source
@@ -272,11 +355,17 @@ async def get_blocklist(
"/{source_id}",
response_model=BlocklistSource,
summary="Update a blocklist source",
responses={
200: {"description": "Blocklist source updated", "model": BlocklistSource},
400: {"description": "URL validation failed"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def update_blocklist(
source_id: int,
payload: BlocklistSourceUpdate,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Update one or more fields on a blocklist source.
@@ -284,21 +373,25 @@ async def update_blocklist(
Args:
source_id: Primary key of the source to update.
payload: Fields to update (all optional).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 400 if URL validation fails.
HTTPException: 404 if the source does not exist.
"""
try:
updated = await blocklist_service.update_source(
db,
blocklist_ctx.db,
source_id,
name=payload.name,
url=payload.url,
url=str(payload.url) if payload.url is not None else None,
enabled=payload.enabled,
)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
if updated is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
return updated
@@ -306,36 +399,48 @@ async def update_blocklist(
"/{source_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a blocklist source",
responses={
204: {"description": "Blocklist source deleted successfully"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def delete_blocklist(
source_id: int,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> None:
"""Delete a blocklist source by id.
Args:
source_id: Primary key of the source to remove.
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
"""
deleted = await blocklist_service.delete_source(db, source_id)
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
@router.get(
"/{source_id}/preview",
response_model=PreviewResponse,
summary="Preview the contents of a blocklist source",
responses={
200: {"description": "Blocklist preview returned", "model": PreviewResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
502: {"description": "URL could not be reached"},
},
)
async def preview_blocklist(
source_id: int,
request: Request,
db: DbDep,
http_session: HttpSessionDep,
blocklist_ctx: BlocklistServiceContextDep,
settings: SettingsDep,
_auth: AuthDep,
) -> PreviewResponse:
"""Download and preview a sample of a blocklist source.
@@ -345,23 +450,22 @@ async def preview_blocklist(
Args:
source_id: Primary key of the source to preview.
request: Incoming request (used to access the HTTP session).
db: Application database connection (injected).
http_session: Shared HTTP session for downloading.
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
HTTPException: 502 if the URL cannot be reached.
"""
source = await blocklist_service.get_source(db, source_id)
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
http_session: aiohttp.ClientSession = request.app.state.http_session
try:
return await blocklist_service.preview_source(source.url, http_session)
domain_result = await blocklist_service.preview_source(
source.url, http_session, sample_lines=settings.blocklist_preview_max_lines
)
return blocklist_mappers.map_domain_preview_result_to_response(domain_result)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Could not fetch blocklist: {exc}",
) from exc
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,539 @@
from __future__ import annotations
import shlex
from pathlib import Path
from typing import Annotated
from app.utils.logging_compat import get_logger
from fastapi import APIRouter, Depends, Query, Request, status
from app.config import get_settings
from app.dependencies import (
AuthDep,
Fail2BanSocketDep,
Fail2BanStartCommandDep,
GlobalRateLimiterDep,
SettingsServiceContextDep,
)
from app.exceptions import OperationError
from app.mappers import config_mappers
from app.models.config import (
Fail2BanLogResponse,
GlobalConfigResponse,
GlobalConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
RegexTestRequest,
RegexTestResponse,
SecurityHeadersResponse,
ServiceStatusResponse,
)
from app.services import (
config_service,
jail_service,
log_service,
)
from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, RATE_LIMIT_CONFIG_UPDATE_REQUESTS
log = get_logger(__name__)
router: APIRouter = APIRouter(tags=["Config Misc"])
# Rate limit bucket constants
_CONFIG_UPDATE_BUCKET = "config:update"
# 60 seconds per minute
_MINUTE = 60
def _check_config_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for config update operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.utils.logging_compat import get_logger
from app.exceptions import RateLimitError
log = get_logger(__name__)
log.warning(
"config_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for config updates. Please try again later.",
retry_after_seconds=retry_after,
)
def _validate_log_target(value: str) -> None:
"""Validate that log_target is either a special value or a valid file path.
Args:
value: The log target to validate.
Raises:
ValueError: If the target is not a special value and not in allowed directories.
"""
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
return
settings = get_settings()
try:
resolved_path = Path(value).resolve()
except (OSError, RuntimeError) as e:
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
for allowed_dir in settings.allowed_log_dirs:
allowed_path = Path(allowed_dir).resolve()
try:
resolved_path.relative_to(allowed_path)
return
except ValueError:
continue
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
raise ValueError(
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
)
@router.get(
"/global",
response_model=GlobalConfigResponse,
summary="Return global fail2ban settings",
responses={
200: {"description": "Global config returned", "model": GlobalConfigResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_global_config(
_request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
) -> GlobalConfigResponse:
"""Return global fail2ban settings.
Includes log level, log target, and database configuration.
Args:
request: Incoming request.
_auth: Validated session.
Returns:
:class:`~app.models.config.GlobalConfigResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
domain_result = await config_service.get_global_config(socket_path)
return config_mappers.map_domain_global_config_to_response(domain_result)
@router.put(
"/global",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update global fail2ban settings",
dependencies=[Depends(_check_config_update_rate_limit)],
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"},
},
)
async def update_global_config(
_request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
body: GlobalConfigUpdate,
) -> None:
"""Update global fail2ban settings.
Args:
request: Incoming request.
_auth: Validated session.
body: Partial update — only non-None fields are written.
Raises:
HTTPException: 400 when a set command is rejected or log_target is invalid.
HTTPException: 502 when fail2ban is unreachable.
"""
if body.log_target is not None:
_validate_log_target(body.log_target)
await config_service.update_global_config(socket_path, body)
# ---------------------------------------------------------------------------
# Reload endpoint
# ---------------------------------------------------------------------------
@router.post(
"/reload",
status_code=status.HTTP_204_NO_CONTENT,
summary="Reload fail2ban to apply configuration changes",
responses={
204: {"description": "Fail2ban reloaded successfully"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "Reload command failed in fail2ban"},
502: {"description": "fail2ban unreachable"},
},
)
async def reload_fail2ban(
_request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
) -> None:
"""Trigger a full fail2ban reload.
All jails are stopped and restarted with the current configuration.
Args:
request: Incoming request.
_auth: Validated session.
Raises:
HTTPException: 409 when fail2ban reports the reload failed.
HTTPException: 502 when fail2ban is unreachable.
"""
await jail_service.reload_all(socket_path)
# Restart endpoint
# ---------------------------------------------------------------------------
@router.post(
"/restart",
status_code=status.HTTP_204_NO_CONTENT,
summary="Restart the fail2ban service",
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"},
},
)
async def restart_fail2ban(
_request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
start_cmd: Fail2BanStartCommandDep,
) -> None:
"""Trigger a full fail2ban service restart.
Stops the fail2ban daemon via the Unix domain socket, then starts it
again using the configured ``fail2ban_start_command``. After starting,
probes the socket for up to 10 seconds to confirm the daemon came back
online.
Args:
request: Incoming request.
_auth: Validated session.
Raises:
HTTPException: 409 when fail2ban reports the stop command failed.
HTTPException: 502 when fail2ban is unreachable for the stop command.
HTTPException: 503 when fail2ban does not come back online within
10 seconds after being started. Check the fail2ban log for
initialisation errors. Use
``POST /api/config/jails/{name}/rollback``
if a specific jail is suspect.
"""
start_cmd_parts: list[str] = shlex.split(start_cmd)
restarted = await jail_service.restart_daemon(
socket_path,
start_cmd_parts,
)
if not restarted:
raise OperationError(
"fail2ban was stopped but did not come back "
"online within 10 seconds. "
"Check the fail2ban log for initialisation errors. "
"Use POST /api/config/jails/{name}/rollback if a "
"specific jail is suspect."
)
log.info("fail2ban_restarted")
# ---------------------------------------------------------------------------
# Regex tester (stateless)
# ---------------------------------------------------------------------------
@router.post(
"/regex-test",
response_model=RegexTestResponse,
summary="Test a fail regex pattern against a sample log line",
responses={
200: {"description": "Regex test result", "model": RegexTestResponse},
401: {"description": "Session missing, expired, or invalid"},
422: {"description": "Invalid regex pattern"},
},
)
async def regex_test(
_auth: AuthDep,
body: RegexTestRequest,
) -> RegexTestResponse:
"""Test whether a regex pattern matches a given log line.
This endpoint is entirely in-process — no fail2ban socket call is made.
Returns the match result and any captured groups.
Args:
_auth: Validated session.
body: Sample log line and regex pattern.
Returns:
:class:`~app.models.config.RegexTestResponse` with match result and
groups.
"""
return log_service.test_regex(body)
# ---------------------------------------------------------------------------
# Log path management
# ---------------------------------------------------------------------------
@router.post(
"/preview-log",
response_model=LogPreviewResponse,
summary="Preview log file lines against a regex pattern",
responses={
200: {"description": "Log preview result", "model": LogPreviewResponse},
401: {"description": "Session missing, expired, or invalid"},
422: {"description": "Invalid regex pattern"},
},
)
async def preview_log(
_auth: AuthDep,
body: LogPreviewRequest,
) -> LogPreviewResponse:
"""Read the last N lines of a log file and test a regex against each one.
Returns each line with a flag indicating whether the regex matched, and
the captured groups for matching lines. The log file is read from the
server's local filesystem.
Args:
_auth: Validated session.
body: Log file path, regex pattern, and number of lines to read.
Returns:
:class:`~app.models.config.LogPreviewResponse` with per-line results.
"""
return await log_service.preview_log(body)
# ---------------------------------------------------------------------------
# Map color thresholds
# ---------------------------------------------------------------------------
@router.get(
"/map-color-thresholds",
response_model=MapColorThresholdsResponse,
summary="Get map color threshold configuration",
responses={
200: {"description": "Color thresholds returned", "model": MapColorThresholdsResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_map_color_thresholds(
_request: Request,
_auth: AuthDep,
settings_ctx: SettingsServiceContextDep,
) -> MapColorThresholdsResponse:
"""Return the configured map color thresholds.
Args:
_request: FastAPI request object.
_auth: Validated session.
settings_ctx: Settings service context containing db and repository.
Returns:
:class:`~app.models.config.MapColorThresholdsResponse` with
current thresholds.
"""
return await config_service.get_map_color_thresholds(settings_ctx.db)
@router.put(
"/map-color-thresholds",
response_model=MapColorThresholdsResponse,
summary="Update map color threshold configuration",
dependencies=[Depends(_check_config_update_rate_limit)],
responses={
200: {"description": "Color thresholds updated", "model": 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"},
},
)
async def update_map_color_thresholds(
_request: Request,
_auth: AuthDep,
settings_ctx: SettingsServiceContextDep,
body: MapColorThresholdsUpdate,
) -> MapColorThresholdsResponse:
"""Update the map color threshold configuration.
Args:
_request: FastAPI request object.
_auth: Validated session.
settings_ctx: Settings service context containing db and repository.
body: New threshold values.
Returns:
:class:`~app.models.config.MapColorThresholdsResponse` with
updated thresholds.
Raises:
HTTPException: 400 if validation fails (thresholds not
properly ordered).
"""
await config_service.update_map_color_thresholds(settings_ctx.db, body)
return await config_service.get_map_color_thresholds(settings_ctx.db)
@router.get(
"/fail2ban-log",
response_model=Fail2BanLogResponse,
summary="Read the tail of the fail2ban daemon log file",
responses={
200: {"description": "Log file lines returned", "model": Fail2BanLogResponse},
400: {"description": "Log target not a file or path outside allowed directory"},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_fail2ban_log(
_request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
lines: Annotated[
int,
Query(
ge=1,
le=2000,
description="Number of lines to return from the tail.",
),
] = 200,
filter_: Annotated[ # noqa: A002
str | None,
Query(
alias="filter",
description=(
"Plain-text substring filter; "
"only matching lines are returned."
),
),
] = None,
) -> Fail2BanLogResponse:
"""Return the tail of the fail2ban daemon log file.
Queries the fail2ban socket for the current log target and log level,
reads the last *lines* entries from the file, and optionally filters
them by *filter*. Only file-based log targets are supported.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
lines: Number of tail lines to return (12000, default 200).
filter: Optional plain-text substring — only matching lines returned.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
HTTPException: 400 when the log target is not a file or path is outside
the allowed directory.
HTTPException: 502 when fail2ban is unreachable.
"""
return await log_service.read_fail2ban_log(socket_path, lines, filter_)
@router.get(
"/service-status",
response_model=ServiceStatusResponse,
summary="Return fail2ban service health status with log configuration",
responses={
200: {"description": "Service status returned", "model": ServiceStatusResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_service_status(
_request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
) -> ServiceStatusResponse:
"""Return fail2ban service health and current log configuration.
Probes the fail2ban daemon to determine online/offline state, then
augments the result with the current log level and log target values.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable (the service itself
handles this gracefully and returns ``online=False``).
"""
from app.services import health_service
domain_result = await health_service.get_service_status(
socket_path,
probe_fn=health_service.probe,
)
return config_mappers.map_domain_service_status_to_response(domain_result)
@router.get(
"/security-headers",
response_model=SecurityHeadersResponse,
summary="Return security-relevant header configuration",
responses={
200: {"description": "Security header names and values returned", "model": SecurityHeadersResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def get_security_headers(
_request: Request,
_auth: AuthDep,
) -> SecurityHeadersResponse:
"""Return the header name and value used for CSRF protection.
This endpoint allows the frontend to discover the required CSRF header
name and value at runtime rather than hard-coding them. The response
is derived from the same constants used by the backend CSRF middleware,
ensuring a single source of truth.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.SecurityHeadersResponse` with
``csrf_header_name`` and ``csrf_header_value``.
"""
return SecurityHeadersResponse(
csrf_header_name=CSRF_HEADER_NAME,
csrf_header_value=CSRF_HEADER_VALUE,
)

View File

@@ -12,33 +12,44 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from typing import Literal
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Query
from app import __version__
from app.dependencies import AuthDep
from app.dependencies import (
AuthDep,
BanServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
HttpSessionDep,
ServerStatusDep,
SettingsDep,
)
from app.mappers import (
map_domain_ban_trend_to_response,
map_domain_bans_by_country_to_response,
map_domain_bans_by_jail_to_response,
map_domain_dashboard_ban_list_to_response,
)
from app.models._common import TimeRange
from app.models.ban import (
BanOrigin,
BansByCountryResponse,
BansByJailResponse,
BanTrendResponse,
DashboardBanListResponse,
TimeRange,
)
from app.models.server import ServerStatus, ServerStatusResponse
from app.services import ban_service, geo_service
from app.services import ban_service
from app.utils.constants import DEFAULT_PAGE_SIZE
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
router: APIRouter = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
# ---------------------------------------------------------------------------
# Default pagination constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_DEFAULT_RANGE: TimeRange = "24h"
@@ -46,9 +57,14 @@ _DEFAULT_RANGE: TimeRange = "24h"
"/status",
response_model=ServerStatusResponse,
summary="Return the cached fail2ban server status",
responses={
200: {"description": "Server status returned", "model": ServerStatusResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_server_status(
request: Request,
server_status: ServerStatusDep,
_auth: AuthDep,
) -> ServerStatusResponse:
"""Return the most recent fail2ban health snapshot.
@@ -58,18 +74,14 @@ async def get_server_status(
returned so the response is always well-formed.
Args:
request: The incoming request (used to access ``app.state``).
server_status: Cached fail2ban server health snapshot (injected).
_auth: Validated session — enforces authentication on this endpoint.
Returns:
:class:`~app.models.server.ServerStatusResponse` containing the
current health snapshot.
"""
cached: ServerStatus = getattr(
request.app.state,
"server_status",
ServerStatus(online=False),
)
cached: ServerStatus = server_status
cached.version = __version__
return ServerStatusResponse(status=cached)
@@ -78,17 +90,26 @@ async def get_server_status(
"/bans",
response_model=DashboardBanListResponse,
summary="Return a paginated list of recent bans",
responses={
200: {"description": "Ban list returned", "model": DashboardBanListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_dashboard_bans(
request: Request,
_auth: AuthDep,
ban_ctx: BanServiceContextDep,
socket_path: Fail2BanSocketDep,
http_session: HttpSessionDep,
geo_cache: GeoCacheDep,
settings: SettingsDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
page: int = Query(default=1, ge=1, description="1-based page number."),
page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."),
page_size: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, description="Items per page."),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -103,8 +124,11 @@ async def get_dashboard_bans(
GET request.
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session dependency.
ban_ctx: Ban service context containing db and repository.
socket_path: Path to fail2ban Unix domain socket.
http_session: Shared HTTP session for geolocation.
geo_cache: Geolocation cache instance.
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
``"365d"``.
page: 1-based page number.
@@ -115,30 +139,37 @@ async def get_dashboard_bans(
:class:`~app.models.ban.DashboardBanListResponse` with paginated
ban items and the total count for the selected window.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
return await ban_service.list_bans(
domain_result = await ban_service.list_bans(
socket_path,
range,
source=source,
page=page,
page_size=page_size,
max_page_size=settings.max_page_size,
http_session=http_session,
app_db=request.app.state.db,
geo_batch_lookup=geo_service.lookup_batch,
app_db=ban_ctx.db,
geo_cache=geo_cache,
origin=origin,
)
return map_domain_dashboard_ban_list_to_response(domain_result)
@router.get(
"/bans/by-country",
response_model=BansByCountryResponse,
summary="Return ban counts aggregated by country",
responses={
200: {"description": "Ban counts by country returned", "model": BansByCountryResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_bans_by_country(
request: Request,
_auth: AuthDep,
ban_ctx: BanServiceContextDep,
socket_path: Fail2BanSocketDep,
http_session: HttpSessionDep,
geo_cache: GeoCacheDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
@@ -162,8 +193,11 @@ async def get_bans_by_country(
during this GET request.
Args:
request: The incoming request.
_auth: Validated session dependency.
ban_ctx: Ban service context containing db and repository.
socket_path: Path to fail2ban Unix domain socket.
http_session: Shared HTTP session for geolocation.
geo_cache: Geolocation cache instance.
range: Time-range preset.
origin: Optional filter by ban origin.
@@ -171,30 +205,34 @@ async def get_bans_by_country(
:class:`~app.models.ban.BansByCountryResponse` with per-country
aggregation and the companion ban list.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
http_session: aiohttp.ClientSession = request.app.state.http_session
return await ban_service.bans_by_country(
domain_result = await ban_service.bans_by_country(
socket_path,
range,
source=source,
http_session=http_session,
geo_cache_lookup=geo_service.lookup_cached_only,
geo_batch_lookup=geo_service.lookup_batch,
app_db=request.app.state.db,
geo_cache_lookup=geo_cache.lookup_cached_only,
geo_cache=geo_cache,
app_db=ban_ctx.db,
origin=origin,
country_code=country_code,
)
return map_domain_bans_by_country_to_response(domain_result)
@router.get(
"/bans/trend",
response_model=BanTrendResponse,
summary="Return ban counts aggregated into time buckets",
responses={
200: {"description": "Ban trend data returned", "model": BanTrendResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_ban_trend(
request: Request,
_auth: AuthDep,
ban_ctx: BanServiceContextDep,
socket_path: Fail2BanSocketDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
@@ -220,8 +258,9 @@ async def get_ban_trend(
* ``365d`` → 7-day buckets (~53 total)
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session dependency.
ban_ctx: Ban service context containing db and repository.
socket_path: Path to fail2ban Unix domain socket.
range: Time-range preset.
origin: Optional filter by ban origin.
@@ -229,25 +268,30 @@ async def get_ban_trend(
:class:`~app.models.ban.BanTrendResponse` with the ordered bucket
list and the bucket-size label.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.ban_trend(
domain_result = await ban_service.ban_trend(
socket_path,
range,
source=source,
app_db=request.app.state.db,
app_db=ban_ctx.db,
origin=origin,
)
return map_domain_ban_trend_to_response(domain_result)
@router.get(
"/bans/by-jail",
response_model=BansByJailResponse,
summary="Return ban counts aggregated by jail",
responses={
200: {"description": "Ban counts by jail returned", "model": BansByJailResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_bans_by_jail(
request: Request,
_auth: AuthDep,
ban_ctx: BanServiceContextDep,
socket_path: Fail2BanSocketDep,
range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
@@ -265,8 +309,9 @@ async def get_bans_by_jail(
distribution bar chart.
Args:
request: The incoming request (used to access ``app.state``).
_auth: Validated session dependency.
ban_ctx: Ban service context containing db and repository.
socket_path: Path to fail2ban Unix domain socket.
range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or
``"365d"``.
origin: Optional filter by ban origin.
@@ -275,12 +320,11 @@ async def get_bans_by_jail(
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
sorted descending and the total for the selected window.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.bans_by_jail(
domain_result = await ban_service.bans_by_jail(
socket_path,
range,
source=source,
app_db=request.app.state.db,
app_db=ban_ctx.db,
origin=origin,
)
return map_domain_bans_by_jail_to_response(domain_result)

View File

@@ -31,9 +31,9 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Request, status
from fastapi import APIRouter, Path, status
from app.dependencies import AuthDep
from app.dependencies import AuthDep, Fail2BanConfigDirDep
from app.models.config import (
ActionConfig,
ActionConfigUpdate,
@@ -52,15 +52,8 @@ from app.models.file_config import (
JailConfigFilesResponse,
)
from app.services import raw_config_io_service
from app.services.raw_config_io_service import (
ConfigDirError,
ConfigFileExistsError,
ConfigFileNameError,
ConfigFileNotFoundError,
ConfigFileWriteError,
)
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
router: APIRouter = APIRouter(prefix="/api/v1/config", tags=["Config"])
# ---------------------------------------------------------------------------
# Path type aliases
@@ -73,39 +66,6 @@ _NamePath = Annotated[
str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).")
]
# ---------------------------------------------------------------------------
# Error helpers
# ---------------------------------------------------------------------------
def _not_found(filename: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Config file not found: {filename!r}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
def _conflict(filename: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Config file already exists: {filename!r}",
)
def _service_unavailable(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=message,
)
# ---------------------------------------------------------------------------
# Jail config file endpoints (Task 4a)
# ---------------------------------------------------------------------------
@@ -115,9 +75,14 @@ def _service_unavailable(message: str) -> HTTPException:
"/jail-files",
response_model=JailConfigFilesResponse,
summary="List all jail config files",
responses={
200: {"description": "Jail config files returned", "model": JailConfigFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_jail_config_files(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
) -> JailConfigFilesResponse:
"""Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``.
@@ -126,26 +91,27 @@ async def list_jail_config_files(
file (defaulting to ``true`` when the key is absent).
Args:
request: Incoming request (used for ``app.state.settings``).
config_dir: Config directory path injected from application settings.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.file_config.JailConfigFilesResponse`.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.list_jail_config_files(config_dir)
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.get(
"/jail-files/{filename}",
response_model=JailConfigFileContent,
summary="Return a single jail config file with its content",
responses={
200: {"description": "Jail config file returned", "model": JailConfigFileContent},
400: {"description": "Filename unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_jail_config_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
filename: _FilenamePath,
) -> JailConfigFileContent:
@@ -164,24 +130,21 @@ async def get_jail_config_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.get_jail_config_file(config_dir, filename)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/jail-files/{filename}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Overwrite a jail.d config file with new raw content",
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"},
},
)
async def write_jail_config_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
filename: _FilenamePath,
body: ConfFileUpdateRequest,
@@ -202,26 +165,21 @@ async def write_jail_config_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.write_jail_config_file(config_dir, filename, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/jail-files/{filename}/enabled",
status_code=status.HTTP_204_NO_CONTENT,
summary="Enable or disable a jail configuration file",
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"},
},
)
async def set_jail_config_file_enabled(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
filename: _FilenamePath,
body: JailConfigFileEnabledUpdate,
@@ -242,29 +200,24 @@ async def set_jail_config_file_enabled(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.set_jail_config_enabled(
config_dir, filename, body.enabled
)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.post(
"/jail-files",
response_model=ConfFileContent,
status_code=status.HTTP_201_CREATED,
summary="Create a new jail.d config file",
responses={
201: {"description": "File created", "model": 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"},
},
)
async def create_jail_config_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
body: ConfFileCreateRequest,
) -> ConfFileContent:
@@ -283,18 +236,7 @@ async def create_jail_config_file(
HTTPException: 409 if a file with that name already exists.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
filename = await raw_config_io_service.create_jail_config_file(config_dir, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileExistsError:
raise _conflict(body.name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return ConfFileContent(
name=body.name,
filename=filename,
@@ -311,9 +253,16 @@ async def create_jail_config_file(
"/filters/{name}/raw",
response_model=ConfFileContent,
summary="Return a filter definition file's raw content",
responses={
200: {"description": "Filter file returned", "model": ConfFileContent},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_filter_file_raw(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
) -> ConfFileContent:
@@ -336,24 +285,21 @@ async def get_filter_file_raw(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.get_filter_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/filters/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter definition file (raw content)",
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"},
},
)
async def write_filter_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
body: ConfFileUpdateRequest,
@@ -371,27 +317,22 @@ async def write_filter_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.write_filter_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.post(
"/filters/raw",
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new filter definition file (raw content)",
responses={
201: {"description": "Filter file created", "model": 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"},
},
)
async def create_filter_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
body: ConfFileCreateRequest,
) -> ConfFileContent:
@@ -410,18 +351,7 @@ async def create_filter_file(
HTTPException: 409 if a file with that name already exists.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
filename = await raw_config_io_service.create_filter_file(config_dir, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileExistsError:
raise _conflict(body.name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return ConfFileContent(
name=body.name,
filename=filename,
@@ -438,9 +368,14 @@ async def create_filter_file(
"/actions",
response_model=ConfFilesResponse,
summary="List all action definition files",
responses={
200: {"description": "Action files returned", "model": ConfFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_action_files(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
) -> ConfFilesResponse:
"""Return a list of every ``.conf`` and ``.local`` file in ``action.d/``.
@@ -452,20 +387,21 @@ async def list_action_files(
Returns:
:class:`~app.models.file_config.ConfFilesResponse`.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.list_action_files(config_dir)
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.get(
"/actions/{name}/raw",
response_model=ConfFileContent,
summary="Return an action definition file with its content",
responses={
200: {"description": "Action file returned", "model": ConfFileContent},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_action_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
) -> ConfFileContent:
@@ -484,24 +420,21 @@ async def get_action_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.get_action_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/actions/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action definition file",
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"},
},
)
async def write_action_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
body: ConfFileUpdateRequest,
@@ -519,27 +452,22 @@ async def write_action_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.write_action_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.post(
"/actions",
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new action definition file",
responses={
201: {"description": "Action file created", "model": 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"},
},
)
async def create_action_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
body: ConfFileCreateRequest,
) -> ConfFileContent:
@@ -558,18 +486,7 @@ async def create_action_file(
HTTPException: 409 if a file with that name already exists.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
filename = await raw_config_io_service.create_action_file(config_dir, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileExistsError:
raise _conflict(body.name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return ConfFileContent(
name=body.name,
filename=filename,
@@ -586,9 +503,16 @@ async def create_action_file(
"/filters/{name}/parsed",
response_model=FilterConfig,
summary="Return a filter file parsed into a structured model",
responses={
200: {"description": "Filter config returned", "model": FilterConfig},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_filter(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
) -> FilterConfig:
@@ -611,24 +535,21 @@ async def get_parsed_filter(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.get_parsed_filter_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/filters/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter file from a structured model",
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"},
},
)
async def update_parsed_filter(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
body: FilterConfigUpdate,
@@ -649,19 +570,7 @@ async def update_parsed_filter(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
# ---------------------------------------------------------------------------
# Parsed action endpoints (Task 3.1)
# ---------------------------------------------------------------------------
@@ -671,9 +580,16 @@ async def update_parsed_filter(
"/actions/{name}/parsed",
response_model=ActionConfig,
summary="Return an action file parsed into a structured model",
responses={
200: {"description": "Action config returned", "model": ActionConfig},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_action(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
) -> ActionConfig:
@@ -696,24 +612,21 @@ async def get_parsed_action(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.get_parsed_action_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/actions/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action file from a structured model",
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"},
},
)
async def update_parsed_action(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
name: _NamePath,
body: ActionConfigUpdate,
@@ -734,19 +647,7 @@ async def update_parsed_action(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.update_parsed_action_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
# ---------------------------------------------------------------------------
# Parsed jail file endpoints (Task 6.1)
# ---------------------------------------------------------------------------
@@ -756,9 +657,16 @@ async def update_parsed_action(
"/jail-files/{filename}/parsed",
response_model=JailFileConfig,
summary="Return a jail.d file parsed into a structured model",
responses={
200: {"description": "Jail file config returned", "model": JailFileConfig},
400: {"description": "Filename unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_jail_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
filename: _NamePath,
) -> JailFileConfig:
@@ -781,24 +689,21 @@ async def get_parsed_jail_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.get_parsed_jail_file(config_dir, filename)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put(
"/jail-files/{filename}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a jail.d file from a structured model",
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"},
},
)
async def update_parsed_jail_file(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
filename: _NamePath,
body: JailFileConfigUpdate,
@@ -819,14 +724,4 @@ async def update_parsed_jail_file(
HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc

View File

@@ -0,0 +1,385 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep
from app.mappers import config_mappers
from app.models.config import (
FilterConfig,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
)
from app.services import filter_config_service
from app.utils.constants import (
RATE_LIMIT_FILTER_CREATE_REQUESTS,
RATE_LIMIT_FILTER_DELETE_REQUESTS,
RATE_LIMIT_FILTER_UPDATE_REQUESTS,
)
router: APIRouter = APIRouter(prefix="/filters", tags=["Filter Config"])
_MINUTE = 60
_FILTER_UPDATE_BUCKET = "filter:update"
_FILTER_CREATE_BUCKET = "filter:create"
_FILTER_DELETE_BUCKET = "filter:delete"
def _check_filter_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for filter update operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_FILTER_UPDATE_BUCKET, client_ip, RATE_LIMIT_FILTER_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"filter_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for filter update operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_filter_create_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for filter create operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_FILTER_CREATE_BUCKET, client_ip, RATE_LIMIT_FILTER_CREATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"filter_create_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for filter create operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_filter_delete_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for filter delete operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_FILTER_DELETE_BUCKET, client_ip, RATE_LIMIT_FILTER_DELETE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
from app.utils.logging_compat import get_logger
log = get_logger(__name__)
log.warning(
"filter_delete_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for filter delete operations. Please try again later.",
retry_after_seconds=retry_after,
)
_FilterNamePath = Annotated[
str,
Path(description='Filter base name, e.g. ``sshd`` or ``sshd.conf``.'),
]
@router.get(
"",
response_model=FilterListResponse,
summary="List all available filters with active/inactive status",
responses={
200: {"description": "Filter list returned", "model": FilterListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def list_filters(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
) -> FilterListResponse:
"""Return all filters discovered in ``filter.d/`` with active/inactive status.
Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any
corresponding ``.local`` overrides, and cross-references each filter's
name against the ``filter`` fields of currently running jails to determine
whether it is active.
Active filters (those used by at least one running jail) are sorted to the
top of the list; inactive filters follow. Both groups are sorted
alphabetically within themselves.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.FilterListResponse` with all discovered
filters.
"""
domain_result = await filter_config_service.list_filters(config_dir, socket_path)
# Sort: active first (by name), then inactive (by name).
domain_result.items.sort(key=lambda f: (not f.active, f.name.lower()))
return config_mappers.map_domain_filter_list_to_response(domain_result)
@router.get(
"/{name}",
response_model=FilterConfig,
summary="Return full parsed detail for a single filter",
responses={
200: {"description": "Filter config returned", "model": FilterConfig},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter not found in filter.d/"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_filter(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")],
) -> FilterConfig:
"""Return the full parsed configuration and active/inactive status for one filter.
Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding
``.local`` override, and annotates the result with ``active``,
``used_by_jails``, ``source_file``, and ``has_local_override``.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
name: Filter base name (with or without ``.conf`` extension).
Returns:
:class:`~app.models.config.FilterConfig`.
Raises:
HTTPException: 404 if the filter is not found in ``filter.d/``.
HTTPException: 502 if fail2ban is unreachable.
"""
return await filter_config_service.get_filter(config_dir, socket_path, name)
# ---------------------------------------------------------------------------
# Filter write endpoints (Task 2.2)
# ---------------------------------------------------------------------------
_FilterNamePath = Annotated[
str,
Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."),
]
@router.put(
"/{name}",
response_model=FilterConfig,
summary="Update a filter's .local override with new regex/pattern values",
dependencies=[Depends(_check_filter_update_rate_limit)],
responses={
200: {"description": "Filter updated", "model": 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"},
},
)
async def update_filter(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
name: _FilterNamePath,
body: FilterUpdateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
) -> FilterConfig:
"""Update a filter's ``[Definition]`` fields by writing a ``.local`` override.
All regex patterns are validated before writing. Validation includes:
- **Length limit**: Patterns must not exceed 1000 characters (prevents DoS)
- **Compilation timeout**: Pattern compilation must complete within 2 seconds
(prevents ReDoS attacks via catastrophic backtracking)
- **Syntax validation**: Patterns must be valid Python regex
The original ``.conf`` file is never modified. Fields left as ``null`` in the
request body are kept at their current values.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Filter base name (with or without ``.conf`` extension).
body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``,
``journalmatch``.
reload: When ``true``, trigger a fail2ban reload after writing.
Returns:
Updated :class:`~app.models.config.FilterConfig`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 400 if any regex pattern exceeds 1000 characters.
HTTPException: 400 if any regex pattern times out during compilation (ReDoS).
HTTPException: 422 if any regex pattern fails to compile.
HTTPException: 404 if the filter does not exist.
HTTPException: 500 if writing the ``.local`` file fails.
"""
return await filter_config_service.update_filter(config_dir, socket_path, name, body, do_reload=reload)
@router.post(
"",
response_model=FilterConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined filter",
dependencies=[Depends(_check_filter_create_rate_limit)],
responses={
201: {"description": "Filter created", "model": 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"},
},
)
async def create_filter(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
body: FilterCreateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
) -> FilterConfig:
"""Create a new user-defined filter at ``filter.d/{name}.local``.
The filter is created as a ``.local`` file so it can coexist safely with
shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for
the requested name already exists.
All regex patterns are validated before writing. Validation includes:
- **Length limit**: Patterns must not exceed 1000 characters (prevents DoS)
- **Compilation timeout**: Pattern compilation must complete within 2 seconds
(prevents ReDoS attacks via catastrophic backtracking)
- **Syntax validation**: Patterns must be valid Python regex
Args:
request: FastAPI request object.
_auth: Validated session.
body: Filter name and ``[Definition]`` fields.
reload: When ``true``, trigger a fail2ban reload after creating.
Returns:
:class:`~app.models.config.FilterConfig` for the new filter.
Raises:
HTTPException: 400 if the name contains invalid characters.
HTTPException: 400 if any regex pattern exceeds 1000 characters.
HTTPException: 400 if any regex pattern times out during compilation (ReDoS).
HTTPException: 409 if the filter already exists.
HTTPException: 422 if any regex pattern is invalid.
HTTPException: 500 if writing fails.
"""
return await filter_config_service.create_filter(config_dir, socket_path, body, do_reload=reload)
@router.delete(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created filter's .local file",
dependencies=[Depends(_check_filter_delete_rate_limit)],
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"},
},
)
async def delete_filter(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
name: _FilterNamePath,
) -> None:
"""Delete a user-created filter's ``.local`` override file.
Shipped ``.conf``-only filters cannot be deleted (returns 409). When
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
When only a ``.local`` exists (user-created filter), the file is deleted
entirely.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Filter base name.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the filter does not exist.
HTTPException: 409 if the filter is a shipped default (conf-only).
HTTPException: 500 if deletion fails.
"""
await filter_config_service.delete_filter(config_dir, name)
# ---------------------------------------------------------------------------
# Action discovery endpoints (Task 3.1)
# ---------------------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More