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
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:
33
.editorconfig
Normal file
33
.editorconfig
Normal 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
60
.env.example
Normal 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
174
.github/workflows/ci.yml
vendored
Normal 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
18
.gitignore
vendored
@@ -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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
cd frontend && npm run validate:types
|
||||
23
.pre-commit-config.yaml
Normal file
23
.pre-commit-config.yaml
Normal 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
157
CONTRIBUTING.md
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 1–3 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.
|
||||
@@ -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 =
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
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
730
Docs/API_STATUS_CODES.md
Normal 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
166
Docs/API_VERSIONING.md
Normal 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 |
|
||||
1009
Docs/Architekture.md
1009
Docs/Architekture.md
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
194
Docs/CONFIGURATION.md
Normal 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: 1–65535. |
|
||||
| `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 1–10000. |
|
||||
| `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
347
Docs/DATABASE_SCHEMA.md
Normal 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
124
Docs/DOMAIN_MODELS.md
Normal 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
1071
Docs/Deployment.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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
845
Docs/Observability.md
Normal 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
146
Docs/PERFORMANCE.md
Normal 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)
|
||||
```
|
||||
@@ -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
176
Docs/Security.md
Normal 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
115
Docs/Service-Development.md
Normal 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
487
Docs/TROUBLESHOOTING.md
Normal 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
145
Docs/TYPE_SAFETY.md
Normal 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
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
118
Docs/Testing-Requirements.md
Normal file
118
Docs/Testing-Requirements.md
Normal 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
|
||||
[](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.
|
||||
@@ -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
154
Docs/runner.csx
Normal 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.");
|
||||
@@ -1 +0,0 @@
|
||||
https://lists.blocklist.de/lists/all.txt
|
||||
71
Makefile
71
Makefile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 unique and never committed to source control."
|
||||
"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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,13 +651,12 @@ 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 "):
|
||||
token = auth_header[len("Bearer "):]
|
||||
token = auth_header[len("Bearer ") :]
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
1115
backend/app/main.py
1115
backend/app/main.py
File diff suppressed because it is too large
Load Diff
27
backend/app/mappers/__init__.py
Normal file
27
backend/app/mappers/__init__.py
Normal 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",
|
||||
]
|
||||
119
backend/app/mappers/ban_mappers.py
Normal file
119
backend/app/mappers/ban_mappers.py
Normal 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,
|
||||
)
|
||||
141
backend/app/mappers/blocklist_mappers.py
Normal file
141
backend/app/mappers/blocklist_mappers.py
Normal 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,
|
||||
)
|
||||
151
backend/app/mappers/config_mappers.py
Normal file
151
backend/app/mappers/config_mappers.py
Normal 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,
|
||||
)
|
||||
23
backend/app/mappers/health_mappers.py
Normal file
23
backend/app/mappers/health_mappers.py
Normal 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,
|
||||
)
|
||||
81
backend/app/mappers/history_mappers.py
Normal file
81
backend/app/mappers/history_mappers.py
Normal 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 [])
|
||||
],
|
||||
)
|
||||
133
backend/app/mappers/jail_mappers.py
Normal file
133
backend/app/mappers/jail_mappers.py
Normal 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),
|
||||
)
|
||||
37
backend/app/mappers/server_mappers.py
Normal file
37
backend/app/mappers/server_mappers.py
Normal 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,
|
||||
)
|
||||
3
backend/app/middleware/__init__.py
Normal file
3
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Application middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
96
backend/app/middleware/correlation.py
Normal file
96
backend/app/middleware/correlation.py
Normal 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
|
||||
99
backend/app/middleware/csrf.py
Normal file
99
backend/app/middleware/csrf.py
Normal 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)
|
||||
107
backend/app/middleware/deprecation.py
Normal file
107
backend/app/middleware/deprecation.py
Normal 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
|
||||
95
backend/app/middleware/metrics.py
Normal file
95
backend/app/middleware/metrics.py
Normal 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,
|
||||
)
|
||||
178
backend/app/middleware/rate_limit.py
Normal file
178
backend/app/middleware/rate_limit.py
Normal 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
|
||||
49
backend/app/models/_common.py
Normal file
49
backend/app/models/_common.py
Normal 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_])
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
110
backend/app/models/ban_domain.py
Normal file
110
backend/app/models/ban_domain.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
108
backend/app/models/blocklist_domain.py
Normal file
108
backend/app/models/blocklist_domain.py
Normal 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
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
130
backend/app/models/config_domain.py
Normal file
130
backend/app/models/config_domain.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
23
backend/app/models/health_domain.py
Normal file
23
backend/app/models/health_domain.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
64
backend/app/models/history_domain.py
Normal file
64
backend/app/models/history_domain.py
Normal 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
|
||||
@@ -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.")
|
||||
|
||||
112
backend/app/models/jail_domain.py
Normal file
112
backend/app/models/jail_domain.py
Normal 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
|
||||
545
backend/app/models/response.py
Normal file
545
backend/app/models/response.py
Normal 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.",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
32
backend/app/models/server_domain.py
Normal file
32
backend/app/models/server_domain.py
Normal 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]
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
163
backend/app/repositories/import_run_repo.py
Normal file
163
backend/app/repositories/import_run_repo.py
Normal 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
|
||||
394
backend/app/repositories/protocols.py
Normal file
394
backend/app/repositories/protocols.py
Normal 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]:
|
||||
...
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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``.
|
||||
|
||||
|
||||
357
backend/app/routers/action_config.py
Normal file
357
backend/app/routers/action_config.py
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
539
backend/app/routers/config_misc.py
Normal file
539
backend/app/routers/config_misc.py
Normal 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 (1–2000, 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
385
backend/app/routers/filter_config.py
Normal file
385
backend/app/routers/filter_config.py
Normal 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
Reference in New Issue
Block a user