410 Commits

Author SHA1 Message Date
83b2cb67b1 backup
Some checks failed
CI / Backend Tests (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
CI / Type Check (pull_request) Has been cancelled
CI / Import Boundary (pull_request) Has been cancelled
CI / OpenAPI Breaking Changes (pull_request) Has been cancelled
CI / OpenAPI Baseline Commit (pull_request) Has been cancelled
2026-05-20 20:18:58 +02:00
7308ff88d6 fix(rate-limit): stop double-counting requests in middleware
Multiple RateLimitMiddleware instances were each calling
check_allowed() on every request, halving the effective global
limit (200 req/min became ~100). Added path_prefixes and skip_paths
so each instance only checks the paths it owns.

- Auth middleware scoped to /api/v1/auth/login and /api/v1/setup
- History middleware scoped to /api/v1/history
- Global middleware skips auth and history paths
- Updated tests to match single-count behavior
2026-05-15 23:04:02 +02:00
77df5d5d65 fixed tests 2026-05-15 20:41:05 +02:00
96ce516ecf fix(logging): resolve logging_compat keyword arg conflicts
- Fix logging_compat._log() to handle extra keyword arguments properly
- Update config.py, main.py, and test_bans.py for compatibility
- Update Tasks.md and runner.csx
2026-05-10 15:54:00 +02:00
7ec80fdeec refactor(logging): replace structlog with stdlib logging compat layer
- Remove structlog dependency from backend/pyproject.toml
- Add app.utils.logging_compat shim for keyword-arg logging API
- Add app.utils.json_formatter for JSON log output with extra fields
- Update all backend modules to use logging_compat.get_logger()
- Update docstrings in log_sanitizer.py and json_formatter.py
- Update test comment in test_async_utils.py
- Record 406 failing tests in Docs/Tasks.md for tracking
2026-05-10 13:37:54 +02:00
7790736918 feat(jail-config): add banaction and banaction_allports to blocklist config
Adds iptables-multiport and iptables-allports ban actions to the blocklist-import jail configuration and updates the corresponding test assertions.
2026-05-10 09:35:33 +02:00
79df1aa493 backup 2026-05-10 08:48:42 +02:00
cc9d3220c9 docs(e2e): add debugging notes and fix incorrect login example
Document lessons learned from debugging blocklist import tests:
- RequestsLibrary vs Browser library auth isolation
- CSRF header requirement
- Robot variable type rules
- network_mode: host implications
- SSRF protection behavior
- API response key discrepancies

Also fix API login example: backend accepts plaintext passwords,
not SHA256-hashed as previously documented.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:11:08 +02:00
8fc1989cc4 fix(docker): use host network mode for e2e mock server access
Both backend and frontend now use network_mode=host so the backend
can reach a mock HTTP server on the host's loopback interface during
e2e tests. VITE_BACKEND_URL env var set so frontend proxy reaches
host backend at localhost:8000.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:07:53 +02:00
aa717a28f8 fix(e2e): resolve blocklist import test failures
auth.resource:
- add Login Via HTTP keyword for RequestsLibrary auth (CSRF-aware)
- fix session_duration_minutes type: bare int → ${60}
- add Process library import to common.resource

03_blocklist_import.robot:
- fix selector to button[data-testid] (was matching all buttons)
- use GET/POST On Session with auth session for blocklist API calls
- fix log response key: entries → items
- fix enabled=true → ${TRUE} for boolean type
- fix ${len(sources)} → Get Length keyword
- make Ensure Blocklist Source Exists accept session argument
- replace strict error assertion with specific error banner check
- add graceful Terminate Process teardown

02_ban_records.robot:
- add Process library import

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:07:39 +02:00
e4c3ae718c fix(backend): relax SSRF validation for loopback in dev, graceful metrics/regexploit fallback
- ip_utils: allow loopback (127.0.0.1) in dev mode (BANGUI_LOG_LEVEL=debug)
  so e2e tests can reach a mock HTTP server on the host
- metrics: make all operations no-ops when prometheus_client not installed
- regex_validator: graceful fallback when regexploit not installed
- geo_cache: use attribute access instead of dict subscript for typed rows
- rate_limit: support bucket_override parameter for per-endpoint rate limits
- ban_service: construct DomainActiveBan explicitly instead of model_copy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 08:07:13 +02:00
d4bab89cf3 fix(e2e): resolve SPA auth race conditions in Robot tests
- Rework Login As Admin: use sessionStorage flag + relative fetch login + polling loop
- Add data-testid to JailDetailPage error render path
- Add Collections library import for Get From List keyword
- Fix /jails API response extraction (returns {items, total} not plain list)
- Change Close Context to Close Browser for proper browser cleanup
- Add domcontentloaded + Sleep + polling to Config test to avoid premature timeout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 06:53:09 +02:00
48ef85bec5 backup 2026-05-05 19:51:14 +02:00
17ba07b592 refactor(e2e): replace HttpLibrary with RequestsLibrary
- Swap HttpLibrary for RequestsLibrary in common.resource
- Add robotframework-requests to requirements
- Remove backend health check from suite setup (setup moved to individual tests)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 19:11:36 +02:00
481f32bb85 backup 2026-05-05 18:47:56 +02:00
d25b56e7e1 backup 2026-05-04 13:13:01 +02:00
48d57c31e1 backup 2026-05-04 13:12:57 +02:00
e41831447f docs: update documentation and e2e tests
- Add configuration docs for database and rate limiting
- Remove completed tasks from tracking list
- Update testing requirements with new test patterns
- Enhance web development docs with frontend guidelines
- Expand page loading and ban records e2e test coverage
2026-05-04 08:34:18 +02:00
23c3a0d9e6 feat: add e2e test suite with Robot Framework
Add e2e/ dir with Robot Framework tests for page loading, ban records,
blocklist import, config edit. Add requirements.txt. Update Makefile with
test commands. Update .gitignore, backend docs, testing requirements docs.
2026-05-04 08:29:12 +02:00
5fa67d3288 backup
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 08:16:20 +02:00
744275d17f backup 2026-05-04 07:20:20 +02:00
58173bd6a9 backup 2026-05-04 07:20:16 +02:00
69e1726045 Refactor data fetching hooks, add page size lint test
- Simplify useFetchData: remove unused URL building logic
- Add usePolledData initial implementation
- Add router page_size param validation test
- Update API reference docs
- Clean up tasks doc
2026-05-04 06:48:24 +02:00
0a3f9c6c16 refactor(backend): external logging metrics, required mode, health checks
- Add external_logging_init_failures counter
- Add external_log_required flag, raise if init fails and required
- Health endpoint: add external_logging status check
- Blocklist service: enrich with metadata fields, update import logic
- Health check task: add runtime_state dependency, fix return typing
- Metrics: add Histogram for request latencies
- Frontend: align BlocklistImportLogSection props
- Docs: update deployment guide, remove stale tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 03:45:13 +02:00
42e177e6ea feat(frontend): add ignoreCancellation option for background tasks
Allow useNavigationAbortSignal to opt out of navigation-based abort
for long-lived background tasks like polling. Set ignoreCancellation: true
to keep requests alive across route changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 02:57:56 +02:00
eb339efcfd Add Kubernetes liveness/readiness probes and middleware order validation
- Split /health into /health/live (liveness) and /health/ready (readiness)
  following Kubernetes conventions. Combined /health retained for backward
  compatibility with existing Docker HEALTHCHECK definitions.
- Add ReadyCheck and ReadyResponse models for structured readiness output.
- Add _assert_middleware_order() startup check enforcing:
  RateLimit → Csrf → CorrelationId middleware chain.
- Register CorrelationIdMiddleware, CsrfMiddleware, RateLimitMiddleware
  in create_app() with documented required order (reverse of processing).
- Add correlation.py, csrf.py, rate_limit.py middleware modules.
- Add health probe tests in test_health_probes.py.
- Update test_main.py with middleware order assertion tests.
- Update frontend useFetchData hook tests.
- Docs: update Deployment.md with Kubernetes probe config examples.
2026-05-04 02:42:09 +02:00
65fe747cba feat(backend): add deprecation middleware and API versioning support
- Add deprecation middleware for warning headers on sunset endpoints
- Add jails_v2 router for API v2 migration path
- Update CI workflow with new test coverage
- Update API versioning documentation
- Remove completed tasks from Tasks.md
2026-05-04 00:03:52 +02:00
c8b48b5b65 fix(api): correlation ID survives HMR; fix endpoint template literal typos
- client.ts: store correlation ID in sessionStorage so HMR (module re-eval)
  does not generate a new ID mid-session; add clearSessionCorrelationId()
- endpoints.ts: fix 3 template literal trailing-quote bugs (missing ')' chars);
  replace template literals with string concat for encodeURIComponent calls
- AuthProvider.tsx: call clearSessionCorrelationId() on logout
- App.tsx: reorder ThemeProvider import before AuthProvider per PROVIDER_ORDER.md;
  indent Routes inside AuthProvider to match expected tree structure
- Tasks.md: update task status
- providerTreeOrder.test.tsx: add integration tests for provider nesting order

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 23:35:18 +02:00
fc57c83f79 refactor: split pagination logic from response models
- Extract pagination logic to separate util module
- Update response models to use new pagination util
- Fix pagination calculation edge cases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 22:57:21 +02:00
b2747381ec Remove Issue #51 from Tasks.md (type-enforcement tracked elsewhere) 2026-05-03 22:47:24 +02:00
edebf1a339 feat(services): add ErrorContract enum and PartialResult type
Add typed wrappers for error handling patterns in error_handling.py:

- ErrorContract(enum): machine-checkable pattern selector with
  from_value() helper and string constants matching the existing
  ABORT_ON_ERROR/RETURN_DEFAULT/PARTIAL_RESULT module-level values
- ErrorEntry: typed error container for PARTIAL_RESULT (context + cause)
- PartialResult[T]: typed result wrapper for PARTIAL_RESULT operations

Existing string constants preserved for backward compat.
Updated module docstring with type annotation table and examples.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 22:46:47 +02:00
a2afec2d1e Remove completed Issue #50 navigation abort signal task
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 22:43:19 +02:00
52a70c3eea Add import-linter boundary to forbid routers importing app.dependencies
Issue #51: Enforce repository boundary at CI level using import-linter.
Contract 'forbid_router_db_import' checks that app.routers never imports
app.dependencies directly, keeping the DB access path through service
contexts only.

- Add import-linter>=2.0.0 to dev dependencies (backend/pyproject.toml)
- Configure [tool.importlinter] with package_root and root_packages
- Add [[tool.importlinter.contracts]] with type='forbidden', source
  app.routers, forbidden app.dependencies
- Add 'Import Boundary' CI job (import-linter)
- Document import-linter in CONTRIBUTING.md code quality table

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 22:42:46 +02:00
3376009903 fix(nav): move AbortController creation synchronously in render
Previously the AbortController was created inside useEffect, which runs
after render. This meant requests initiated during render received
the previous cycle's (possibly aborted) signal and were cancelled
immediately instead of completing.

Now the controller is created synchronously when pathname changes, before
any request can be initiated on the new route. The old controller is
aborted in the same conditional block, before the new one is created.

Side effect: removed resolved Issue #49 from Tasks.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 22:37:07 +02:00
7fcfc14199 fix(auth): dedupe handler + error utils refactor
- Add 401/403 dedup guard to API client to prevent double logout
- Extract fetchError util: isAuthError + getErrorMessage
- AuthProvider uses new error utils, removes duplicate logic
- Remove completed task docs from Tasks.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 22:13:12 +02:00
dafe8d61e2 feat(security): add CSRF header constants and security-headers endpoint
Move X-BanGUI-Request header name/value to backend/app/utils/constants.py as single source of truth. Add GET /api/v1/config/security-headers endpoint. Update csrf middleware, frontend api client, and docs to use shared constants.
2026-05-03 22:06:43 +02:00
cee3daffc1 fix: enforce PRAGMA query_only on fail2ban DB and refactor CSRF cookie name
- Add _acquire_readonly_connection() that applies PRAGMA query_only=ON after connect
- Verify PRAGMA value back to catch URI flag bypasses
- Wrap in async context manager _readonly_connection() used by all repo methods
- Replace hardcoded '_SESSION_COOKIE_NAME' in CSRF middleware with import from
  app.utils.constants
- Remove completed Issues #45 and #46 from Docs/Tasks.md (Issue #46 now fixed,
  #45 cache invalidation deferred to auth refactor branch)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 21:47:42 +02:00
1c3dff31e8 feat(rate-limiting): add per-bucket limits and startup validation
- Add per-bucket rate limit config (ban, unban, import, config, jail, filter, action)
- Add process-local warning at startup for multi-worker deployments
- Document Redis migration path for shared state across workers
- Remove Issue #42 from Tasks.md (resolved)
2026-05-03 20:53:21 +02:00
c3cd1574dc fix(auth): invalidate session cache on login
Stale sessions from a stolen device could be reused up to the cache
TTL after a legitimate user re-logs in, because login never cleared
the existing cache entry.

Changes:
- Add invalidate_by_user(user_id) to SessionCache protocol
- InMemorySessionCache maintains a user_id -> set[token] index to
  support O(1) invalidation of all sessions for a given user
- NoOpSessionCache stub updated for API compatibility
- auth_service.login() now returns the Session object alongside
  signed_token and expires_at
- login router calls session_cache.invalidate_by_user(session.id)
  immediately after successful authentication

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 20:51:51 +02:00
ae9313568e feat: enforce single-worker at startup
Fail with RuntimeError when WEB_CONCURRENCY or BANGUI_WORKERS > 1.

In-memory session cache, rate-limit windows, and runtime state are
process-local. Multi-worker silently causes stale limits, ghost sessions,
inconsistent status.

Skipped when TESTING=1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 20:33:23 +02:00
e1a6491ac2 docs: add API reference and database schema docs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 19:50:36 +02:00
4d09d2538d docs: Add security best practices to Deployment.md
- Secrets management via environment variables
- Container security hardening (non-root user, filesystem permissions, capabilities)
- Network security and TLS termination guidance
- Prune obsolete task tracking from Tasks.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 19:48:52 +02:00
624f869f5b docs: add CI workflow and testing requirements documentation
- Add GitHub Actions CI pipeline with pytest, ruff, mypy
- Expand Tasks.md with implementation tracking and testing criteria
- Update CONTRIBUTING.md with CI requirements
- Add Testing-Requirements.md with coverage thresholds and PR checks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 19:42:50 +02:00
497d7cab41 docs: clean up completed accessibility issue, expand WCAG guidelines
- Remove Issue #31 (weak password validation) from Tasks.md
- Mark Issue #32 (accessibility) done in Tasks.md
- Expand Web-Development.md §14 with WCAG compliance rules, ARIA guide, keyboard nav, form accessibility, testing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 18:53:58 +02:00
c96b87ee8b feat: reject common passwords in SetupRequest
- Add ~75 common plaintext passwords to setup.py validator
- Check case-insensitively; passes complexity but blocked
- Add tests: reject common, accept unique, short common fail on length
- Update Security.md docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 18:25:17 +02:00
96525573fa Normalise IP addresses across backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 18:19:41 +02:00
85d05ee582 docs: add editorconfig setup and remove completed task
- Add EditorConfig section to CONTRIBUTING.md with IDE plugins table
- Remove Issue #28 from Tasks.md (pre-commit hooks now documented)
2026-05-03 18:07:33 +02:00
5f0ab40816 refactor(backend): clean up models setup, improve ip utils, add adr docs
- Extract ADR documents for architectural decisions (SQLite, FastAPI, React, APScheduler, Scheduler)
- Refactor setup.py: improve code structure and readability
- Add IP validation utilities with test coverage
- Update frontend components (BanTable, HistoryPage)
- Add pre-commit hooks and CONTRIBUTING.md
- Add .editorconfig for consistent coding standards
2026-05-03 18:04:45 +02:00
2f9fc8076d refactor(backend): clean up jail service, add error handling service
- Extract jail status/processing to helper functions
- Add error_handling.py service for centralized error handling
- Update config.py with validation and defaults
- Update .env.example with all config options
- Remove obsolete Tasks.md, add Service-Development.md
- Minor fixes across routers and services

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 17:40:37 +02:00
2df029f7e8 refactor(ban_service): extract _bans_by_country_load_data helper
Break up long function into focused helper. Load data logic separate from aggregation.
2026-05-03 17:00:34 +02:00
5058a50143 Refactor backend: fix geo cache cleanup, scheduler heartbeat, correlation middleware; update docs 2026-05-03 16:02:40 +02:00
896751ada9 fix: handle socket close errors properly in PapertrailLogHandler
- Replace contextlib.suppress with try/except + warning log
- Add test for fail2ban client
- Remove stale Issue #21 from Tasks.md (indexes)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 12:25:14 +02:00
Copilot
22db607875 Add fail2ban DB index management and socket-based path resolution
- New get_fail2ban_db_path() in setup_service resolves DB path from configured socket path
- New ensure_fail2ban_indexes() creates missing performance indexes on bans table
- Call ensure_fail2ban_indexes on every startup before first ban query
- Remove completed tasks from Docs/Tasks.md
- Update Docs/PERFORMANCE.md with index findings
2026-05-03 12:17:31 +02:00
0133489920 Update observability docs and task utilities
- Add Observability.md documentation
- Standardize task logging with correlation_id support
- Add log_sanitizer utility for PII masking
- Update Tasks.md tracking
- Update geo_cache tasks and other task modules with correlation_id

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 11:52:09 +02:00
7b93499551 Refactor config loading and add status code docs
- Move config loading to dedicated ConfigLoader class with validation
- Add DATABASE_MIGRATIONS.md content to TROUBLESHOOTING.md
- Add API_STATUS_CODES.md documenting all API response codes
- Update runner.csx to use new config structure
- Add check_responses.py validation script
- Update config tests for new structure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 11:52:01 +02:00
8f26776bb3 docs: add OpenAPI responses={} to all router endpoints
Add explicit HTTP status code documentation to every endpoint
across 15 router files. Each endpoint now declares all possible
response codes (200/201/204/400/401/404/409/429/502/503) with
descriptions so frontend can distinguish error types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 01:12:08 +02:00
7ad885d276 refactor: separate config service from jail config service
- Split config_service.py into config_service.py and jail_config_service.py
- Update Docs/Tasks.md, Security.md, TROUBLESHOOTING.md
2026-05-03 01:05:18 +02:00
881cfbdd71 fix: replace broad except Exception with specific exception types
- jail_service: catch ValueError (fail2ban protocol error) instead of Exception
- health.py: catch AttributeError (not OSError/TypeError) for defensive checks
- ban_service: re-raise programming errors in geo lookup handlers
- server_service: catch Fail2BanConnectionError, Fail2BanProtocolError, ValueError
- config_writer: catch OSError instead of Exception

Programming errors now bubble to global handler instead of being silently caught.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 00:54:44 +02:00
bd6170722a feat(geo): add cache hit/miss metrics and prewarm support
- Add _hits/_misses counters to GeoCache for cache hit/miss ratio tracking
- Reset counters on clear()
- Count hits before misses in lookup_batch() to avoid interleaving
- Add synchronous prewarm() using asyncio.create_task for fire-and-forget
- Add hits/misses fields to GeoCacheStatsResponse model
- Add TestCacheMetrics (5 tests), TestPrewarm (3 tests), TestLargeBanList (2 tests)
- Fix _make_async_db() mock: db.execute is not async, returns ctx manager
- Move collections.abc to TYPE_CHECKING block (TC003)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 00:35:47 +02:00
b587c6e850 docs: add TYPE_SAFETY.md documenting frontend/backend type conventions
Establishes shared type conventions to prevent runtime type mismatches
between TS frontend and Python backend. Covers snake_case JSON field
names, null vs empty string handling, timestamp formats, and validation
patterns for country codes, bans, and jail configuration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 00:12:44 +02:00
0817a4cb47 fix(regex_validator): add ReDoS detection via regexploit
Detect catastrophic backtracking patterns before regex compilation
using regexploit library. Add ReDoSDetectedError exception and
_MINIMUM_STARRINESS threshold (>=3) to catch dangerous patterns
like (a+)+b. Update pyproject.toml deps, add tests for detection.
2026-05-03 00:05:33 +02:00
e436727942 fix: atomic upsert for import runs (Issue #12)
Replace check-then-insert race condition with INSERT ON CONFLICT.
- upsert_pending uses RETURNING id for atomic upsert
- UNIQUE(source_id, content_hash) constraint from migration 6
- blocklist_import_workflow updated to use upsert_pending
- test_import_source_success fixed for async mock patterns

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 23:39:43 +02:00
1285bc8571 feat: comprehensive health check with DB, scheduler, cache
- Add /api/v1/health endpoint with component-level checks
- Verify DB connectivity, fail2ban socket, scheduler, session cache
- Add SQLite WAL cleanup on startup (orphan crash files)
- Migration 8: import_log.timestamp → INTEGER UNIX epoch
- Align import_log timestamps with history_archive (already UNIX int)
- Add unit tests for DB cleanup and health router

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 23:03:57 +02:00
b631c1c546 feat(backend): implement graceful shutdown for container stop
Graceful shutdown ensures in-flight operations complete before process exits:
- Lifespan shutdown handler drains pending tasks with 25s timeout
- Scheduler stops accepting new jobs immediately
- HTTP session, external logging, scheduler lock, DB conn closed cleanly
- 25s Python timeout leaves 5s margin before Docker's 30s SIGKILL

Files changed:
- backend/app/main.py: enhanced _lifespan shutdown with task drain
- Docker/Dockerfile.backend: documented signal handling in header
- Docker/docker-compose.yml: added stop_grace_period: 30s
- Docker/compose.prod.yml: added stop_grace_period: 30s
- Docs/Deployment.md: new Graceful Shutdown section with sequence table
- Docs/TROUBLESHOOTING.md: new Graceful Shutdown Issues section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 22:47:10 +02:00
f6c3c02183 Refactor response handling and health check endpoints
- Enhance response model with additional fields and validation
- Update health and server router implementations
- Improve frontend type definitions and API integration
- Clean up documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 21:57:00 +02:00
cc6dbcf3f0 feat: implement API versioning /api/v1/
- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 21:29:30 +02:00
0d5882b32f Fix HIGH priority issues: unbounded queries, rate limiting, health checks
Issue #3 - Unbounded Query Results (OOM):
- get_all_archived_history() now uses keyset pagination with bounded max_rows (50k default)
- Added 'id' field to records from get_archived_history() and get_archived_history_keyset()
- Protocol signature updated with page_size, max_rows, last_ban_id params

Issue #7 - Docker Health Check Fails:
- Added curl to Dockerfile.backend runtime image
- HEALTHCHECK now uses 'curl -f http://localhost:8000/api/health'
- compose.prod.yml: increased start_period to 40s, timeout to 10s
- Frontend healthcheck proxies to backend /api/health

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 21:47:36 +02:00
1830da496d backup
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 20:23:54 +02:00
3b3728c58d feat: implement request deduplication in useFetchData
- Add optional requestKey parameter to UseFetchDataOptions
- Implement module-level cache (inFlightRequests) to track in-flight requests
- When requestKey is provided, multiple hook instances with same key share in-flight requests
- Prevents duplicate API calls when multiple components fetch same data or rapid refresh calls
- Cache entries are automatically cleared when response arrives (success or error)
- Maintains backward compatibility: without requestKey, behaves as before
- Adds comprehensive tests for deduplication scenarios

This reduces bandwidth waste and prevents race conditions caused by concurrent requests for identical data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:44:46 +02:00
e46062d4cd Memoize chart components with custom deep comparison
- Add custom comparison function to React.memo for TopCountriesPieChart
- Add custom comparison function to React.memo for TopCountriesBarChart
- Use JSON.stringify for deep equality comparison of countries and countryNames
- Prevents unnecessary re-renders when parent updates with same data
- Avoids Recharts reprocessing 5000+ data points on each parent re-render

All tests passing. No linting issues.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:36:18 +02:00
1af67eb0ce Add Application Performance Monitoring (APM) with Prometheus metrics
- Backend: Implement Prometheus metrics collection
  - Add prometheus-client dependency
  - Create metrics utility module with HTTP request tracking counters, histograms, gauges
  - Implement MetricsMiddleware to track request latency, count, and active requests
  - Add /metrics endpoint to expose metrics in Prometheus text format
  - Normalize paths to prevent cardinality explosion (e.g., /api/{id} for UUIDs)
  - Exclude /metrics and /health from detailed tracking

- Frontend: Add web vitals and API metrics collection
  - Install web-vitals library (v4.0.0) for Core Web Vitals tracking
  - Create metrics utility module for FCP, LCP, CLS, INP, TTFB collection
  - Implement useTrackedFetch hook for automatic API call metrics (method, endpoint, status, duration)
  - Initialize web vitals tracking in App component on mount
  - Provide exportMetrics() for sending metrics to backend

- Testing:
  - Add comprehensive backend metrics tests (9 tests, 100% coverage)
  - Add comprehensive frontend metrics tests (10 tests)
  - All tests passing

- Documentation:
  - Expand Docs/Observability.md with complete APM section
  - Include metrics reference, integration examples (Prometheus, Datadog, NewRelic)
  - Add troubleshooting guide and best practices for cardinality management
  - Update Tasks.md to mark APM task as complete

Metrics exposed:
- bangui_http_requests_total: HTTP request count by method, endpoint, status
- bangui_http_request_duration_seconds: Request latency histogram
- bangui_http_active_requests: Active request gauge
- Web Vitals: CLS, FCP, INP, LCP, TTFB with ratings
- API metrics: endpoint, method, status, duration, timestamp

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:33:14 +02:00
37078b742b Implement structured logging to centralized platforms (Datadog, Papertrail, ELK)
This commit adds support for shipping logs to external centralized logging platforms, addressing the MEDIUM priority task for structured logging infrastructure.

## Key Changes:

### 1. New Documentation: Docs/Observability.md
- Comprehensive guide to logging architecture and configuration
- Covers all three supported platforms (Datadog, Papertrail, Elasticsearch)
- Includes best practices, security considerations, and troubleshooting
- Documents sensitive data handling and compliance requirements

### 2. Core Implementation: app/utils/external_logging.py
- ExternalLogHandler: Abstract base class for non-blocking log delivery
- DatadogLogHandler: HTTP API integration with JSON payloads
- PapertrailLogHandler: Syslog protocol over TCP
- ElasticsearchLogHandler: Bulk API integration with NDJSON format
- Features:
  - Async buffering with configurable batch size and flush interval
  - Exponential backoff retry logic
  - Non-blocking delivery (never blocks application logic)
  - Proper error handling and internal logging
  - Lifecycle management (start/shutdown)

### 3. Configuration: app/config.py
- New Settings fields for external logging:
  - external_logging_enabled (default: False)
  - external_logging_provider (datadog/papertrail/elasticsearch)
  - external_logging_buffer_size (default: 1000)
  - external_logging_flush_interval_seconds (default: 5.0)
  - Provider-specific configuration (API keys, hosts, batch sizes)
- All fields have sensible defaults
- Full field validation and normalization

### 4. Integration: app/main.py
- Global _external_log_handler for application lifecycle
- _external_logging_processor: structlog processor for handler integration
- Updated _configure_logging(): Add handler to processor chain when enabled
- Updated _lifespan(): Initialize handler before startup, shutdown on termination

### 5. Tests: backend/tests/test_external_logging.py
- 20 comprehensive tests covering all handlers and factory
- Configuration validation tests
- All tests passing

## Design Decisions:

1. **Non-blocking Delivery**: External logging never blocks request handling.
   Failures are logged locally but don't impact application.

2. **Buffering Strategy**: In-memory buffer with configurable size prevents
   unbounded memory growth. When buffer fills, oldest logs are dropped with
   a warning.

3. **Retry Logic**: Transient failures (timeouts, 5xx errors) are retried
   with exponential backoff. Permanent failures (bad credentials) are logged
   and skipped.

4. **Disabled by Default**: External logging is opt-in via environment
   variables, maintaining backward compatibility with existing deployments.

5. **Provider Flexibility**: Support for multiple platforms allows users to
   choose based on their infrastructure (cloud-native, on-premise, etc).

## Backward Compatibility:

- All new configuration fields have defaults
- External logging disabled by default
- No changes to existing logging behavior unless explicitly configured
- No new required dependencies

## Testing:

- All 20 new tests passing
- Existing tests unaffected (same count of passing tests)
- Configuration validation tested
- Handler creation and lifecycle management tested

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:25:26 +02:00
60d9c5b340 Refactor filter configuration with regex validation
- Add regex validation utility for query strings
- Update filter_config_service to use regex validation
- Add comprehensive test coverage for regex validator
- Update exception handling for validation errors
- Update documentation for tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:17:12 +02:00
445c2c5418 Update configuration and documentation
- Update .env.example with latest environment variables
- Update deployment and task documentation
- Update backend configuration settings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:10:03 +02:00
8138857ee1 feat: Implement session secret rotation support
Adds support for gradual session secret rotation without forcing logout:

- Add BANGUI_SESSION_SECRET_PREVIOUS config field for rotation window
- Implement unwrap_session_token_with_rotation() to accept tokens signed with
  either current or previous secret
- Update validate_session() to transparently accept old tokens during rotation
- Update logout() to accept tokens from both secrets
- Add comprehensive logging for rotation events and metrics
- Add 8 new tests covering all rotation scenarios
- Update documentation with step-by-step rotation strategy
- Update .env.example with previous secret field

Key features:
- No forced logout: old tokens continue working during rotation window
- Transparent validation: old tokens are automatically logged for monitoring
- Production-safe: can rotate secrets without service interruption
- Metrics-ready: logs track token rotation for observability

Rotation workflow:
1. Generate new secret and set BANGUI_SESSION_SECRET
2. Set BANGUI_SESSION_SECRET_PREVIOUS to old secret
3. Wait for old tokens to expire (≥ session_duration_minutes)
4. Unset BANGUI_SESSION_SECRET_PREVIOUS to complete rotation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 18:01:11 +02:00
67b26a3ef7 Refactor pagination with cursor-based support and standardized response format
- Implement cursor-based pagination in pagination.py
- Update response models to standardize pagination structure
- Add cursor pagination utilities for repositories
- Update HistoryArchiveRepository and ImportLogRepository with new pagination
- Add comprehensive tests for cursor pagination
- Update documentation for backend development and task tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 17:54:05 +02:00
be974b9b0d fix: Add promise cancellation check to ActionDetail.tsx
Prevent state updates on unmounted components in handleRemoveFromJail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 17:47:02 +02:00
96a21ffb70 Fix promise cancellation in 5 components with AbortController refs
Add AbortController refs and abort signal checks to prevent race conditions
and memory leaks when components unmount or new requests are initiated.

Components fixed:
- JailsTab.tsx: validation handler with AbortController pattern
- JailInfoSection.tsx: handle function with useCallback wrapper
- RawConfigSection.tsx: fetch handler with abort checks
- ConfFilesTab.tsx: file fetch handler with abort signal verification
- IgnoreListSection.tsx: three handlers (add, remove, toggle) with callbacks

All handlers now:
1. Abort previous requests before initiating new ones
2. Create and store new AbortController instances
3. Check abort status before state updates in .then()/.catch()
4. Include cleanup effects that abort on unmount

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 17:43:47 +02:00
c988b4b8b6 Refactor provider composition and ESLint configuration
- Add new provider composition system with validation
- Create providerComposition.tsx for centralized provider management
- Implement providerOrderValidator.tsx to ensure correct provider order
- Add comprehensive tests for provider composition
- Create custom ESLint rules in frontend/eslint-rules/
- Update ESLint configuration
- Update architecture and tasks documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 17:33:56 +02:00
4f7316c484 Add unified RequestValidationError handler to unify error response schema
- Add RequestValidationError handler that converts Pydantic validation errors to unified ErrorResponse format
- Ensures all error responses return consistent schema: code, detail, metadata, correlation_id
- Add field_errors count and first_field location to metadata for validation errors
- Register handler in exception handler hierarchy before HTTPException handler
- Add comprehensive tests for validation error responses
- Update Backend-Development.md documentation to include correlation_id field and validation error details
- All 44 error-related tests pass (38 existing + 6 new validation tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 15:49:39 +02:00
0221e423f2 Fix pagination metadata return structure and test assertions
The API pagination infrastructure was already correctly implemented with:
- PaginatedListResponse base model containing 'items' and 'pagination' fields
- PaginationMetadata object with all required fields (page, page_size, total, total_pages, has_next_page, has_prev_page)
- All services correctly calling create_pagination_metadata()

However, there were two bugs preventing tests from passing:

1. IMPORT BUG: time_utils.py was importing TIME_RANGE_SECONDS from app.models.ban
   when it's actually defined in app.models._common. This caused import errors
   in tests that exercise time-range filtering.

2. TEST BUG: Test assertions were using outdated API structure, accessing
   .total, .page, .page_size directly on paginated responses instead of
   through the .pagination object.

   Fixed locations:
   - test_mappers/test_ban_mappers.py: 3 assertions updated to use .pagination.*
   - test_services/test_blocklist_service.py: 6 assertions updated
   - test_services/test_history_service.py: 14 assertions updated

All paginated API endpoints now correctly return pagination metadata:
- GET /api/history
- GET /api/history/archive
- GET /api/dashboard/bans
- GET /api/jails/{name}/banned
- GET /api/blocklists/log

Verified with 24 passing pagination tests demonstrating correct behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 15:42:05 +02:00
73021429f7 refactor: restructure API pagination metadata for better frontend usability
- Create PaginationMetadata model with computed derived fields (total_pages, has_next_page, has_prev_page)
- Update PaginatedListResponse to embed pagination metadata in a separate 'pagination' object
- Add create_pagination_metadata() factory function in utils/pagination.py for consistent computation
- Update all paginated service functions to use new structure:
  - history_service.list_history()
  - blocklist_service.get_import_logs()
  - jail_service.get_jail_banned_ips()
  - ban_mappers.map_domain_dashboard_ban_list_to_response()
- Update response model docstrings with new structure examples
- Update Backend-Development.md documentation with new pagination patterns
- Update test fixtures to work with new response structure

Response shape changes from:
  {"items": [...], "total": 100, "page": 1, "page_size": 50}
To:
  {"items": [...], "pagination": {"page": 1, "page_size": 50, "total": 100, "total_pages": 2, "has_next_page": true, "has_prev_page": false}}

Benefits:
- Frontend receives all pagination state needed for UI controls
- No need for frontend to calculate total_pages or page navigation logic
- Consolidated pagination metadata reduces field sprawl
- OpenAPI schema automatically reflects changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 22:24:42 +02:00
05c3b564ae Refactor scheduler lock implementation with heartbeat mechanism
- Add heartbeat-based lock renewal in scheduler_lock_heartbeat.py
- Update scheduler_lock.py with improved lock management
- Add comprehensive tests for scheduler lock functionality
- Update deployment and task documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 22:10:38 +02:00
f9e283541b Add explicit database transaction isolation to multi-step operations
This commit addresses race conditions in multi-step database operations by:

1. Wrap write operations in BEGIN IMMEDIATE ... COMMIT transactions:
   - import_run_repo: create_pending, mark_completed, mark_failed
   - geo_cache_repo: all upsert_*_and_commit functions
   - geo_cache_repo: bulk_upsert_entries_and_neg_entries_and_commit

2. Handle concurrent write collisions gracefully:
   - import_run_repo.create_pending can now raise IntegrityError
   - blocklist_import_workflow catches IntegrityError and retries lookup
   - Logs 'blocklist_import_lost_race' event when another request wins the race

3. Add comprehensive documentation:
   - Backend-Development.md § 6.3 Database Transactions
   - Explains when to use BEGIN IMMEDIATE
   - Shows transaction pattern with try-except-rollback
   - Documents race condition error handling pattern

The solution leverages SQLite's UNIQUE constraint for data integrity while
handling the concurrent case gracefully in application logic. This is more
efficient than using BEGIN EXCLUSIVE which would serialize all writers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 22:04:15 +02:00
94d6352d1d Fix health check endpoint to return 503 when fail2ban is offline
The health check endpoint now properly indicates service unavailability:
- Returns HTTP 200 when fail2ban is online
- Returns HTTP 503 when fail2ban is offline

This allows Docker and other orchestration tools to correctly detect when
fail2ban is unreachable and automatically restart the backend container,
preventing the situation where Docker treats the container as healthy
despite fail2ban being down.

Changes:
- Update GET /api/health to return 503 on fail2ban offline
- Return appropriate JSON response bodies for each state
- Update tests to verify both online (200) and offline (503) scenarios
- Update Dockerfile HEALTHCHECK documentation
- Add Health Checks section to Deployment.md documentation

All tests pass with 100% coverage on health.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:56:42 +02:00
52f237d5d4 Make background tasks idempotent - prevent duplicate bans on retry
CRITICAL FIX: Background tasks (especially blocklist_import) crashed mid-execution,
leaving partial state. On retry, the same bans were applied again, causing duplicates.

Solution: Content-hash based operation tracking for blocklist imports:
- Added import_runs table (migration 6) to track operations by source + content hash
- Before banning, check if this exact content has already been imported
- If completed: skip banning (already done), optionally re-warm cache
- If new or failed: proceed with ban and mark as completed or failed

Changes:
- Database: Migration 6 adds import_runs table with operation state tracking
- Model: Added ImportRunEntry for import run records
- Repository: New import_run_repo module with CRUD operations
- Workflow: Updated blocklist_import_workflow to check operation history before banning
- Dependencies: Registered import_run_repo for dependency injection
- Tests: Added test_import_source_idempotent_on_retry and test_import_source_different_content_not_reused
- Documentation: Added Task Idempotency section to Backend-Development.md

Verification:
- All 7 import tests pass (5 existing + 2 new idempotency tests)
- Type checking: mypy --strict 
- Linting: ruff 
- No API changes, backwards compatible via automatic migration

Fixes: Background tasks not idempotent #CRITICAL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:54:14 +02:00
400ab1a3f1 Add security headers middleware and documentation
- Add SecurityHeadersMiddleware to backend/app/main.py
  - Implements Content-Security-Policy: default-src 'self'
  - Implements X-Frame-Options: DENY (clickjacking protection)
  - Implements X-Content-Type-Options: nosniff (MIME-sniffing protection)
  - Implements X-XSS-Protection: 1; mode=block (browser XSS filters)
- Add CSP meta tag to frontend/index.html for defense-in-depth
- Create Docs/Security.md with comprehensive security headers documentation
- Add test suite (backend/tests/test_security_headers_middleware.py) with 5 tests
  - Tests verify headers are present on success and error responses
  - Tests ensure all four security headers are correctly set
- All existing tests continue to pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:33:08 +02:00
3bd9848a08 Implement global rate limiter and refactor auth middleware
- Add global rate limiter utility with configurable limits and cleanup
- Move rate limiting logic to middleware for consistent application
- Update auth routes to use new rate limiter
- Add comprehensive tests for rate limiter functionality
- Update documentation with backend development guidelines and tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:26:31 +02:00
d1316ca66e Clear Tasks.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:05:00 +02:00
90f4c6239c Add resource limits to all Docker containers
- fail2ban: 0.5 CPU / 128M memory limit, 0.1 CPU / 64M reserved
- backend: 2.0 CPU / 512M memory limit, 1.0 CPU / 256M reserved
- frontend: 0.5 CPU / 128M memory limit, 0.25 CPU / 64M reserved

Prevents 'noisy neighbor' scenarios where one container exhausts
host resources (CPU, memory, disk). Limits are hard caps; reservations
guarantee minimum allocation to prevent OOM kills and ensure
responsive service even under load.

Fixes resource contention issue in production and staging environments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:03:56 +02:00
fc5f44ebe4 Add session validation UI and expose isValidating in auth context
- LoginPage now shows a loading spinner while validating the session
- Redirect to dashboard automatically once validation completes and session is valid
- Expose isValidating state through AuthProvider for components to track validation status
- Update useAuth hook to return isValidating along with isAuthenticated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:02:49 +02:00
e24b1241fb docs: Add pre-commit hook setup instructions for type validation
Document optional local Git pre-commit hook configuration to catch
type drift before commits. Also document Husky alternative.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:02:00 +02:00
59c92f9a83 feat: Implement automated OpenAPI type generation
Add automated type synchronization from backend OpenAPI schema to frontend TypeScript types to prevent type drift and ensure runtime safety.

Changes:
- Add openapi-typescript as dev dependency
- Create npm scripts for type generation (generate:types) and validation (validate:types)
- Integrate type generation into build pipeline (runs before TypeScript compilation)
- Generate frontend/src/types/generated.ts from backend OpenAPI schema
- Add frontend/scripts/validate-types.sh for CI/CD validation
- Update Web-Development.md with type generation workflow documentation
- Update Backend-Development.md with OpenAPI schema sync requirements

Workflow:
1. Backend automatically exposes OpenAPI schema at /api/openapi.json (FastAPI built-in)
2. Frontend build runs 'npm run generate:types' to generate types from schema
3. Generated types are committed to version control
4. CI can run 'npm run validate:types' to fail builds if types drift

Fixes critical type safety issue where frontend types were manually maintained
and could become out of sync with backend Pydantic models.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 21:00:05 +02:00
c4ede71fa6 Fix: Enforce single-worker deployment for session cache cluster safety
Addresses: Backend session cache not cluster-safe (multi-worker issue)

Problem:
- Session cache is process-local (InMemorySessionCache)
- Multi-worker deployments (uvicorn --workers N) create separate processes
- Each process has its own independent session cache
- Sessions cached in Worker A are invisible to Workers B, C, D
- Users randomly logged out when requests land on different workers
- Also affects RuntimeState, rate limiter, and background jobs

Solution (Option A - Strict single-worker enforcement):
- Enhance startup validation with clearer error messages
- Update error messages to explain the problem and how to fix it
- Document single-worker requirement prominently in Docker configs
- Update module docstrings to clarify constraints

Changes:
1. app/startup.py:
   - Enhanced _check_single_worker_mode() error message with troubleshooting
   - Enhanced _stage_check_worker_mode_and_acquire_lock() error message
   - Removed unused import

2. app/utils/session_cache.py:
   - Updated module docstring to explain constraints more clearly
   - Added references to deployment documentation
   - Clarified multi-worker solution for future implementation

3. app/utils/runtime_state.py:
   - Updated module docstring with deployment constraint references
   - Aligned messaging with session_cache.py

4. Docker/Dockerfile.backend:
   - Added comprehensive comments about single-worker requirement
   - Explained impact in multi-worker deployments
   - Referenced deployment constraints documentation

5. Docker/docker-compose.yml, compose.prod.yml, compose.debug.yml:
   - Added documentation comments about BANGUI_WORKERS constraint
   - Explained why single-worker is required

6. backend/tests/test_startup_integration.py:
   - Fixed test unpacking to match function return signature (3 values, not 2)

This ensures multi-worker deployments fail loudly at startup with clear
guidance on what went wrong and how to fix it. The database-backed scheduler
lock provides defense-in-depth for container orchestration scenarios.

For future multi-worker support, implement:
- Redis or database-backed session cache
- Shared RuntimeState coordination
- Distributed APScheduler backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:54:24 +02:00
f074882f2d Update documentation and ErrorBoundary component
- Updated architecture documentation with refactoring notes
- Updated task documentation with progress
- Enhanced ErrorBoundary component for better error handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:43:41 +02:00
3bd2a71367 Refactor usePolledData hook and add comprehensive tests
- Renamed usePolledIntervalCheck to usePolledData for clarity
- Updated hook to properly manage interval cleanup on unmount
- Added comprehensive test suite covering normal operation, error handling, and cleanup
- Updated documentation to reflect new hook name
- Updated Tasks.md to track progress

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:24:47 +02:00
69d32bfbe9 feat: Implement cross-tab authentication synchronization in AuthProvider
- Add BroadcastChannel API for real-time logout synchronization across tabs
- Implement storage event listener as fallback for older browsers
- When a user logs out in one tab, all other tabs immediately reflect the logout state
- Update tests to verify storage event and BroadcastChannel behavior
- Update Architecture.md to document cross-tab synchronization
- Update Web-Development.md with authentication state management notes

The provider now broadcasts logout messages to other tabs so they immediately
reflect the logout state without requiring a page refresh or additional API calls.
The implementation uses BroadcastChannel as the primary sync mechanism with
storage events as a fallback for older browsers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:15:26 +02:00
ac53a56ae7 Update backend configuration and documentation
- Modified main.py with backend updates
- Updated Tasks.md documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:10:57 +02:00
9afdbe2852 Refactor auth and setup services
- Updated auth_service.py to improve authentication logic
- Modified setup_service.py for better configuration handling
- Added comprehensive tests for setup_service
- Updated documentation in Tasks.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:10:00 +02:00
7f68d6b7d7 Remove completed task from Tasks.md
The login rate limiter task has been completed and resolved, removing
it from the active tasks list.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:06:29 +02:00
3d5acb756f refactor: move repository and service imports to module level in dependencies.py
Move all repository imports (session_repo, blocklist_repo, import_log_repo,
settings_repo, history_archive_repo, geo_cache_repo, fail2ban_db_repo) and
service imports (auth_service, health_service, default_fail2ban_metadata_service)
to module level in app/dependencies.py.

This eliminates the pattern of local imports inside provider functions,
providing consistency and reducing import overhead. The from app.db import
open_db remains a local import since it's only used within get_db().

- Verified no circular dependencies exist
- All repository and service provider functions simplified to return modules
- Updated Architekture.md § 2.3 to document the module-level import pattern
- All tests pass (28 dependency + auth tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 20:06:10 +02:00
277f2a467c Refactor rate limiting with exponential backoff strategy
- Update rate limiter to use exponential backoff instead of fixed limit
- Implement progressive delays for failed login attempts (0.5s, 1s, 2s, 4s, 5s max)
- Update auth router documentation and endpoint docs
- Refactor test suite to match new rate limiting behavior
- Update backend development documentation
- Clean up unused tasks documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:58:09 +02:00
2db635ae19 Fix exception handler overlap issue - add DomainError catch-all handler
**Problem:** Broad exception handlers created fragility where adding a new
DomainError subclass without explicit registration would silently fall through
to the generic exception handler, losing the specific error_code and metadata.

**Solution:**
1. Import DomainError in main.py for explicit handler registration
2. Fix type hints in exception handlers from 'Exception' to specific types
   - NotFoundError handler now typed as 'NotFoundError'
   - BadRequestError handler now typed as 'BadRequestError'
   - ConflictError handler now typed as 'ConflictError'
   - DomainError handler now typed as 'DomainError'
   - ServiceUnavailableError handler now typed as 'ServiceUnavailableError'
3. Add DomainError as an explicit catch-all handler in the registration chain
   - Positioned after specific handlers, before HTTPException
   - Any unregistered DomainError subclass now gets correct error_code + metadata
4. Document the exception handler hierarchy with detailed comments
5. Update Backend-Development.md with handler hierarchy documentation
6. Update Architekture.md section 2.2 with exception handler details
7. Fix test expectations in test_main.py to verify ErrorResponse format

**Impact:** Any new DomainError subclass now automatically gets correct HTTP 500
status, error_code, and metadata - even if developer forgets explicit handler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:44:43 +02:00
9b4aee7f37 docs: enhance Pydantic validator constraints and mark task complete
Verified that BanGUI's codebase is fully compliant with the constraint that
Pydantic validators must not execute at import time or have side effects.

Changes:
- Architekture.md § 2.1: Added explicit 'No I/O or Side Effects' constraint
  for model validators, explaining why this prevents circular dependencies
- Backend-Development.md: Enhanced validator documentation with subsection
  on import-time execution, including wrong/correct examples
- Tasks.md: Marked '[Backend] Pydantic validators execute at import time'
  as COMPLETE with verification results and regression prevention guidance

Verification Summary:
✓ Audited 14 model files: no problematic imports or function calls
✓ Import time: 0.159s (fast, no import-time side effects)
✓ Type checking: mypy --strict passes on all models
✓ Unit tests: 17 tests pass (100%)
✓ Correct pattern in use: validation in routers/services, not models

The codebase architecture is sound—no code changes required, only
documentation clarification to prevent future violations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:37:03 +02:00
100fd47c4b Refactor: Make model packages true leaf nodes - remove app-layer dependencies
Models in app/models/ are now pure data classes with no cross-layer dependencies.
This ensures the models layer remains a true leaf node in the dependency graph.

Changes:
- Create app/models/_common.py with shared types (TimeRange, bucket_count, constants)
- Move TimeRange and time-range constants from ban.py to _common.py
- Update history.py, routers, and services to import from _common.py
- Remove imports from app.config and app.utils from config.py models
- Move field validators from models to router layer:
  - Add log_target validation in config_misc router
  - Add log_path validation in jail_config router
- Update test_models.py to reflect validators moved to router layer
- Update documentation (Architekture.md, Backend-Development.md) with model layering rules
- Fix import ordering and type annotations in affected files

Model layering rule: Models may only import from:
✓ Standard library and third-party packages (Pydantic, typing)
✓ Other models in app/models/ (sibling models)
✓ app.models.response (response envelopes)
✗ app.services, app.config, app.utils, or any application layer

Validation requiring app-level state (settings, allowed directories) now happens
at the router or service layer, not in model validators.

Fixes: Models were not true leaf nodes due to circular imports and app-layer dependencies

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 19:31:11 +02:00
3d1a6f5538 Implement frontend and backend observability alignment
Align frontend and backend error observability with correlation IDs and
structured telemetry for distributed tracing across systems.

Backend changes:
- Add CorrelationIdMiddleware to generate/extract correlation IDs
- Include correlation_id in all ErrorResponse objects
- Store correlation ID in structlog contextvars for automatic inclusion in logs
- Add correlation ID to response headers (X-Correlation-ID)

Frontend changes:
- API client automatically generates session-scoped UUID4 and includes
  X-Correlation-ID header in all requests
- Extract correlation ID from API error responses
- Update error handlers to use telemetry with correlation IDs
- Add telemetry logging to ErrorBoundary, PageErrorBoundary, SectionErrorBoundary
- Implement redaction utilities for privacy-safe logging of sensitive data

Documentation:
- Add observability guidelines to Web-Development.md
  * Correlation ID usage patterns
  * Privacy & security best practices
  * Telemetry event structure
  * Redaction utilities for sensitive data
- Add distributed tracing architecture section to Architecture.md
  * Correlation ID flow across frontend/backend
  * Example troubleshooting scenario
  * Implementation details for future enhancements

Testing:
- Add comprehensive tests for correlation middleware
- Update error boundary tests to verify telemetry integration
- Verify TypeScript and ESLint pass with no warnings

Fixes: Issue #40 - Frontend and backend observability are not aligned

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 18:32:19 +02:00
9a43123b3a docs: Define explicit DI container strategy for backend service graph
- Add comprehensive 'Dependency Wiring and Service Composition' section to
  Architekture.md (§ 2.3) documenting:
  * The lightweight FastAPI Depends() pattern used as composition root
  * Service composition through explicit parameter passing
  * Service context dependencies pattern (SessionServiceContext, etc.)
  * Repository boundary enforcement
  * Lifecycle and scope management
  * Checklist for adding new services

- Update Backend-Development.md to reference the new Architecture section
  from the 'Dependency Layering' section

- Enhance dependencies.py module docstring with clear explanation of:
  * Composition root pattern
  * Explicit over implicit principles
  * Service context dependencies
  * Repository boundary enforcement

This resolves issue #39 by providing clear guidance on dependency wiring
without over-engineering. The pattern uses FastAPI's built-in Depends()
framework and avoids heavyweight container libraries, keeping the solution
lightweight and maintainable.

Fixes: #39

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 20:25:25 +02:00
b6631b86e4 Add database migration 5: Indexes for history_archive query performance
- Add composite index on (jail, timeofban DESC) for dashboard filtering
- Add composite index on (timeofban DESC, jail, action) for time-range queries
- Add single-column indexes on ip and action for targeted filtering
- Update schema version to 5 and document in Backend-Development.md

Indexes optimize common dashboard and API query patterns with pagination.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 20:17:58 +02:00
187cd8250d Implement database-backed scheduler lock for multi-worker safety
Enforce single-executor safety regardless of process launcher through a
robust database-backed lock mechanism that works reliably in container
orchestration environments.

Key changes:
1. Add scheduler_lock table to database schema (migration 4)
   - Singleton row (id=1) prevents concurrent execution
   - Stores PID, hostname, creation timestamp, heartbeat timestamp
   - Atomic transaction prevents race conditions

2. Create scheduler lock utility (app/utils/scheduler_lock.py)
   - acquire_scheduler_lock(): Atomically acquire or fail
   - release_scheduler_lock(): Clean up on shutdown
   - update_scheduler_lock_heartbeat(): Keep lock alive (every 10 seconds)
   - get_scheduler_lock_info(): Debug/inspect lock status
   - Stale lock detection: TTL-based (60 second expiry)

3. Reorder startup DAG stages
   - DATABASE now comes first (required for lock acquisition)
   - WORKER_MODE depends on DATABASE (performs lock check after initialization)
   - Maintains all other stage dependencies intact

4. Update startup process (app/startup.py)
   - Replace _check_single_worker_mode() with two-tier check:
     * Fast check: BANGUI_WORKERS env var (if explicitly set to >1)
     * Authoritative check: Database lock (catches misconfiguration)
   - Return startup_db from startup_shared_resources() for lock management

5. Register scheduler lock heartbeat task
   - New task: scheduler_lock_heartbeat (app/tasks/scheduler_lock_heartbeat.py)
   - Updates lock heartbeat every 10 seconds (keeps lock alive)
   - Prevents false positives from temporary load spikes

6. Add lock release to lifespan shutdown (app/main.py)
   - Release lock before closing database
   - Allows other instances to acquire during rolling deployments
   - Graceful handoff between instances

7. Comprehensive test coverage (backend/tests/test_scheduler_lock.py)
   - Lock acquisition success and failure cases
   - Stale lock cleanup on startup
   - Lock release and heartbeat updates
   - Full lifecycle: acquire → heartbeat → release

8. Update documentation (Docs/Architekture.md § 9.3)
   - Explain single-executor requirement
   - Document database-backed locking mechanism
   - Compare with alternative approaches (filesystem, env var)
   - Include troubleshooting guide
   - Container orchestration examples (Docker, Kubernetes, systemd)

Why database-backed instead of filesystem?
   - Atomicity: SQLite transactions prevent TOCTOU race windows
   - Container-safe: Works across containers with shared DB volumes
   - No NFS/SMB edge cases
   - Timestamp-based stale detection (PID reuse is unreliable)
   - More reliable in rolling deployments

Benefits:
   - Works with any process manager (uvicorn, gunicorn, etc.)
   - Handles simultaneous startup attempts correctly
   - Automatic failover on instance crash (stale lock cleanup)
   - Clear error messages with troubleshooting steps
   - No environment variable required (lock is authoritative)
   - Scales to multi-worker deployments if combined with external job store

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 20:10:53 +02:00
336242ad06 Implement visibility-aware polling to reduce background tab resource usage
- Add usePageVisibility hook to track page visibility state
- Add pauseWhenHidden option to usePolledData (defaults to false for backward compatibility)
- When enabled, polling pauses when page is hidden and resumes with immediate refresh when visible
- Refactor useBlocklistStatus to use usePolledData with pauseWhenHidden=true
- Add comprehensive tests for usePageVisibility hook
- Add polling lifecycle documentation to Web-Development.md

Fixes #36: Polling continues when tab is not visible

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 20:01:25 +02:00
0a350b3acc Optimize API client headers by method - only set Content-Type and CSRF header as needed
- Only set Content-Type header for requests with a body (POST, PUT, DELETE with body)
- Only set X-BanGUI-Request CSRF header for mutating methods (POST, PUT, DELETE, PATCH)
- GET, HEAD, OPTIONS requests no longer include unnecessary headers, reducing CORS preflights
- Update Web-Development.md to clarify conditional header behavior
- Add comprehensive tests for header behavior by HTTP method

This reduces unnecessary CORS preflight requests on GET endpoints while maintaining
CSRF protection on state-mutating requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:52:17 +02:00
bc4ba703f0 Fix #34: Replace setup redirect allowlist prefix matching with explicit allowlist
- Replace fragile startswith() matching with explicit path matching
- Split allowlist into _EXACT_ALLOWED (exact paths) and _PREFIX_ALLOWED (prefixes)
- Prefix paths MUST end with '/' to prevent matching unintended paths like /api/setup-debug
- Paths correctly matched: /api/setup, /api/health, /api/docs, /api/redoc, /api/openapi.json, /api/setup/timezone
- Paths correctly blocked: /api/setup-debug, /api/setup123, /api/jails
- Add comprehensive Setup Guard Route Policy documentation to Backend-Development.md
- Update line numbers in documentation to reflect current implementation

This prevents future route additions from accidentally bypassing the setup guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:45:42 +02:00
6bc440dce4 Refactor backend configuration and authentication
- Add comprehensive documentation for backend development
- Improve client IP detection with utility functions and tests
- Update auth router with better error handling
- Refactor config module with environment-based settings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:39:55 +02:00
dd14ed7e7e Update Tasks.md
- Remove completed task #31 about fire-and-forget reschedule

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:29:49 +02:00
c2dd9f5f55 Add scheduled cleanup for rate limiter (#32)
Implement periodic cleanup of expired rate-limiter entries to prevent
unbounded memory growth during long runtimes.

Changes:
- Create rate_limiter_cleanup task that calls cleanup_expired() every 30 minutes
- Register the task in the startup DAG alongside other background jobs
- Update rate_limiter module documentation with operational notes about the
  cleanup lifecycle and memory management strategy

The cleanup is conservative and only removes IPs with no recent attempts
(all timestamps outside the rate-limit window), so active IPs are preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:28:45 +02:00
18036d53bf Fix issue #31: Make schedule reschedule deterministic and observable
Replace fire-and-forget reschedule pattern with proper async/await:
- Changed reschedule() from fire-and-forget to awaitable async function
- Errors are now properly propagated instead of silently failing
- Added structured logging for reschedule start and completion
- Schedule updates are now deterministic and observable to callers

Changes:
- app/tasks/blocklist_import.py: Convert reschedule to async, remove asyncio.ensure_future
- tests/test_tasks/test_blocklist_import.py: Add tests for error propagation and logging
- Docs/Features.md: Document scheduling reliability guarantees

All 15 blocklist_import tests pass with 100% coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:24:55 +02:00
1302ac821f Fix non-atomic setup persistence across DB contexts (Issue #30)
Implement transactional setup with explicit state machine and crash-safety
to prevent partial commits from leaving inconsistent state.

## Changes

### Core Implementation
1. **settings_repo.py**: Add atomic batch settings write
   - New set_settings_batch() method: writes multiple settings in single
     transaction (BEGIN IMMEDIATE ... COMMIT). Either all settings persist
     or none do, preventing partial state if crash occurs mid-batch.

2. **setup_service.py**: Refactor run_setup() with transactional phases
   - Phase 0: Compute password hash early (before any DB writes) to ensure
     idempotency. Same hash is used throughout retries, preventing divergent
     hashes from bcrypt's random salt.
   - Phase 1 (Bootstrap DB transaction): Set setup_state=in_progress and
     database_path, then commit. First checkpoint for crash detection.
   - Phase 2 (Filesystem): Initialize runtime database (idempotent)
   - Phase 3 (Runtime DB transaction): Batch-write all settings atomically
   - Phase 4 (Bootstrap DB transaction): Set setup_state=complete and
     setup_completed=1. Final commit point.

3. **protocols.py**: Add set_settings_batch to SettingsRepository protocol

### Testing
- Added 6 new transactionality tests covering:
  - State machine transitions (None → in_progress → complete)
  - Password hash idempotency across retries
  - Atomic batch writes (all-or-nothing persistence)
  - Bootstrap DB state tracking
  - Database path propagation to both DBs
  - Recovery on partial failure
- All 18 tests pass (12 existing + 6 new)

### Documentation
- Updated Docs/Architekture.md with new section 6:
  - Setup state machine with state transitions
  - Transaction boundary documentation
  - Password hash idempotency rationale
  - Backward compatibility notes

## Design Decisions

### Why This Approach
- Current code already idempotent via INSERT OR REPLACE, but password
  hash non-idempotency created silent inconsistency risk
- Simpler than multi-state machine: 2 states sufficient for detection
- Maintains backward compatibility (setup_completed key still written)
- Explicit transactions make crash-safety obvious to future maintainers

### Crash Scenarios Now Handled
1. Crash after Phase 1 → detected by setup_state=in_progress on retry
2. Crash after Phase 2 → runtime DB may be partial, safe to retry
3. Crash after Phase 3 → runtime DB rolls back on next connection
4. Crash after Phase 4 → setup_completed detected, skipped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:19:53 +02:00
cc4370c50d feat: Add runtime DNS-rebinding protection for blocklist HTTP connections
## Problem
The blocklist URL validation at create/update time has a TOCTOU (time-of-check-to-time-of-use) window.
An attacker can perform a DNS-rebinding attack where:
1. User adds blocklist URL pointing to attacker.com
2. At create time, attacker.com resolves to a public IP → validation passes
3. Later, when fetching, attacker.com resolves to 192.168.1.1 (internal network)
4. HTTP client connects to the private IP, potentially accessing internal services

## Solution
Add runtime destination IP validation at connection time via a custom socket factory:

- Created 'dns_validated_connector.py' with create_dns_validated_socket_factory() that validates
  all resolved IPs before socket creation
- HTTP session now uses the validated socket factory, protecting all blocklist imports globally
- Rejects connections to RFC 1918 private ranges, loopback, link-local, ULA, multicast, and
  reserved addresses (IPv4 and IPv6)
- Added comprehensive test coverage with 13 test cases

## Changes
- backend/app/services/dns_validated_connector.py: Custom socket factory with IP validation
- backend/app/startup.py: Use DNS-validated socket factory in HTTP session creation
- backend/app/utils/ip_utils.py: Updated docstring explaining runtime validation
- backend/app/services/blocklist_downloader.py: Updated module docstring
- backend/app/services/blocklist_service.py: Updated docstrings explaining two-layer protection
- backend/tests/test_services/test_dns_validated_connector.py: Test suite for socket factory
- Docs/Architekture.md: Added detailed section on DNS-rebinding protection

## Testing
- All 13 DNS validation tests pass
- All blocklist downloader tests pass (unaffected by changes)
- Linting: ruff, mypy pass with --strict
- Test coverage: 90% line coverage on dns_validated_connector.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 19:10:51 +02:00
9072117db3 ## 28) Login failure delay can enable app-layer DoS 2026-04-29 19:02:00 +02:00
1e2576af2a ## 27) Error response body shape is inconsistent 2026-04-28 22:28:02 +02:00
a2129bb9bd Pagination contract is not standardized across endpoints 2026-04-28 21:40:22 +02:00
ad21590f60 No canonical snake_case/camelCase serialization policy 2026-04-28 21:27:26 +02:00
b27765928a Standardize API response envelopes: use items for collection responses and update tests 2026-04-28 20:48:00 +02:00
1c673d600c Standardize API response envelope shapes across all endpoints
This commit standardizes how API responses are wrapped, solving issue #24.

Problem:
- Inconsistent response envelopes (jails vs items vs bans vs no wrapper)
- Frontend required multiple field name variants
- Integration bugs from branching logic
- No clear pattern for different response types

Solution:
- Created response.py with base classes: PaginatedListResponse,
  CollectionResponse, CommandResponse
- Standardized all list/collection responses to use 'items' field
- Domain-specific field names for detail and aggregation responses
- Updated all backends routers and mappers
- Updated frontend types and hooks to match

Changes:
Backend:
- backend/app/models/response.py (new): Base response models
- backend/app/models/ban.py: Updated responses to inherit from bases
- backend/app/models/jail.py: Updated JailListResponse, JailCommandResponse
- backend/app/models/config.py: Updated collection responses
- backend/app/services/jail_service.py: Updated return statements
- backend/app/mappers/ban_mappers.py: Updated 'bans' to 'items'
- backend/tests/test_mappers/test_ban_mappers.py: Updated tests

Frontend:
- frontend/src/types/jail.ts: Updated response interfaces
- frontend/src/types/config.ts: Updated response interfaces
- frontend/src/hooks/useActiveBans.ts: Updated selector
- frontend/src/hooks/useJailList.ts: Updated selector
- frontend/src/hooks/useJailConfigs.ts: Updated selector
- frontend/src/hooks/useConfigActiveStatus.ts: Updated field access
- frontend/src/hooks/useJailAdmin.ts: Updated field access

Documentation:
- Docs/Backend-Development.md: Added § 4.1 API Response Envelope Policy

The policy defines:
1. Paginated lists use PaginatedListResponse (items, total, page, page_size)
2. Non-paginated collections use CollectionResponse (items, total)
3. Detail responses use entity-specific field names (jail, status, settings)
4. Command responses use CommandResponse (message, success, optional target)
5. Aggregations use domain-specific fields (jails, countries, buckets, bans)

All responses now follow one of these patterns, reducing frontend complexity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 10:12:55 +02:00
7ba1cf7ca2 feat: Implement global request lifecycle cancellation on route transitions
Adds a navigation-aware request cancellation mechanism that automatically
aborts all route-specific API requests when the user navigates to a
different route. This prevents silent state-update errors from responses
arriving after component unmount and conserves bandwidth by cancelling
now-irrelevant requests.

Key additions:
- NavigationCancellationContext: Context for managing route-specific signals
- NavigationCancellationProvider: Provider that detects route changes and
  aborts all signals from the previous route
- useNavigationAbortSignal hook: Allows components to subscribe to
  navigation-aware cancellation signals
- Comprehensive tests for the cancellation lifecycle
- Documentation in Web-Development.md for request lifecycle policy

The provider is placed in the app hierarchy between BrowserRouter and
AuthProvider, ensuring consistent cancellation behavior across all routes.

Long-lived background tasks (polling, session validation) can opt-out by
managing their own AbortController lifecycle.

Closes #23 from Tasks.md: No global cancellation policy on route transitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:58:59 +02:00
e0a4d36fc3 Document storage key registry pattern in Web-Development
Add "Storage Key Registry" subsection explaining:
- Centralize all storage keys in utils/constants.ts
- Never hard-code storage key strings in components
- Document storage type and purpose with JSDoc
- Include code examples for correct usage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:49:23 +02:00
252204ed97 Consolidate frontend storage keys into constants module
- Move magic strings from AuthProvider, MainLayout, and ThemeProvider to
  frontend/src/utils/constants.ts
- Add STORAGE_KEY_AUTHENTICATED, STORAGE_KEY_SIDEBAR_COLLAPSED, and
  STORAGE_KEY_THEME constants with JSDoc descriptions
- Update all three files to import and use centralized keys
- Prevents key drift and typo regressions across the frontend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:48:28 +02:00
72c4a0ed04 fix: prevent silent auth error swallowing in fetch error utility
- Add setAuthErrorHandler() registration mechanism to utils/fetchError.ts
- Implement fallback logging when auth errors (401/403) occur without registered handler
- Update AuthProvider to register both API client and fetch error handlers
- Ensure auth errors are handled deterministically at multiple layers
- Add comprehensive tests for auth error handler registration and fallback logging
- Update Web-Development.md documentation with auth error handling contract

Fixes issue #21: Silent auth errors are now caught and logged if the handler is not
registered, preventing actionable errors from being silently swallowed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:45:08 +02:00
ca23858946 Add skeleton loading components for progressive UX
Implement standardized skeleton loading placeholders to reduce perceived
loading time and prevent layout shift during data fetches. These components
match actual content dimensions exactly, improving perceived responsiveness.

New skeleton components in src/components/skeletons/:
- SkeletonTable: Table/grid loading with customizable rows and cells
- SkeletonTableRow: Individual animated skeleton row
- SkeletonChart: Chart/graph loading with bars matching dimensions
- SkeletonStat: Stat card loading with label and value
- SkeletonFormField: Form input loading placeholder
- PageLoadingSkeleton: Convenience wrapper for page-level loading states

Implementation details:
- All skeletons use global 'skeleton-pulse' animation (2s cycle)
- Dimensions match real content to prevent layout shift on arrival
- Marked with aria-hidden and role=presentation for accessibility
- Theme-aware colors using Fluent UI tokens
- Respects prefers-reduced-motion setting

Updates:
- ChartStateWrapper: Uses SkeletonChart instead of spinner
- PageFeedback: Added PageLoadingSkeleton component
- App.tsx: Injects skeleton styles at startup
- Web-Design.md: Added § 8a with loading UX guidance and usage examples

All components tested (22 tests, 100% passing) and linted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:40:10 +02:00
2fea513c9c docs: make provider dependency chain explicit with documentation and tests
This addresses issue #19 by making the implicit provider dependency order
explicit and order-sensitive.

Changes:
1. Created PROVIDER_ORDER.md - comprehensive documentation explaining:
   - The provider hierarchy from outermost to innermost
   - Why each provider must be at its position
   - Order-sensitive pitfalls and what would break
   - Guidelines for adding new providers in the future

2. Added provider composition tests (providerComposition.test.tsx):
   - 13 comprehensive tests validating provider order and dependencies
   - Tests verify all providers mount correctly
   - Tests check that hooks only work inside correct providers
   - Tests validate async initialization (AuthProvider, TimezoneProvider)
   - Tests verify theme persistence and notification propagation

3. Updated App.tsx with inline documentation:
   - Added detailed provider order contract in JSDoc header
   - Inline comments explaining each provider's position
   - Reference to PROVIDER_ORDER.md for detailed rationale

4. Updated Web-Development.md:
   - Added new section 5.5 'Provider Order Contract'
   - Documents provider hierarchy and rationale
   - Links to comprehensive provider documentation
   - References regression test suite

All tests pass. TypeScript compilation succeeds. Build succeeds.
The provider order is now explicit and future refactors can validate
compliance through the regression test suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:30:22 +02:00
d10145e5d6 refactor(frontend): extract shared fetch lifecycle into useFetchData base hook
Eliminates ~100 lines of duplicated code across useListData and usePolledData
by creating a composable base hook that handles:
- Abort controller lifecycle and cancellation
- Loading/error state management
- Fetch error handling
- Unmount cleanup

Changes:
- Create hooks/useFetchData.ts with base fetch lifecycle (no effects on consumers)
- Refactor useListData to compose useFetchData, returns items array by default
- Refactor usePolledData to compose useFetchData, adds polling and focus-refetch
- Add comprehensive tests for useFetchData base hook
- Document hook architecture and composition pattern in Web-Development.md

Result: Both hooks now use shared primitives, reducing maintenance burden
and ensuring consistent cancellation/error handling across all data fetches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:23:34 +02:00
5166789b68 feat: Implement typed error contracts in generic hooks
Introduce discriminated FetchError union type to replace weak string error
handling in API calls and hooks. Enables actionable error diagnostics.

Changes:
- Create types/api.ts with FetchError discriminated union (api_error,
  network_error, abort_error)
- Export type guards: isAuthError, isAbortError, isNetworkError, isApiError
- Update useListData and usePolledData to expose typed FetchError instead of
  string
- Add getErrorMessage() helper to extract displayable messages from FetchError
- Add createStringErrorAdapter() for backward compatibility with string error
  state
- Update handleFetchError() to work with both FetchError and string setters
- Update all consumer hooks to expose typed errors
- Update components to use getErrorMessage() when displaying errors
- Update tests to mock FetchError instead of strings
- Add comprehensive typed error model documentation to Web-Development.md

This enables better error handling patterns:
- Check error.type to distinguish between API, network, and abort errors
- Extract status codes for specific handling (401/403 auth, 50x server errors)
- Maintain backward compatibility with existing string-based error states

All TypeScript compilation passes with no errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 09:13:47 +02:00
6c8e2b3423 fix(#16): Establish consistent API usage layering patterns
- Refactor useActiveBans to use useListData generic hook instead of inline state management
- Refactor useBans to use useListData generic hook for consistency
- Add comprehensive 'API Usage Layering' section to Web-Development.md documenting:
  - Tier 1: API Functions (pure wrappers around HTTP calls)
  - Tier 2: Reusable Generic Hooks (useListData, useConfigItem for common patterns)
  - Tier 3: Domain Hooks (compose Tier 2 with domain-specific logic)
  - Tier 4: Components (receive data/actions via props or context)
- Document pattern for action callbacks with automatic data refresh
- List anti-patterns to avoid for future consistency

These changes improve composability, testability, and reduce code duplication by
establishing a clear convention for data-fetching patterns across the frontend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:53:36 +02:00
f169bbd39a test: fix BanUnbanForm tests with NotificationProvider wrapper
Include NotificationContainer in test setup so notification messages
appear in the DOM and can be found by tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:44:39 +02:00
ae34d98859 feat: centralized error notification service (issue #15)
- Create NotificationService with context provider for centralized error/success messaging
- Add NotificationContainer component to render notification stack
- Integrate NotificationProvider into App root
- Refactor BanUnbanForm to use notification service instead of local error state
- Update fetchError utility to optionally use notification callbacks
- Add comprehensive error handling guidelines to Web-Development.md
- Prevent duplicate notifications with deduplication logic
- Support auto-dismiss with configurable TTL per notification type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:41:33 +02:00
da6433b2cf Improve error boundary granularity with page and section level boundaries
Implement three-level error boundary strategy:
- Top-level (app shell): catches critical failures
- Page-level: preserves navigation when page crashes
- Section-level: graceful degradation for charts/tables

Create new components:
- PageErrorBoundary: wraps page routes
- SectionErrorBoundary: wraps data-heavy sections

Enhance ErrorBoundary with customizable titles, messages, and reload behavior.

Apply page boundaries to all route handlers in App.tsx.

Apply section boundaries to:
- DashboardPage: server status, ban trend, country charts, ban list
- JailsPage: jail overview, ban/unban form, IP lookup
- MapPage: world map, ban table
- ConfigPage: configuration editor
- HistoryPage: history table, IP detail view
- BlocklistsPage: sources, schedule, import log

Update Web-Development.md with error boundary strategy documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:33:39 +02:00
42beb9cf3b refactor: Decompose ConfigPage into focused routing and component layers
Split the over-centralized ConfigPage into focused, composable layers:

1. useTabRouter hook: Encapsulates tab state management and URL synchronization
   - Maintains selected tab and active item (e.g., jail name)
   - Syncs state to location.state for deep linking and browser history
   - Supports bookmarkable URLs and back/forward navigation

2. ConfigPageContainer: Orchestrates tab navigation
   - Manages TabList and routes tab selection events
   - Conditionally renders tab content panels
   - Delegates domain-specific logic to tab components

3. ConfigPage: Focused page layout component
   - Renders page structure (header, title, description)
   - Delegates tab orchestration to ConfigPageContainer
   - No routing or tab state logic

Benefits:
- Page is now 30 lines vs 125 lines (76% reduction)
- Tab state management is reusable for other multi-tab pages
- Each tab component remains focused on domain-specific UI
- Deep linking and browser history work out of the box
- Easier to test and maintain

Added comprehensive tests:
- useTabRouter: 6 tests covering state initialization, tab selection, and deep linking
- ConfigPageContainer: 8 tests covering tab rendering and navigation
- ConfigPage: 3 tests for page structure

Updated Web-Development.md with tab orchestration pattern documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:27:36 +02:00
69a5f0ceb1 refactor: eliminate prop drilling in JailsPage with context provider
Replace multi-hop prop forwarding with a dedicated JailContext that manages
jail state and actions. This reduces coupling, simplifies the component hierarchy,
and makes the data flow more explicit.

Changes:
- Create JailContext.tsx with JailProvider and useJailContext hook
- Wrap JailsPage content with JailProvider to expose jail state
- Refactor JailOverviewSection to use useJailContext instead of props
- Remove 10 props from JailOverviewSection component signature
- Add comprehensive documentation on state ownership and prop drilling

Benefits:
- Eliminates unnecessary prop chains through intermediate components
- Makes component contracts clearer (no longer need to pass unrelated props)
- Simplifies future refactoring of jail-related functionality
- Sets a pattern for other page-scoped state management

Testing:
- TypeScript type check passes (tsc --noEmit)
- Frontend builds successfully
- Existing JailsPage tests pass with new context structure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:20:29 +02:00
ace8930482 Update documentation
- Update Backend-Development.md with recent changes
- Update Tasks.md with current status

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:16:03 +02:00
e86ab6dad1 10) Implement explicit startup DAG for resource initialization
- Created StartupDAG class to orchestrate startup stages with explicit dependencies
- Defined 6 startup stages: WORKER_MODE → DATABASE → GEO_CACHE → HTTP_SESSION → SCHEDULER → TASKS
- Each stage has prerequisites, error handling, and rollback support
- Refactored startup_shared_resources() to use the DAG
- Added StartupContext for resource tracking and failure management
- Partial failures automatically roll back all completed resources in reverse order
- Added health checks to verify all resources initialized successfully
- Comprehensive test coverage: 15 DAG unit tests + 3 integration tests + 6 existing tests
- Documented startup DAG in Architekture.md with detailed stage descriptions and failure modes

This replaces implicit ordering with explicit dependency tracking, making lifecycle
changes safe and failure modes predictable. Hidden order dependencies no longer exist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 08:08:05 +02:00
a273b96563 feat: Complete repository protocol coverage
- Add missing protocol methods to Fail2BanDbRepository:
  - get_ban_event_counts: Aggregate ban events per IP (used in ban_service)

- Add missing protocol methods to GeoCacheRepository:
  - delete_stale_entries: Remove old geo cache entries (used in geo_cache_cleanup)

- Add missing protocol methods to HistoryArchiveRepository:
  - purge_archived_history: Remove archived entries older than age threshold

- Add comprehensive protocol compliance tests:
  - Created test_protocol_compliance.py with 8 test classes
  - Validates all 7 repository modules fully implement their protocols
  - Prevents silent protocol drift when methods change signatures
  - Tests verify no unexpected public methods in repository modules

- Update documentation:
  - Add Repository Protocol Coverage Checklist to Backend-Development.md
  - Document procedure for adding new repositories with protocol definitions
  - List current protocol coverage (all 7 repositories, 40 total methods)

- All repositories now have 100% protocol coverage:
  - SessionRepository: 4 methods
  - SettingsRepository: 4 methods
  - BlocklistRepository: 6 methods
  - ImportLogRepository: 4 methods
  - GeoCacheRepository: 13 methods
  - HistoryArchiveRepository: 5 methods
  - Fail2BanDbRepository: 8 methods

This ensures:
- Enhanced mockability for testing
- Static contract verification
- Prevention of protocol drift
- Better IDE support and type checking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 07:58:57 +02:00
52a4d04d92 Task 8: Standardize modeling style (TypedDict vs Pydantic)
Convert inconsistent modeling style to standardized Pydantic models for all
external-facing data structures while maintaining TypedDict compatibility where
appropriate for internal layer-private structures.

Changes:
- Converted IpLookupResult TypedDict to use IpLookupResponse Pydantic model
  in jail_service.lookup_ip() for consistency with routers
- Added GeoCacheEntry Pydantic model for geo cache repository rows
- Converted GeoCacheRow TypedDict to use GeoCacheEntry alias
- Converted ImportLogRow TypedDict to use ImportLogEntry alias
- Updated routers and services to work with Pydantic models
- Updated all tests to use Pydantic model field access (attributes)
  instead of dict subscripting

Documentation:
- Added 'Model Type Usage by Layer' section to Backend-Development.md
- Defines when TypedDict is allowed (internal structures) vs Pydantic
  (external-facing, cross-boundary data)
- Provides clear guidance on modeling conventions per layer

Benefits:
- Consistent validation and serialization behavior
- Better IDE support and type checking
- Clearer separation of concerns by layer
- Reduced maintenance cost from mixed validation approaches

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 07:53:30 +02:00
3888c5eb3f Refactor ban management with domain models and mappers
- Add ban domain model for core business logic separation
- Implement mapper pattern for DTO/domain conversions
- Update ban service with new domain-driven approach
- Refactor router endpoints to use new architecture
- Add comprehensive mapper tests
- Update documentation with architecture changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 07:46:02 +02:00
507f153ab9 Enforce repository boundary: Remove DbDep from routers
This commit enforces the repository boundary by eliminating direct database connection
dependencies (DbDep) from all routers. Routers now depend on service context dependencies
that combine the database connection with the related repositories.

Changes:
- Add 5 service context dependencies in dependencies.py:
  * SessionServiceContext: db + session_repo
  * BlocklistServiceContext: db + blocklist_repo + import_log_repo + settings_repo
  * SettingsServiceContext: db + settings_repo
  * BanServiceContext: db + fail2ban_db_repo
  * HistoryServiceContext: db + fail2ban_db_repo + history_archive_repo

- Refactor all 9 routers (auth, bans, blocklist, config_misc, dashboard, geo,
  history, jails, setup) to use service contexts instead of DbDep.

- Update Backend-Development.md with clear examples of the new pattern and
  documentation of available service contexts.

Rationale:
- Enforces the repository boundary through the dependency system
- Makes database operations explicit and auditable
- Improves testability by allowing service contexts to be mocked
- Prevents accidental direct database access from routers

The deprecated DbDep remains available for backward compatibility with
services that have not yet been refactored, but routers can no longer import it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 07:35:23 +02:00
813cf09bed Enforce repository boundary for persistence access
- Hide raw database connections (DbDep) from routers by removing from public exports
- Maintain DbDep as deprecated export for backward compatibility
- Add _DbDep internal dependency for use by other dependencies like require_auth
- Update module docstring to explain dependency layering rules
- Add comprehensive documentation section on dependency layering to Backend-Development.md

This enforces the architectural boundary where:
- Routers depend on repository dependencies (SessionRepoDep, BlocklistRepositoryDep, etc)
- Services orchestrate operations through repositories
- Only repositories execute SQL queries

The repository boundary is now technically enforced through the dependency injection
system, making it impossible for routers to accidentally bypass repositories and
access the database directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 19:04:52 +02:00
afc1e44e99 Implement centralized exception handling and validation
- Add custom exception classes for structured error handling
- Implement global exception handlers in FastAPI application
- Add comprehensive request/response validation
- Create exception contract tests for validation
- Update backend development documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 18:52:12 +02:00
2e221f6852 Refactor: Move module-level mutable flags to JailServiceState
TASK-004: Replace module-level mutable runtime flags in service layer with
injected state holder, eliminating hidden global state and improving testability
and synchronization boundaries.

Changes:
- Create JailServiceState dataclass in app/utils/runtime_state.py to hold
  backend capability cache and synchronization lock
- Add JailServiceState as a field in RuntimeState (with default_factory)
- Remove module-level _backend_cmd_supported and _backend_cmd_lock from
  jail_service.py
- Refactor _check_backend_cmd_supported() to accept state parameter
- Inject JailServiceState into list_jails() and _fetch_jail_summary() via
  parameters
- Add get_jail_service_state() dependency provider in app/dependencies.py
- Add JailServiceStateDep type alias for router injection
- Update jails router to receive and pass state to service functions
- Update all tests to use jail_service_state fixture and pass state to functions
- Remove duplicate _MAX_PAGE_SIZE constant definition
- Document mutable state management in Backend-Development.md
- Update Architecture.md to describe JailServiceState and state nesting pattern

Benefits:
- Eliminates global mutable state and associated race conditions
- Makes state visible to callers (not hidden in module scope)
- Enables test isolation (each test gets fresh state)
- Prepares codebase for multi-worker deployments (state can be extracted to
  shared backend)
- Synchronization boundaries are now explicit (state.get_backend_cmd_lock())

Compliance:
- All tests pass (17 passed in TestListJails, TestGetJail, TestLockInitialization)
- No ruff linting errors
- Type-safe: JailServiceState properly typed with asyncio.Lock, bool | None

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 18:42:52 +02:00
79112c0430 Remove completed task from documentation
Task #2 about hidden cross-service coupling has been resolved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 18:35:06 +02:00
e08a16c7dd Refactor: Split blocklist import flow into focused components
Extracted the monolithic import_source() function (776 lines) into focused,
testable components with clear single responsibilities:

- BlocklistDownloader: HTTP download with exponential backoff retry logic
  * Handles transient failures (429, 5xx errors, timeouts)
  * Configurable retry attempts and backoff strategy
  * 93% test coverage

- BlocklistParser: Parse and validate IP addresses
  * Extract valid IPv4/IPv6 addresses from text
  * Skip CIDRs and malformed entries gracefully
  * Separate parsing from validation concerns
  * 100% test coverage

- BanExecutor: Ban execution with error handling
  * Ban IPs via fail2ban socket
  * Stop on JailNotFoundError (jail doesn't exist)
  * Continue on JailOperationError (individual ban failures)
  * 100% test coverage

- BlocklistImportWorkflow: Thin orchestrator
  * Coordinates the download → parse → ban → log flow
  * Pre-warms geo cache with newly banned IPs
  * 96% test coverage

- blocklist_service.py: Maintains public API
  * Source CRUD (create, read, update, delete)
  * URL validation and preview functionality
  * Scheduling configuration and import triggers
  * 92% test coverage

Benefits:
* Each component is independently testable with mock dependencies
* Error handling is explicit and localized
* Components can evolve independently
* Logging is contextual and clear
* Retry and transient error handling are isolated

Testing:
* All 36 existing blocklist_service tests pass
* All 13 blocklist import task tests pass
* Added 17 comprehensive component unit tests
* Combined 96%+ coverage on new modules
* Zero type errors in new code

Documentation:
* Updated Refactoring.md with detailed architecture notes
* Added component architecture diagram to Architekture.md
* Documented ownership and responsibilities of each component

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 18:34:11 +02:00
3bbf413c55 refactor: Make service dependencies explicit and injectable
Remove hidden cross-service coupling by making dependencies explicit through
dependency injection while maintaining backward compatibility via lazy imports.

Key changes:
- history_service and ban_service: Removed direct module-level imports of
  fail2ban_metadata_service, added optional service parameters to functions
- Added get_fail2ban_metadata_service() provider to dependencies.py
- Updated history router to inject Fail2BanMetadataService dependency
- history_service functions now use lazy imports in fallback paths for
  backward compatibility when service is not explicitly injected
- All test patches updated to use internal _get_fail2ban_db_path() helper
- jail_config_service and jail_service already follow best practices

This pattern prevents circular imports, makes services testable via explicit
mocking, and documents service dependencies clearly.

Fixes: Instructions.md #2 - Hidden cross-service coupling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-27 18:26:08 +02:00
bc315b936b Refactor services and update documentation
- Refactor ban_service.py with improved error handling
- Refactor blocklist_service.py for better code organization
- Update geo_cache.py with performance improvements
- Update backend development guide and task documentation
- Update runner.csx script

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 20:27:04 +02:00
93021500c3 TASK-033: Remove session token from JSON response body
Fixes a critical security vulnerability where the session token was
being returned in the JSON response body of POST /api/auth/login.
This exposed the token to JavaScript, allowing malicious scripts to
steal it and bypass the HttpOnly cookie protection.

Changes:
- Backend: Remove 'token' field from LoginResponse model (auth.py)
- Backend: Update login() endpoint to return only 'expires_at'
- Frontend: Update LoginResponse type to exclude 'token' field
- Backend: Update test helper _login() to extract token from cookie
- Backend: Update test cases to verify token is NOT in response body
- Documentation: Add section 'Authentication Endpoints' in Backend-Development.md
- Documentation: Update Web-Development.md to explain HttpOnly cookie benefits

Security benefit: Session tokens are now only accessible via HttpOnly
cookies, protected from JavaScript access, XSS attacks, and malicious
third-party scripts. The frontend continues to use only the cookie for
authentication.

All auth tests pass (23 tests). Type checking and linting pass with
zero errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 19:38:33 +02:00
e2560f5db0 TASK-032: Implement geo_cache retention policy and cleanup
Add automatic cleanup of stale geolocation cache entries to prevent
unbounded database growth. Resolves the issue where unique IP addresses
accumulated indefinitely in the geo_cache table, degrading query performance.

## Changes

### Database Schema (Migration 3)
- Add 'last_seen' column to geo_cache table tracking last reference time
- Existing entries default to current timestamp

### Repository Layer (geo_cache_repo.py)
- Update upsert_entry() to set/refresh last_seen on insert/update
- Update upsert_neg_entry() to set/refresh last_seen on negative cache hits
- Update bulk_upsert_entries() to set/refresh last_seen in batch operations
- Add delete_stale_entries(db, cutoff_iso) -> int for purging old entries

### Background Task (geo_cache_cleanup.py)
- New APScheduler task that runs nightly (24-hour interval)
- Calculates cutoff as 90 days ago from current time (UTC)
- Deletes all entries with last_seen older than cutoff
- Logs operation results (info when deleted > 0, debug when 0 deleted)
- Configurable retention period via GEO_CACHE_RETENTION_DAYS constant

### Application Startup (startup.py)
- Register geo_cache_cleanup task in scheduler during app startup
- Placed after geo_cache_flush in task registration order

### Tests
- Add delete_stale_entries test cases covering:
  * Removal of old entries beyond cutoff
  * No deletion when all entries are recent
  * Empty table edge case
- Update existing test fixtures to include last_seen column
- Add full test suite for cleanup task registration and execution

### Documentation
- Architekture.md: Document cleanup task, update schema/diagram
- Backend-Development.md: Add retention policy documentation

## Behavior

When an IP is accessed, its last_seen is refreshed. After 90 days of no
access, an IP is purged by the nightly cleanup. On next encounter, the IP
is re-resolved from MaxMind MMDB or ip-api.com (if configured).

This is acceptable because:
1. Stale geolocation data may become inaccurate over time
2. Re-resolution cost is minimal compared to unbounded storage growth
3. Active IPs maintain fresh data through their last_seen updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 19:24:34 +02:00
32aad186c3 TASK-031: Enforce bcrypt 72-byte password limit
Bcrypt silently truncates passwords at 72 bytes, so passwords longer than 72
characters provide no additional security. This commit enforces the 72-byte
maximum across the authentication and setup flows.

Changes:
- Add max_length=72 to LoginRequest.password and SetupRequest.master_password
- Update field validator in SetupRequest to explicitly check max_length
- Add comprehensive tests for password length validation (6 new test cases)
- Document the 72-byte limitation in Features.md (master password options)
- Add new section 12 'Password Hashing' in Backend-Development.md explaining:
  - The bcrypt truncation behavior
  - Why the limit is enforced
  - The validation flow from frontend to backend
  - What happens when passwords exceed the limit

All existing tests pass, no regressions introduced.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:38:20 +02:00
1d91e24a88 TASK-030: Secure IP geolocation with MMDB-primary resolver
Make MaxMind GeoLite2-Country MMDB the primary IP resolver (local, encrypted)
and demote ip-api.com to optional fallback only (disabled by default).

Changes:
- Add geoip_allow_http_fallback config flag (default False) to Settings
- Refactor GeoCache.lookup() and lookup_batch() to try MMDB first
- Update startup.py to pass config flag and log security warning when HTTP enabled
- Update all 49 tests to reflect new MMDB-primary strategy
- Add comprehensive geoip configuration section to Backend-Development.md
- Update Architekture.md to show MMDB + optional HTTP in system dependencies
- Update .env.example with BANGUI_GEOIP_DB_PATH and HTTP fallback flag

Security impact:
- 99% of IP addresses (successful MMDB lookups) now stay local, encrypted
- HTTP-only IPs are cached for 5 minutes to minimize external calls
- Operators must explicitly enable HTTP fallback (security-conscious default)
- GDPR/CCPA compliance: no PII sent over unencrypted networks by default

Fixes TASK-030: Resolved plaintext IP transmission to ip-api.com

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:31:39 +02:00
b9289a3b0e Fix: Remove socket path leak in fail2ban error responses
- Change _fail2ban_connection_handler() to return generic message instead of
  leaking socket path in HTTP 502 response body
- Change _fail2ban_protocol_handler() to return generic message instead of
  leaking raw exception details in HTTP 502 response body
- Full error details are still logged server-side (error=str(exc)) for debugging
- Update Backend-Development.md with error message hygiene section explaining
  the pattern: generic user-friendly messages in HTTP responses, full details
  in server logs only

Fixes TASK-029: Fail2BanConnectionError leaks socket path in HTTP error responses

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:21:35 +02:00
5d24780c63 TASK-028: Add exception logging to fire-and-forget asyncio.create_task()
- Create logged_task() helper in backend/app/utils/async_utils.py to wrap
  fire-and-forget coroutines with exception logging
- Ensures unhandled task exceptions are always logged to structlog instead of
  silently discarded (Python 3.11+ RuntimeWarning)
- Update ban_service.py to use logged_task() for geo_cache.lookup_batch()
  background resolution
- Add comprehensive tests for logged_task() in test_async_utils.py
- Document fire-and-forget task conventions in Backend-Development.md

The logged_task() wrapper catches any exception raised in a background task,
logs it with full traceback context and task name, and never re-raises.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:17:30 +02:00
46fa7c78bc Update tasks documentation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:12:54 +02:00
57eacf39ba fix(security): Remove insecure session secret fallback in compose.debug.yml
TASK-027: The compose.debug.yml file had a publicly known weak session secret as
a fallback value. This has been replaced with an explicit requirement via the :?
bash parameter expansion pattern, forcing developers to set BANGUI_SESSION_SECRET.

Changes:
- Changed BANGUI_SESSION_SECRET fallback to use :? pattern with clear error message
- Created .env.example with placeholder values and generation instructions
- Added first-run setup instructions to Instructions.md
- Verified .env is already in .gitignore

The error message provides clear guidance:
'BANGUI_SESSION_SECRET must be set — generate with: python -c "import secrets; print(secrets.token_hex(32))"'

Severity: Medium
- Prevents exposure of session secret in repositories
- Ensures each environment has unique secrets
- Aligns with production compose.prod.yml which already uses this pattern

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:12:10 +02:00
df841c21e4 TASK-026: Disable API docs in production, protect with BANGUI_ENABLE_DOCS setting
Addresses security concern where FastAPI's default behavior exposes interactive
API documentation (/docs, /redoc) without authentication, allowing attackers to
enumerate endpoints and understand API schemas.

Changes:
- Add BANGUI_ENABLE_DOCS boolean setting (default: false) to Settings
- Modify create_app() to conditionally set docs_url, redoc_url, openapi_url
- Add docs endpoints to SetupRedirectMiddleware allowlist (/api/docs, /api/redoc, /api/openapi.json)
- Set BANGUI_ENABLE_DOCS=true in Docker/compose.debug.yml for development
- Production compose files leave it unset (defaults to false, docs disabled)
- Add comprehensive tests for docs configuration
- Document the new setting in Backend-Development.md

Security Impact:
- API documentation is now disabled by default in production
- Development environments can enable docs by setting BANGUI_ENABLE_DOCS=true
- Docs endpoints are inaccessible in production without manual configuration

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:09:51 +02:00
a768a2d303 TASK-025: Remove HMAC bypass in unwrap_session_token
- Remove the early-return branch that skipped HMAC verification for unsigned tokens
- Raise ValueError if the signature separator is absent
- Update unwrap_session_token docstring to reflect mandatory signing requirement
- Add comprehensive session token signing documentation to Backend-Development.md
- Document the session token format, signing/verification pattern, and security rationale

All tokens must now carry a valid HMAC-SHA256 signature. Tokens without a
signature are rejected immediately. This removes the vulnerability where an
attacker with database access could bypass the HMAC layer by using raw tokens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 15:02:02 +02:00
c2348d7075 Refactor backend architecture and update documentation
- Add CSRF protection middleware implementation
- Update API client with improved configuration
- Enhance documentation for backend development
- Add architecture documentation updates
- Reorganize and clean up task documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:52:23 +02:00
a44f1ef35b TASK-023: Make database migrations atomic
Replace non-atomic db.executescript() with explicit transaction control.
Wrap each migration's DDL statements and schema_migrations insert in a
single BEGIN IMMEDIATE ... COMMIT transaction to ensure atomicity.

Changes:
- Add _parse_migration_statements() to split migration scripts into
  individual statements while handling comments and string literals
- Update _apply_migration() to wrap all statements in a single explicit
  transaction with rollback on error
- Ensure _get_current_schema_version() uses execute() instead of
  executescript()
- Add 9 new tests for migration atomicity and statement parsing
- Update Backend-Development.md with migration authoring guidelines

If a crash occurs between DDL execution and schema_migrations insert,
the next startup will re-apply the entire migration atomically,
preventing partial migrations and data corruption.

Test coverage: 98% on db.py (up from 55%)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:40:27 +02:00
81f009e323 TASK-022: Hash session tokens in database for security
- Store session tokens as one-way SHA256 hashes instead of plaintext
- Hash tokens on write (create_session) and on read (get_session, delete_session)
- Add migration to drop plaintext sessions table and recreate with token_hash column
- Update Session model: token field still contains raw token for signing
- Add test to verify tokens are hashed in database, not plaintext
- Update Architekture.md to document session token hashing
- Update Backend-Development.md with implementation pattern and best practices

Prevents direct session token hijacking if database file is exposed to attacker.
If plaintext DB was readable, sessions are invalidated by the migration anyway.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:36:21 +02:00
5709785942 Remove completed TASK-020 from tasks list
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:28:30 +02:00
ec253d9b7a TASK-021: Implement atomic writes for set_jail_config_enabled and write_jail_config_file 2026-04-26 14:27:33 +02:00
d476e9d611 TASK-020: Fix log_target security vulnerability (defense in depth)
**Issue:**
- log_target accepted arbitrary paths, allowing authenticated users to write
  files as root via fail2ban (e.g., /etc/cron.d/bangui-pwned)
- fail2ban runs as root and opens files specified in log_target

**Solution:**
1. **Model layer validation:** Already existed in GlobalConfigUpdate, prevents
   invalid paths before reaching service
2. **Service layer validation:** Added defensive check in update_global_config()
   that validates log_target even if model validation is bypassed
3. **New validation helper:** Added validate_log_target() utility that accepts
   special values (STDOUT, STDERR, SYSLOG) or paths within allowed directories

**Changes:**
- app/utils/path_utils.py: Added validate_log_target() helper
- app/services/config_service.py: Added service-layer validation before
  sending command to fail2ban
- backend/tests: Fixed session_secret length issues in fixtures (min 32 chars)
- backend/tests: Added tests for valid special log targets
- Docs/Backend-Development.md: Documented log_target security requirements

**Test Coverage:**
- Model validation rejects /etc/passwd (existing test)
- Model validation accepts STDOUT, STDERR, SYSLOG special values
- Model validation accepts paths in allowed directories
- Service layer validation tested with special values

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:23:56 +02:00
d9022b9d06 Refactor config and add comprehensive tests
- Updated config.py to support environment-based configuration
- Added test_config.py with full test coverage
- Updated Backend-Development.md with configuration documentation
- Removed outdated tasks from Tasks.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:14:35 +02:00
4ceb11a4e3 TASK-018: Make config file writes atomic using write-to-temp + rename
- Replace Path.write_text() with tempfile.NamedTemporaryFile + os.replace()
  in _write_conf_file() and _create_conf_file()
- Ensures atomic operations on same filesystem (temp file in target.parent)
- Prevents config corruption if process is killed mid-write
- Follows existing pattern in jail_config_service.py
- Add proper cleanup of temp files on error with contextlib.suppress()
- Document atomic file write conventions in Backend-Development.md

This prevents fail2ban config files (especially jail.d/*.conf) from being
left in a truncated or corrupt state, which could disable active protection.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:11:18 +02:00
b6e8e3f5ff Clean up unused imports and remove completed task
- Remove TASK-016 from Docs/Tasks.md (completed)
- Remove unused imports from protocols.py (Iterable, BanIpCount)
- Remove unused imports from raw_config_io_service.py (asyncio, ConfigDirError, ConfigFileExistsError, ConfFileEntry)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:08:43 +02:00
667ab674ca Fix SQLite LIKE wildcard escaping in IP filter queries
- Add escape_like() helper to escape % and _ wildcards in LIKE queries
- Update fail2ban_db_repo.get_history_page() to use escaping
- Update history_archive_repo.get_archived_history() to use escaping
- Add ESCAPE clause to all LIKE queries
- Add comprehensive unit tests for escape_like function
- Add integration tests for LIKE wildcard handling
- Document LIKE escaping best practices in Backend-Development.md

Fixes TASK-017: Prevent unintended LIKE matches when IP filter contains
special characters like underscore or percent sign.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:07:49 +02:00
94bdabe622 TASK-016: Validate delete_log_path query parameter with allowlist
- Extract path validation logic into shared helper function in
  backend/app/utils/path_utils.py (validate_log_path)
- Refactor AddLogPathRequest to use the helper function
- Apply the same validation to DELETE /api/config/jails/{name}/logpath
  endpoint by validating the log_path query parameter
- Return HTTP 422 with descriptive error if validation fails
- Add comprehensive unit tests for path validation
- Update Backend-Development.md with usage examples

This prevents path-traversal attacks on the delete_log_path endpoint
by ensuring all log paths are within allowlisted directories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 14:04:21 +02:00
d66493f135 TASK-015: Add validation for GlobalConfigUpdate.log_target and log_level
- Add LogLevel Literal type: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG
- Add log_target validation to accept special values (STDOUT, STDERR, SYSLOG)
  or validated file paths within allowed directories
- Update GlobalConfigResponse to use LogLevel type
- Add field_validator for log_target in both GlobalConfigUpdate and
  GlobalConfigResponse following the same pattern as AddLogPathRequest
- Add @autouse fixture to test_config_service.py to mock get_settings
- Update existing tests to use uppercase log level values
- Add 12 comprehensive tests for new validation in test_models.py
- Update Features.md to document valid log_target and log_level values
- Add section to Backend-Development.md documenting Literal types and
  field_validator patterns with examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:57:22 +02:00
b9e046bd66 Update task documentation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:49:52 +02:00
308cf680a7 TASK-014: Add log path validation to prevent arbitrary file access
Restrict monitored log paths to a configurable allowlist of safe directories
to prevent authenticated users from instructing fail2ban to monitor arbitrary
files on the system, which could leak contents via fail2ban logging.

Changes:
- Add 'allowed_log_dirs' setting to Settings (defaults to /var/log, /config/log)
- Add @field_validator to AddLogPathRequest to validate log paths at request time
- Validator resolves paths to canonical form and checks against allowed prefixes
- Use Path.is_relative_to() to prevent prefix bypass attacks like /var/log_evil
- Add comprehensive tests for valid/invalid paths and symlink handling
- Update Features.md and Backend-Development.md with security documentation

Security improvements:
- Blocks access to sensitive files (/etc/shadow, /etc/passwd, etc.)
- Resolves symlinks before validation to prevent escape routes
- Uses proper path comparison instead of string prefix matching
- Configurable via BANGUI_ALLOWED_LOG_DIRS environment variable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:49:04 +02:00
2331567bd7 Remove completed TASK-012 from task list
TASK-012 (SetupGuard duplicate API calls) has been resolved and is no longer
relevant to track in the tasks document.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:40:48 +02:00
5d9cef7760 TASK-013: Add nginx security headers (CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
- Added OWASP-recommended security headers to nginx server block
- CSP allows same-origin scripts and inline styles (required for Fluent UI v9)
- X-Frame-Options: DENY prevents clickjacking
- X-Content-Type-Options: nosniff prevents MIME-sniffing
- Referrer-Policy: no-referrer prevents URL leakage
- Permissions-Policy: disables geolocation, microphone, camera APIs
- HSTS commented out until HTTPS is fully configured
- All headers use 'always' directive for error responses (4xx, 5xx)
- Updated Architekture.md with security header documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:35:15 +02:00
3095fa3313 fix(frontend): deduplicate setup status API calls using shared hook
Implement request deduplication to prevent multiple duplicate calls to GET
/api/setup when multiple components mount simultaneously. The fix introduces:

1. New 'useSharedSetupStatus' hook with module-level caching
   - Shares a single in-flight request across all consumers
   - Implements 30-second cache TTL with cache invalidation
   - Notifies all subscribers when cache is invalidated

2. Refactored 'useSetup' hook to use shared cache
   - Internally uses useSharedSetupStatus for status checks
   - Calls invalidateSetupStatus() after successful setup submission
   - Maintains backward-compatible API

3. Updated components using setup status
   - SetupGuard and SetupPage automatically benefit from deduplication
   - No changes needed to consumer code

4. Updated tests
   - Mocked useSharedSetupStatus in component tests
   - Added comprehensive tests for cache behavior
   - All existing tests pass

5. Documentation updates
   - Added 'Request Deduplication & Shared Caching' section to Web-Development.md
   - Explains when and how to use shared hooks
   - Provides complete implementation example

This eliminates wasted resources from duplicate API calls and potential
race conditions where different requests return slightly different states.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:32:44 +02:00
5b24a9c142 TASK-011: Remove session token prefix from log output
Replace sensitive token fragments in structured logs with:
- login(): Use session_id=session.id (database row ID) instead of token_prefix
- logout(): Use token_hash (SHA256 one-way hash, first 12 chars) instead of token_prefix

This prevents partial token material leakage into log aggregation systems while
maintaining useful session correlation via hashed tokens or database IDs.

Also updated Backend-Development.md to clarify logging conventions for
sensitive data handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:19:26 +02:00
8698b89f6a TASK-010: Replace .split() with shlex.split() for fail2ban_start_command
- Add @field_validator for fail2ban_start_command to validate with shlex.split()
  at startup, catching misconfigured commands with mismatched quotes
- Replace .split() with shlex.split() in jail_config.py line 450
- Replace .split() with shlex.split() in config_misc.py line 154
- Update Backend-Development.md with configuration documentation explaining
  quoted path handling and common pitfalls
- Add comprehensive test suite (8 tests) covering valid commands, quoted paths,
  and mismatched quote errors

This fix ensures commands like '/opt/my tools/fail2ban-client' start are
correctly parsed as two tokens instead of three, preventing execution failures
when the path contains spaces.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:04:14 +02:00
4ab767e3d4 TASK-009: Mitigate SSRF vulnerability in blocklist URL validation
- Change BlocklistSourceCreate.url from str to AnyHttpUrl (Pydantic type)
  - Rejects non-http schemes (file://, ftp://, etc.) at model boundary

- Add is_private_ip() utility to detect RFC 1918 private ranges:
  - 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (RFC 1918)
  - 127.0.0.0/8, ::1/128 (loopback)
  - 169.254.0.0/16, fe80::/10 (link-local)
  - IPv6 site-local, multicast, and reserved ranges

- Add async validate_blocklist_url() function:
  - Resolves hostname via DNS using loop.run_in_executor()
  - Rejects if hostname resolves to private/reserved IP
  - Raises ValueError on validation failure

- Integrate validation into service layer:
  - create_source() calls validate_blocklist_url() before persist
  - update_source() conditionally validates if url provided
  - Both raise ValueError on failure

- Update router endpoints with error handling:
  - create_blocklist() and update_blocklist() catch ValueError
  - Return HTTP 400 Bad Request with descriptive error message

- Add comprehensive test coverage (9 new SSRF tests):
  - file://, ftp://, localhost, 127.0.0.1, 192.168.x.x
  - 10.x.x.x, 172.16.x.x, 169.254.x.x (link-local)
  - Valid public URLs (passes validation)
  - All 36 service tests passing

- Update documentation:
  - Features.md: Document URL validation constraints
  - Backend-Development.md: Add SSRF prevention pattern section

Fixes SSRF vulnerability where authenticated users could supply
file://, ftp://, or private IP URLs and the backend would fetch them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 12:57:23 +02:00
a5b55d1248 Add session cleanup task and update documentation
- Implement session_cleanup task for removing expired sessions
- Add comprehensive tests for session cleanup functionality
- Update architecture and task documentation
- Integrate cleanup task into application startup

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 12:49:13 +02:00
ea4c7c2f85 Implement login endpoint rate limiting (TASK-007)
- Add in-memory rate limiter with per-IP deque tracking of attempt timestamps
- Limit login attempts to 5 per 60 seconds per IP, return 429 on excess
- Add Retry-After header to rate limit responses
- Implement IP extraction utility with proxy trust validation (prevent X-Forwarded-For spoofing)
- Integrate rate limiter into auth router and dependencies
- Add 10-second asyncio.sleep on failed login attempts to further slow brute-force
- Add comprehensive tests for rate limiting (9 new tests, all passing)
- Update Features.md to document login rate limiting
- Update Backend-Development.md with rate limiting conventions and design patterns
- Fix test infrastructure issues: update password to meet complexity requirements
- Fix TestValidateSession tests to use Bearer token authentication
- All tests passing: 23 auth tests + full test suite coverage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 12:40:52 +02:00
9725714aa2 docs: document nginx routing rules to prevent SPA fallback hiding API 404s
TASK-006: Document the nginx routing configuration that ensures API requests
returning 404 from FastAPI are not intercepted by the SPA wildcard fallback
rule. This prevents development bugs from being masked by 200 responses
containing HTML instead of 404 errors.

Added section 9.2 in Architekture.md covering:
- nginx location block priority (longest-prefix matching)
- Routing configuration for /api/, /assets/, and /
- Detailed routing behavior diagrams
- Critical implementation notes to prevent regressions

The current nginx.conf is already correct:
- /api/ location has no try_files and proxies directly to backend
- /assets/ location uses try_files with =404
- / catch-all uses SPA fallback to index.html

This ensures:
✓ API typos like /api/jailss return 404, not SPA HTML
✓ Frontend routes serve SPA HTML for client-side routing
✓ Static assets properly return 404 when missing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 12:10:21 +02:00
f55c317f87 Backend refactoring updates
- Update Docker compose debug configuration
- Update backend documentation
- Update tasks documentation
- Update backend config module

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 12:05:01 +02:00
29daaa9906 TASK-004: Bootstrap frontend auth state from backend session check
Validates session on app mount by calling GET /api/auth/session instead of relying
solely on cached sessionStorage. This ensures the UI state always reflects server
reality — expired or revoked sessions are detected immediately.

Changes:
- Backend: Add GET /api/auth/session endpoint (requires valid session, returns 200/401)
- Frontend: Add useSessionValidation hook for mount-time validation
- Frontend: Add SessionValidationLoading component for validation spinner
- Frontend: Update AuthProvider to call validation on mount with loading state
- Frontend: Add validateSession API function
- Docs: Update Features.md with session validation behavior
- Docs: Update Web-Development.md with session validation pattern

Handles three outcomes:
1. Valid session (200): Proceed with cached state
2. Invalid session (401): Clear sessionStorage and redirect to login
3. Network error: Don't logout (backend may be temporarily unreachable)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 12:00:21 +02:00
d982fe3efc TASK-003: Document process-local constraint for RuntimeState and SessionCache
- Add comprehensive docstring to runtime_state.py explaining single-process
  constraint, impacts in multi-worker deployments, and solution approach
- Add comprehensive docstring to session_cache.py explaining process-local
  cache limitation, security implications, and Redis/database alternatives
- Update Architecture.md to clarify session cache is process-local and
  describe single-worker enforcement via TASK-002
- Update Architecture.md runtime state section with detailed explanation of
  per-process state and multi-worker impacts
- Add Backend-Development.md section 13.7.2 documenting session cache
  pluggability pattern with example Redis implementation
- All tests pass; linting passes; type checking has pre-existing errors

This is the short-term fix for TASK-003: enforce single-worker deployment
(TASK-002) and document the constraint clearly. The long-term fix (Redis
backend) is deferred as a follow-up.
2026-04-26 11:43:34 +02:00
825a67f13a Add multi-worker detection for APScheduler safety
- Add _check_single_worker_mode() to startup.py that detects and rejects
  multi-worker configurations, raising a clear RuntimeError with instructions
- Set BANGUI_WORKERS=1 as default in Dockerfile.backend
- Document single-worker requirement in compose.prod.yml
- Add 'Deployment Constraints' section to Architekture.md explaining why
  single-worker mode is required and detailing future multi-worker support
- Add '9.1 Background Tasks and Scheduler Architecture' section to
  Backend-Development.md documenting task structure and single-worker requirement
- Add comprehensive test suite (test_startup.py) covering all scenarios:
  allows single worker, rejects multi-worker, validates config format,
  and verifies informative error messages

This fix addresses TASK-002 which identified that in-process APScheduler is
unsafe in multi-worker deployments due to each worker creating independent
scheduler instances, causing duplicate background job execution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 11:39:51 +02:00
def412797a Remove completed task T-20 from documentation
Removed the completed task about replacing inline style objects with makeStyles classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:50:48 +02:00
045c8048fe Refactor: Replace inline style objects with makeStyles classes
Moved all static layout properties (display, gap, margin, padding, colour)
from inline style props to makeStyles classes in:

- MapBansTable.tsx: Pagination row flexbox layout
- JailDetailPage.tsx: Link styling for textDecoration
- HistoryPage.tsx: Summary text styling
- IpDetailView.tsx: Loading container and text formatting

Kept inline styles only for genuinely dynamic values:
- WorldMap.tsx: Tooltip position (follows mouse)
- TopCountriesPieChart.tsx: Legend color (from recharts data)
- TopCountriesBarChart.tsx: Chart height (derives from data length)

This change improves performance by leveraging Griffel's atomic CSS cache
and ensures consistency with the established Fluent UI pattern.

Updated Docs/Web-Development.md with explicit rule: inline styles only
for runtime-dynamic values, all static properties go in makeStyles.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:48:25 +02:00
c1135150c3 Refactor: Move DashboardFilterProvider to pages directory
- Move DashboardFilterProvider component and tests from providers/ to pages/
- Update DashboardPage imports to reflect new structure
- Update documentation with latest task progress

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:45:10 +02:00
69a0296c47 T-18: Merge useDashboardCountryData and useMapData into shared base hook
Create useBansByCountry as the shared base hook containing all common
fetch logic, abort-controller pattern, and state management. Both
useDashboardCountryData and useMapData now wrap this base hook:

- useDashboardCountryData: Thin wrapper that calls base hook with autoFetch=true
- useMapData: Wraps base hook with 300ms debounce layer

Changes:
- Create useBansByCountry.ts (base hook with optional autoFetch parameter)
- Refactor useDashboardCountryData.ts to use base hook
- Refactor useMapData.ts to use base hook with debounce wrapper
- Add tests for all three hooks

Benefits:
- Single source of truth for ban-by-country logic
- Bug fixes in base hook apply to both consumers
- Eliminates code duplication (~80 lines reduced)
- Maintains backward compatibility: existing call sites work unchanged

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:39:51 +02:00
3b527244aa Update task documentation and test fixes
- Update Tasks.md with current progress and status
- Fix useListData hook tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:34:16 +02:00
6490e9d3df T-16: Centralize PAGE_SIZE frontend constants
- Add BAN_PAGE_SIZE (100) and HISTORY_PAGE_SIZE (50) to frontend/src/utils/constants.ts
- Replace local PAGE_SIZE definitions in useBans.ts and HistoryPage.tsx with imports
- Eliminates risk of pagination constants silently diverging from backend defaults
- Single source of truth for all pagination sizes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:27:24 +02:00
f84aeef249 Refactor authentication logic and API client
- Update AuthProvider with improved error handling and token management
- Enhance API client with better request/response handling
- Add comprehensive test coverage for auth flows
- Update documentation with current tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:23:12 +02:00
6a062a72a7 refactor: move jail detail sub-sections from pages/jail to components/jail
Move reusable UI section components (JailInfoSection, PatternsSection,
BantimeEscalationSection, IgnoreListSection, CodeList) from pages/jail/
to components/jail/, aligning with the project convention that pages/
contains only route-level entry points while components/ contains reusable
UI building blocks.

Changes:
- Move 5 section components + jailDetailPageStyles.ts to components/jail/
- Update import paths in moved components (relative paths to commonStyles)
- Update JailDetailPage.tsx imports to reference components/jail/
- Delete empty pages/jail/ directory
- Document pages/ vs components/ distinction in Web-Development.md

All components use standard import structure and TypeScript passes type
checking. BannedIpsSection was already correctly placed in components/jail/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:17:03 +02:00
8bd5713d38 Refactor jail detail hooks: split into useJailData and useJailCommands
- Split monolithic useJailDetail hook into separate concerns
- Created useJailData for fetching and managing jail data
- Created useJailCommands for jail operations (power, console, etc.)
- Updated JailDetailPage to use new hooks
- Updated tests to reflect new hook structure
- Removed old useJailDetail hook

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:14:16 +02:00
8d30a81346 feat(hooks): consolidate data-fetching patterns with useListData and usePolledData
- Refactor useJails (useJailList.ts) to use useListData with onSuccess for total
- Refactor useBanTrend to use useListData with onSuccess for bucket_size
- Refactor useDashboardCountryData to use useListData with onSuccess for aggregated data
- Refactor useHistory to use useListData with proper abort guard in finally()
- Create usePolledData for single-item endpoints with polling and window focus refetch
- Refactor useServerStatus to use usePolledData for 30s polling + window focus refetch
- Keep useIpHistory with manual pattern (single-item, no list semantics)
- Document deferred refactoring of useJailDetail (depends on T-13 for data/command split)

All data-fetching hooks now follow one of two consistent patterns:
1. useListData: for paginated/list endpoints with refresh semantics
2. usePolledData: for single-item endpoints with polling and focus-refetch

This eliminates code duplication, centralizes abort-guard logic, and enables
consistent fixes across all data-fetching hooks.

Resolves T-12.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 19:08:26 +02:00
b44b72053a T-11: Validate repository Protocol structural compatibility — minimal approach (Option B)
Problem: Repository modules use structural typing to satisfy Protocol interfaces via
cast(). A function rename, parameter change, or signature mismatch would silently pass
mypy but fail at runtime.

Solution (Option B — minimal):
1. Aligned Protocol signatures in protocols.py with actual implementations:
   - BlocklistRepository: dict[str, object] → dict[str, Any] (matches implementation)
   - ImportLogRepository: dict[str, object] → ImportLogRow (typed model)
   - GeoCacheRepository: dict[str, object] → GeoCacheRow; Iterable → Sequence
   - HistoryArchiveRepository: dict[str, object] → dict[str, Any]
   - ImportLogRepository: async compute_total_pages → sync (matches implementation)

2. Created CI validation script (backend/scripts/validate_repository_protocols.py)
   that runs at build time to ensure all repository modules satisfy their Protocol
   interfaces. Exit 0 if valid, 1 if any mismatch. Detects:
   - Missing functions
   - Parameter count mismatches
   - Type annotation mismatches
   - Return type mismatches

3. Updated backend/app/dependencies.py with explicit docstrings linking each
   get_*_repo() provider to Backend-Development.md § 13.7.1, explaining the
   module-as-Protocol pattern and that it is intentional and validated.

4. Documented the pattern in Backend-Development.md § 13.7.1:
   'Repository Module Pattern — Module-as-Protocol Structural Compatibility'
   explaining why the pattern works, risks (silent breakage), and how the
   validation mitigates it.

5. Fixed type annotation in history_archive_repo.py:
   - get_all_archived_history returns list[dict] → list[dict[str, Any]]
   - Imported Any type

Benefits:
- Prevents silent breakage of repository interfaces
- Formalizes the module-as-Protocol pattern as intentional
- CI validation prevents regressions without refactoring cost
- All repository tests pass (53/53)
- mypy --strict passes on modified files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:59:49 +02:00
4b8af1d43a Fix import formatting and sorting
Ruff formatting fixes for import organization.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:54:57 +02:00
1a3401f418 T-10: Fix get_geo_batch_lookup for proper injection with GeoCache instance
Instead of returning a bound method (geo_cache.lookup_batch), now inject
the GeoCache instance directly into routers and services. This provides
proper runtime isolation since T-04 made GeoCache a proper object.

Changes:
- Remove get_geo_batch_lookup() dependency provider
- Add GeoCacheDep type alias for injecting GeoCache instances
- Update all routers (bans, blocklist, dashboard, jails) to use GeoCacheDep
- Update ban_service, blocklist_service, jail_service to accept GeoCache
- Update service protocols to match new signatures
- Update docstrings to reference GeoCache methods instead of module functions

All callers now call geo_cache.lookup_batch(...) directly instead of
geo_batch_lookup(...), providing real dependency injection with proper
testing isolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:53:47 +02:00
ac2028e1c2 Fix: Consolidate divergent _since_unix implementations (T-09)
Consolidate the two divergent implementations of _since_unix from ban_service.py
and history_service.py into a single shared utility function in time_utils.py.

Changes:
- Move _since_unix to app/utils/time_utils.py with consistent time.time() approach
- Move TIME_RANGE_SLACK_SECONDS constant to app/utils/constants.py
- Update ban_service.py to import since_unix from time_utils
- Update history_service.py to import since_unix from time_utils
- Both services now use the same window boundary calculation with 60-second slack
- Add comprehensive tests for the shared since_unix function
- Document timestamp handling rationale in Backend-Development.md

This ensures dashboard and history queries return consistent row counts for the
same time range by using the same timestamp calculation and slack window across
all services.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:44:59 +02:00
420ea18fd9 Refactor backend services and utilities
- Update service layer implementations
- Improve configuration handling utilities
- Update documentation tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:39:30 +02:00
83452ffc23 Refactor backend services and jail configuration
- Refactor action_config_service, filter_config_service, jail_config_service, and jail_service
- Add jail_socket utility module for socket communication
- Update test_jail_service with new test cases
- Update architecture and task documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:34:03 +02:00
c3410bd554 Update Tasks documentation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:26:40 +02:00
24f9fdd358 T-06: Remove AppState Protocol, use ApplicationState directly
The AppState Protocol (lines 42-54) and ApplicationContext dataclass
(lines 57-69) described identical fields, creating maintenance burden.
The Protocol was only used for a single cast() in _build_app_context.

Changes:
- Remove AppState Protocol class
- Import ApplicationState from runtime_state.py
- Replace cast('AppState', request.app.state) with
  cast(ApplicationState, request.app.state)
- Remove unused Protocol import

This eliminates the redundancy while maintaining the same typing
guarantees. request.app.state is set to ApplicationState instances
during app initialization in main.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:25:56 +02:00
e57d19fd76 T-05: Remove app.state mutation from _build_app_context
Move session cache initialization from per-request _build_app_context to
startup lifespan handler. The session cache type is now decided once at app
startup based on settings, making _build_app_context pure (read-only).

Changes:
- Move cache initialization logic to new _update_session_cache() in main.py
- Call _update_session_cache() during lifespan startup to initialize cache
- Remove three if/elif/elif branches mutating state.session_cache from _build_app_context
- Add cache swap logic to set_runtime_settings() in runtime_state.py to handle
  runtime settings changes (e.g., setup wizard updates)
- Keep app.state.session_cache initialization in create_app() for test compatibility

This ensures:
- _build_app_context is pure and doesn't mutate app state on each request
- Session cache configuration decisions are centralized at startup
- Settings changes during runtime (via setup wizard) also trigger cache swap
- Cache initialization logic is isolated in one place

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:23:08 +02:00
d467190eb1 Refactor geo caching and service layer tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:15:31 +02:00
654dbdb000 T-04: Encapsulate geo_service module-level mutable state in GeoCache class
Create GeoCache class with all mutable state as instance attributes:
- _cache, _neg_cache, _dirty, _geoip_reader, _geoip_initialized, _cache_lock
- All public methods: lookup(), lookup_batch(), lookup_cached_only(), flush_dirty(), load_from_db(), clear(), etc.

Initialization & Dependency Injection:
- Instantiate GeoCache in startup.py and store on app.state.geo_cache
- Add get_geo_cache() dependency function in dependencies.py
- Inject into routes and tasks via FastAPI's dependency system

Backward Compatibility:
- Maintain module-level functions in geo_service.py as deprecated wrappers
- All old callers continue to work through _default_geo_cache instance
- Remove test-escape-hatch functions (clear_cache, clear_neg_cache moved to methods)

Background Tasks:
- Update geo_cache_flush.py and geo_re_resolve.py to receive GeoCache instance
- Tasks now operate on injected instance rather than module globals

Tests:
- Refactor test_geo_service.py with geo_cache fixture providing fresh instances
- Update patch paths to target GeoCache methods correctly
- Fix internal state assertions to access instance attributes

Documentation:
- Update Architekture.md to document GeoCache as managed stateful service
- Describe cache lifecycle (load on startup, flush periodically, re-resolve stale)
- Note process-local limitations for multi-worker deployments

Fixes violation of Single Responsibility Principle: module no longer owns both
lookup logic and cache lifecycle management. Cache is now a first-class
injectable service with transparent lifecycle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 16:18:09 +02:00
fdfd24508f Refactor backend services and routers
- Reorganized dashboard router with improved structure
- Enhanced ban_service with better separation of concerns
- Updated history service with cleaner logic
- Improved constants and configuration handling
- Updated documentation of completed tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 16:06:10 +02:00
fd685e8211 refactor: Remove unused HTTPException imports from routers
After removing all try/except blocks that used HTTPException for domain
exception conversion, these imports are no longer needed in the routers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 16:01:44 +02:00
5480dce221 refactor: Remove duplicate router-level exception helpers
All routers now let domain exceptions propagate to the global handlers in main.py
instead of catching and converting them to HTTPException. This eliminates:

- Duplicate exception-to-HTTP-status mappings across 8 routers
- Duplicate helper functions (_bad_gateway, _not_found, _conflict, etc.)
- Inconsistent error response formats

Changes:
- Removed all try/except blocks from routers that catch domain exceptions
- Removed duplicate helper functions from all routers
- Added missing exception handlers to main.py for:
  * ActionNameError
  * FilterNameError
  * JailNameError
  * JailNotFoundInConfigError
  * FilterInvalidRegexError
- Removed unused imports from affected routers

All domain exceptions now propagate to the single authoritative mapping in
main.py, ensuring consistent error codes, messages, and logging across the API.

Affected routers:
- action_config.py: Removed _action_not_found, _bad_request, _not_found helpers
- bans.py: Removed try/except in ban/unban endpoints
- config_misc.py: Removed try/except blocks
- file_config.py: Removed 6 try/except blocks and _service_unavailable helper
- filter_config.py: Removed try/except blocks
- geo.py: Removed try/except in lookup_ip endpoint
- jail_config.py: Removed try/except blocks
- jails.py: Removed try/except blocks
- server.py: Removed try/except blocks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 16:00:37 +02:00
b634ce876a refactor: Extract fail2ban response utilities into shared module
Consolidate duplicate _ok(), _to_dict(), ensure_list(), and is_not_found_error()
functions from 6 service modules into a single canonical implementation at
backend/app/utils/fail2ban_response.py.

Changes:
- Create fail2ban_response.py with canonical implementations
- Remove local duplicates from: ban_service, jail_service, config_service,
  health_service, server_service, config_file_utils
- Update all imports to use shared module
- Add comprehensive docstrings and examples
- Update Architecture.md and Backend-Development.md documentation

Benefits:
- Single source of truth for response parsing logic
- Eliminates code duplication across service layer
- Improves maintainability and consistency
- Enables centralized bug fixes and improvements

Tests: All 228 service tests passing, no regressions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 15:11:21 +02:00
6d21a53620 Update tasks and history page tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:59:21 +02:00
eaff272aae feat: Add dismissible warning UI for threshold loading errors
- Replace console.warn with visible MessageBar warning when map color thresholds fail to load
- Add DismissRegular icon button to allow users to dismiss the warning
- Add dismissedThresholdWarning state to manage warning visibility
- Add mock and test for useMapColorThresholds hook
- Add test case verifying warning displays and can be dismissed
- Remove TASK-QUALITY-04 from Tasks.md (completed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:58:27 +02:00
ac44bab8e6 Update documentation and refactor useAutoSave hook
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:52:22 +02:00
9c5757eeb0 Refactor useHistory hook: replace HistoryQuery with explicit parameters and add documentation
- Split useHistory interface to accept explicit parameters (page, pageSize, range, origin, jail, ip, source) instead of HistoryQuery object
- Add comprehensive JSDoc for useHistory function
- Update HistoryPage and tests to use new parameter structure
- Move TaskList documentation from Tasks.md to Web-Development.md
- Improve type safety with explicit TimeRange and BanOriginFilter types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:51:16 +02:00
8904e180d1 Fix: Prevent session-expiry errors from briefly showing in useConfigItem.save()
When save() encounters a 401 or 403 error, the HTTP client dispatches
SESSION_EXPIRED_EVENT which triggers auth handling and navigation to login.
However, setSaveError was called first, causing a brief flash of an
'Unauthorized' message before the redirect.

Now, isAuthError(err) checks if the error is a 401/403 before setting
saveError. Auth errors are rethrown without setting error state, allowing
the auth handler to deal with session expiry cleanly without UX confusion.

- Import isAuthError from api/client in useConfigItem hook
- Check for auth errors in the save() catch block before setSaveError
- Add tests for 401 and 403 error handling

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:45:11 +02:00
6d5be523ab fix: KVEditor effect dependency uses stable JSON serialization
Replace the flawed join-based comparison (entryKeys.join(',')) with
JSON.stringify() to properly handle keys containing commas. The previous
implementation could produce false equality when different key sets
shared the same comma-separated representation (e.g., 'a,b' key vs
separate 'a' and 'b' keys).

This ensures the effect fires correctly when keys change, fixing silent
failures to update derived state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:41:49 +02:00
9375430e02 Refactor schedule functionality in frontend
- Extract schedule logic into custom useSchedule hook
- Update BlocklistScheduleSection to use the new hook
- Add tests for useSchedule hook
- Update documentation with task progress

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:40:19 +02:00
a87d892584 Update Tasks documentation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:34:02 +02:00
f3d6574160 Fix misleading auth token storage in sessionStorage
- Remove JWT token and expires_at from sessionStorage
- Simplify AuthProvider to use boolean isAuthenticated flag
- Persist only isAuthenticated boolean for page-reload continuity
- Update AuthProvider test to verify new auth model
- Add comprehensive auth documentation to Web-Development.md explaining:
  - Cookie-based authentication model
  - How frontend auth state persists
  - Why tokens are no longer stored
  - Error handling flow for 401/403 responses

The authentication model is cookie-based: the backend sets bangui_session
cookie on login, frontend automatically includes it via credentials:
'include', and the backend is the sole authority on session validity.
Previously stored tokens were never actually used and made the auth model
misleading during development.

Fixes TASK-STATE-04.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:30:29 +02:00
941502b710 Fix BanTable to use props exclusively (complete dual state source refactoring)
- BanTable now requires all filter props (timeRange, origin, source)
- Removed useDashboardFilters() hook dependency from BanTable
- Eliminated context fallback chain using ?? operator
- Component now exclusively reads from props, never from context

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:26:45 +02:00
814000fe68 Refactor DashboardFilterBar to use props exclusively, eliminate dual state source
- DashboardFilterBar now requires all filter props (timeRange, onTimeRangeChange, originFilter, onOriginFilterChange) instead of falling back to context
- Removed useDashboardFilters() hook dependency from DashboardFilterBar, BanTrendChart, and JailDistributionChart
- Updated DashboardPage to explicitly pass all filter values and callbacks from context to components
- Made props required on BanTrendChart and JailDistributionChart
- Updated all tests to reflect new prop requirements
- This eliminates the silent dual-source behavior that could lead to subtle bugs when components are used with different data sources

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:24:16 +02:00
10c534d090 Stabilize function references in useJails with useCallback
Previously, the withRefresh helper and all operations (startJail, stopJail, setIdle, reloadJail, reloadAll) were recreated on every render because they were defined in the hook body without useCallback. This caused unnecessary re-renders of child components using React.memo when parent state changed.

Now each operation is wrapped in useCallback with [load] as its dependency. This ensures function references remain stable between renders, allowing React.memo optimizations to work correctly in JailOverviewSection.

Tests confirm that function references are now stable between consecutive renders.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:18:31 +02:00
1bcc336c9b Add tests and documentation updates for log preview and regex tester hooks
- Add useLogPreview.test.ts with comprehensive test coverage
- Add useRegexTester.test.ts with comprehensive test coverage
- Update Docs/Tasks.md and Docs/Web-Development.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 09:14:58 +02:00
3fba69970c Remove completed task TASK-ABORT-03
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:52:37 +02:00
584588e363 fix: add AbortController and unmount guard to useIpLookup hook
- Add AbortController to cancel pending IP lookups when component unmounts
- Prevent state updates on unmounted components by checking abort signal before setState calls
- Add useEffect cleanup that aborts pending requests on unmount
- Update lookupIp API function to accept optional AbortSignal parameter
- Converts callback-based fetch to async/await pattern for better control flow
- Aligns with other API functions that already support abort signals (fetchJails, fetchJailBannedIps)

Fixes TASK-ABORT-04

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:43:11 +02:00
4e3f2005f9 fix: capture AbortController in local variable to avoid race condition in three hooks
TASK-ABORT-03: Fix stale abortRef read in .finally() callbacks

In useGlobalConfig, useServerSettings, and useJailConfigDetail hooks, the
.finally() block was reading abortRef.current instead of using the locally
captured controller reference. If load() is called while a fetch is in flight,
the previous fetch's .finally() would read the new controller (not aborted)
and prematurely clear the loading state while the new fetch is still pending.

Changes:
- useGlobalConfig.ts: use locally-captured ctrl in .finally() (line 46)
- useServerSettings.ts: use locally-captured ctrl in .finally() (line 50)
- useJailConfigDetail.ts: use locally-captured ctrl in .finally() (line 47)

All three hooks already use ctrl correctly in .then() and .catch() callbacks.

Documentation:
- Add 'AbortController in Hooks' section to Web-Development.md
- Explains the pattern and shows incorrect vs correct examples
- Prevents future regressions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:40:03 +02:00
57ee5a2892 Remove completed task TASK-ABORT-01 from docs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:37:16 +02:00
0223cb12a4 fix(hooks): Forward abort signal to fetchBansByCountry in useDashboardCountryData
The useDashboardCountryData hook was creating an AbortController and checking
signal.aborted in callbacks, but was not passing the signal to the fetchBansByCountry
API call. This meant the HTTP request itself was never actually aborted.

Now the signal is forwarded, allowing proper request cancellation when the hook
unmounts or dependencies change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:36:27 +02:00
5a6cb640d8 Add AbortSignal support to API functions for request cancellation
Add optional signal?: AbortSignal parameter to all API GET functions so they can be
cancelled when components unmount. This prevents state-update warnings and wasted
resources.

Changes:
- frontend/src/api/history.ts: fetchHistory, fetchIpHistory
- frontend/src/api/map.ts: fetchBansByCountry
- frontend/src/api/jails.ts: fetchJails, fetchActiveBans
- frontend/src/api/config.ts: fetchJailConfig, fetchInactiveJails, fetchJailConfigFiles,
  fetchFilterFiles (threads signal through fetchFilters), fetchFilterFile, fetchActionFiles,
  fetchActionFile
- frontend/src/api/blocklist.ts: fetchImportLog, previewBlocklist

Updated all calling hooks to pass the abort signal from their controllers:
- useHistory, useIpHistory
- useMapData
- useActiveBans
- useJails
- useConfigActiveStatus (fetchJails and fetchJailConfigs)
- useJailAdmin (fetchInactiveJails)
- useJailConfigDetail (fetchJailConfig)
- useImportLog (fetchImportLog)
- useBlocklists (previewBlocklist with AbortController)

Updated Docs/Web-Development.md to document the convention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:33:28 +02:00
1c5b2d36d9 docs: Remove completed TASK-BUG-08 from Tasks.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:25:32 +02:00
f0caa24d91 fix(ServerHealthSection): add debounce to linesCount input to prevent rapid API calls
- Introduce linesCountRaw state to capture raw input values
- Add handleLinesCountChange callback with 300ms debounce delay
- Reuse existing filterDebounceRef pattern with linesCountDebounceRef
- Guard against zero/negative values by enforcing minimum of 100 lines
- Update Select component to use debounced value and new handler
- Add comprehensive test coverage for debounce behavior and input validation

Fixes TASK-BUG-09: Typing '500' in the Lines field now fires single API
request instead of three (one per keystroke). This mirrors the existing
debounce pattern used for the filter input.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:24:58 +02:00
1510dfc851 fix(config): gate useJails() calls behind dialog open prop
Refactored AssignActionDialog and AssignFilterDialog to only render
dialog content when open=true. This prevents useJails() from being called
when dialogs are closed, eliminating unnecessary GET /api/jails requests.

Implementation uses inner components (AssignActionDialogInner,
AssignFilterDialogInner) that are only mounted when the dialog is open.
The Dialog wrapper remains in the outer component to preserve Fluent UI
animation behavior.

Fixed test setup for AssignFilterDialog to properly call
assignFilterToJail from the mocked onAssign callback.

Fixes TASK-BUG-08.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:18:19 +02:00
97d47fae81 fix(jails): consolidate useJails() calls to eliminate double HTTP request
TASK-BUG-07: Remove duplicate useJails() hook call on JailsPage

Previously, useJails() was called twice on page load:
1. In JailsPage to extract jailNames for BanUnbanForm
2. In JailOverviewSection to manage the jail table

This caused two parallel GET /api/jails requests on every page load.

Changes:
- Lift useJails() to JailsPage as the single source of truth
- Accept jail state as props in JailOverviewSection
- Thread all required state (jails, total, loading, error, and action
  handlers) down from JailsPage to JailOverviewSection
- Remove useJails hook import from JailOverviewSection

This consolidation reduces unnecessary HTTP requests and improves
page load performance, especially with many jails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:12:31 +02:00
3024a4ef07 fix(config): re-sync JailConfigDetail form when jail prop updates from server refresh
When useJailConfigs performs a background refresh, it may deliver an updated
JailConfig object for an already-selected jail. Previously, JailConfigDetail
would continue displaying stale locally-edited form values because the component
only re-initialized on jail name changes (via the key prop), not on object
identity changes.

Added a useEffect that detects when the jail prop reference has changed
(indicating a server refresh) and automatically resets all form fields to the
new server state, but only if autoSave is idle and has no pending changes.
This prevents accidentally overwriting external changes when the user saves,
while still letting users continue editing unsaved changes without interruption.

The implementation:
- Tracks the last-synced jail object in a ref
- Compares incoming jail reference to detect server updates
- Checks autoSave status to ensure no pending saves
- Verifies that current form state matches the old jail values
- Resets all 20+ form fields when conditions are met

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:08:33 +02:00
1d50bc1a73 fix: add validation error handling to InactiveJailDetail
- Add validationError state to show network/API failures to user
- Use handleFetchError to properly handle auth errors (suppress generic error banner, trigger session-expiry flow)
- Clear validationError when user clicks Validate again
- Ensure error MessageBar renders instead of success banner when validation fails
- Fix InactiveJailDetail onValidate to return Promise as expected by prop type
- Fix useJailConfigs test to use correct JailConfig interface

Fixes TASK-BUG-05: prevents silent validation failures where user cannot distinguish between clean 'no issues' result and server error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:02:24 +02:00
3c310e1d79 Update Tasks documentation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:31:24 +02:00
649ebf2dc7 fix: preserve zero values in autoSavePayload
TASK-BUG-04: The autoSavePayload was using the || operator to fall back
to server values when ban_time, find_time, or max_retry were empty or zero.
This silently dropped user intent to set these fields to 0, which is a
valid and meaningful value in fail2ban (e.g., ban_time=0 means permanent ban).

Replace the || fallback with explicit NaN and empty-string guards that
only fall back when:
1. The trimmed input is empty (user cleared the field)
2. The input is non-numeric (NaN)

This preserves valid zero values while still falling back appropriately
for invalid input.

- ban_time: 0 now correctly sends permanent ban instead of falling back
- find_time: 0 now sends the intended value instead of falling back
- max_retry: 0 now sends the intended value instead of falling back

Added comprehensive tests for:
- Preserving zero values in the payload
- Falling back for empty input
- Falling back for non-numeric input

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:30:31 +02:00
dfd1b9006b Remove completed task from Tasks.md
Remove TASK-BUG-02 documentation as it has been resolved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:25:07 +02:00
d1674add90 fix: Stop MapPage pagination resetting on every data refresh
Remove 'bans' from the useEffect dependency array that resets pagination.
Since 'bans' changes with every background data refresh (new array reference),
the page was being reset to 1 every 30 seconds, making the table unusable for
pagination beyond the first page.

Add a separate effect that clamps the current page to totalPages when the
data shrinks below the current page offset (edge case when filtered results
are fewer than displayed page).

Fixes TASK-BUG-03.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:24:05 +02:00
0bfa975222 Fix: Keep ConfigPage tabs mounted to preserve form state
Previously, the tab content wrapper used 'key={tab}' which caused React to
unmount and remount the entire subtree when switching tabs. This destroyed
all component state, including unsaved form data and pending auto-saves.

Changes:
- Removed 'key={tab}' from the wrapper div
- All tab panels now render at page initialization
- Inactive tabs use CSS 'display: none' to hide without unmounting
- Tabs remain mounted throughout the page lifetime
- Users can now switch tabs without losing form input

Updated ConfigPage.test.tsx to reflect that inactive tabs remain in the DOM
(just hidden with CSS) rather than being removed entirely.

Documentation: Added 'Tab Panels' section to Web-Development.md
explaining the rule and rationale.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:21:36 +02:00
0f261e31c2 Fix infinite re-fetch loop in useJailConfigs
The hook was passing an inline onSuccess callback to useListData, which
included onSuccess in its internal refresh function's dependency array.
This caused refresh to be recreated on each render, which triggered the
useEffect, which fired the fetch, which completed and caused a re-render,
creating an infinite loop.

Wrap onSuccess in useCallback with empty dependencies so it maintains a
stable reference across renders. This allows refresh to be stable when
its dependencies don't change, breaking the cycle.

Add documentation to Refactoring.md explaining the onSuccess stability
requirement for useListData callers.

Also add tests for useJailConfigs to verify it doesn't trigger infinite
refetches with stable onSuccess callback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:16:42 +02:00
3e3578f4d8 Update task list and add runner script
- Updated Tasks.md with refined task tracking format
- Added runner.csx script for automated task processing with Copilot

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:05:41 +02:00
0481810226 Fix open redirect vulnerability in LoginPage
Validate the ?next= query parameter to prevent open redirects to
external URLs. The parameter is validated to ensure it is a relative
path (starts with / but not //) before using it for navigation.
Invalid paths fall back to '/'.

This prevents attackers from crafting login links like /login?next=https://evil.com
that would transparently redirect authenticated users to malicious sites.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 21:04:17 +02:00
a286ede49c Refactor frontend components and dependencies
- Update ESLint configuration for frontend
- Refactor dialog components (ActivateJail, CreateAction, CreateFilter, CreateJail)
- Update JailsTab and RegexTesterTab components
- Refactor TopCountriesPieChart component
- Update package.json dependencies
- Update documentation (Tasks.md)
- Refactor CodeList component for jail page

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 20:26:43 +02:00
1bf0645c04 Configure Vite dev proxy via VITE_BACKEND_URL 2026-04-22 20:21:20 +02:00
1d41822a36 Add SEO/security meta tags and favicon to frontend index.html 2026-04-22 20:06:49 +02:00
b7fbad0328 Add dashboard filter context to remove prop drilling 2026-04-21 20:08:54 +02:00
b6d9c649ca Delete hook barrel files and switch to direct hook imports 2026-04-21 20:02:50 +02:00
1ba82d56e7 Refactor ServerTab and ConfFilesTab to use reducers 2026-04-21 19:52:05 +02:00
260ce7e875 Fix frontend config tests for strict type narrowing 2026-04-21 19:40:51 +02:00
4c313af1c5 Narrow jail config types with explicit union values 2026-04-21 19:39:36 +02:00
fef8f60ee2 Add dark mode support with persisted OS-aware theme selection 2026-04-21 19:30:29 +02:00
4f91e8fdd3 Persist sidebar collapsed preference to localStorage 2026-04-21 19:17:00 +02:00
b3eb5dc6ec Standardise loading state naming across dashboard hooks 2026-04-21 19:12:43 +02:00
094fb4fece Replace index keys with stable keys in editable list components 2026-04-21 19:04:18 +02:00
4da2703966 Move constant inline styles into makeStyles 2026-04-21 18:47:18 +02:00
86a7336ac0 Refactor shared data source selection for dashboard and map 2026-04-21 17:56:59 +02:00
e244a85291 Extract generic useListData hook for shared list fetching 2026-04-21 17:53:58 +02:00
e683108965 Standardise AbortController cancellation in setup and server health hooks
Add abortable API signals for setup status and server health/log fetches, document hook cancellation patterns, and cover stale refresh cancellation with tests.
2026-04-21 17:38:35 +02:00
cf5a000bf5 Add AbortSignal support to dashboard/blocklist APIs and hooks 2026-04-21 17:29:05 +02:00
51e340fa33 backup 2026-04-20 20:19:43 +02:00
69d5cffabd Remove duplicate api/file_config.ts and consolidate raw file APIs into api/config.ts 2026-04-20 20:19:20 +02:00
8b4a2f0b71 Fix useMapData debounce loading state 2026-04-20 20:10:48 +02:00
1694ac17f8 Add React.memo to heavy dashboard components 2026-04-20 20:00:59 +02:00
1d6564aa32 Add route code splitting and Vite vendor chunk splitting 2026-04-20 19:53:56 +02:00
27369b43d6 Memoize Fluent chart token resolution 2026-04-20 19:47:10 +02:00
20412dd94b Memoize dashboard and history table columns 2026-04-20 19:28:29 +02:00
e593498de5 Strengthen setup password validation
- Add backend Pydantic password complexity validation for setup
- Update frontend setup page with password rule feedback and strength indicator
- Add/adjust setup API tests for password validation
- Document setup password requirements
- Fix frontend test type annotation issue
2026-04-20 19:23:12 +02:00
cc8c71906f Add auth expiry interceptor and session-expired redirect 2026-04-19 20:31:49 +02:00
d0991e0d40 Fix SetupGuard error handling and add retry UI 2026-04-19 20:20:31 +02:00
c58eb240b1 Fix KVEditor duplicate key rename validation
Prevent users from renaming a KVEditor entry to an existing key and show inline validation errors.
2026-04-19 19:59:13 +02:00
082dcc7ee1 Fix BanUnbanForm floating promises and add submit guards 2026-04-19 19:42:39 +02:00
76c9f388a8 Fix HistoryPage stale appliedQuery effect and add mount query regression test 2026-04-19 19:36:44 +02:00
5446f6c3e1 Fix jail banned IP loading race with AbortController 2026-04-19 19:31:03 +02:00
9e7f881a8a backup 2026-04-19 19:25:09 +02:00
7fb0cc727f Surface setup error state instead of console.warn in useSetup 2026-04-19 18:53:02 +02:00
b6303cff72 Remove production test scaffolding from useMapData and update MapPage tests 2026-04-19 18:47:29 +02:00
e7582c4bae Relocate misplaced frontend files 2026-04-19 18:36:55 +02:00
d44a667592 Fix unsafe frontend casts and mark Task 18 done 2026-04-19 18:25:32 +02:00
e6ee525e0f Deduplicate TimeRange type in frontend type definitions 2026-04-19 18:21:51 +02:00
09a1d3c7b7 Move frontend runtime constants out of types/ban.ts 2026-04-19 18:18:24 +02:00
d99d6bd119 Replace inline frontend styles with makeStyles and design tokens 2026-04-19 12:04:24 +02:00
91269448d0 Replace ErrorBoundary fallback with Fluent UI styles and dialog compliance 2026-04-19 09:44:14 +02:00
47f9c602d4 Finish Task 13: extract remaining page subcomponents and clean page files 2026-04-19 09:38:23 +02:00
38b9d35255 Refactor frontend pages and config components into single-component files for Task 13 2026-04-19 09:30:35 +02:00
6c053cdaee Add AbortController cleanup to async frontend effects 2026-04-18 21:30:57 +02:00
2105f8b435 Task 11: Remove direct API calls from components 2026-04-18 21:20:45 +02:00
3f197b1ad7 Split multi-hook frontend modules into single-hook files 2026-04-18 20:47:44 +02:00
fba7675eb8 Move auth and timezone hooks into dedicated hook files 2026-04-18 20:35:28 +02:00
d9550ae4aa Split frontend config API into file_config, server, and health modules 2026-04-18 20:32:38 +02:00
01f2e07921 Extract shared raw config file helpers and simplify raw_config_io_service 2026-04-18 20:18:54 +02:00
c1f188643c Move geo cache commit handling into repository layer 2026-04-18 20:10:05 +02:00
be1d66988f Document RuntimeState concurrency model and mark task 5 complete 2026-04-18 19:56:41 +02:00
52e08e17a4 Guard geo_service.init_geoip against repeated initialization 2026-04-18 19:54:05 +02:00
99731a9919 Remove empty backend helpers package 2026-04-18 19:50:44 +02:00
db5b4cb77e Add settings and history archive repository protocols and DI support 2026-04-17 20:54:08 +02:00
7055971163 Harden preview_log path validation and add regression test 2026-04-17 20:37:14 +02:00
5e5d7c34b2 Document task DB access and unify background task DB handling 2026-04-17 17:18:49 +02:00
16687b0520 Mark Task 20 complete and document global exception handlers 2026-04-17 17:07:48 +02:00
4754f1407e Mark task 19 done and centralize map-color thresholds in settings_service 2026-04-17 17:04:09 +02:00
7a1cb0c46c Extract health-check crash-detection logic into runtime state helper 2026-04-17 16:58:24 +02:00
1e2850a34e Add async lock protection to geo service cache and mark Task 16 done 2026-04-17 16:51:05 +02:00
04b2e2f700 Add global domain exception handlers in main.py
Register consistent HTTP error mappings for common domain exceptions and add regression tests for 404/400/500 handler behavior.
2026-04-17 16:42:18 +02:00
900d111a5d Refactor geo enrichment into jail_service and mark Task 14 done 2026-04-17 16:36:22 +02:00
487f252a4d Move history geo enrichment into history service 2026-04-17 16:28:53 +02:00
8c6950afc1 Task 13: move ban_ip, unban_ip, and get_active_bans from jail_service to ban_service and update routers/tests 2026-04-17 16:22:20 +02:00
6e1e3c4546 Remove unused service protocol aliases and use direct service imports 2026-04-17 16:01:27 +02:00
7d16391c6c Centralise DbDep and mark Task 11 complete 2026-04-17 15:44:13 +02:00
74ff4cb4b8 Remove repository import from setup_utils and move password-hash helper to setup service 2026-04-17 15:38:41 +02:00
e70d98809b Mark task 9 as completed in task list 2026-04-17 15:35:12 +02:00
58112fb191 Move auth session signing into auth_service.login 2026-04-17 15:33:09 +02:00
33643880ed Extract fail2ban restart orchestration into jail_service 2026-04-17 15:23:54 +02:00
c21cf82e9e Refactor map color threshold storage into dedicated settings service 2026-04-17 15:13:07 +02:00
13b3fde274 Fix stale activation record on failed jail activation
Record activation only after a successful jail activate request and add regression coverage to prevent stale last_activation state.
2026-04-17 14:53:57 +02:00
73cc212e28 Invert blocklist scheduler dependency to task callback 2026-04-15 21:31:08 +02:00
a5e95e2061 Replace __import__('datetime') antipattern in health_check task 2026-04-15 21:22:56 +02:00
56f03f39c7 Move history archive max timestamp query into repository 2026-04-15 21:18:44 +02:00
cdb0c3681e Task 3: remove config_file_service facade, update direct imports and tests 2026-04-15 21:16:00 +02:00
0e22d1c425 Move config file exceptions into app.exceptions
Move ConfigDirError, ConfigFileNotFoundError, ConfigFileExistsError, ConfigFileWriteError, and ConfigFileNameError from raw_config_io_service into the shared domain exception module. Update router and tests to import the exceptions from app.exceptions.
2026-04-15 10:28:27 +02:00
328f3575e2 Move Fail2Ban exceptions into central app.exceptions module 2026-04-15 10:22:48 +02:00
a79f5339bc Refactor fail2ban DB path lookup and simplify geo router response 2026-04-15 09:15:50 +02:00
6dc53a80b5 Mark TASK-13 complete and document fail2ban_metadata_service 2026-04-15 09:14:29 +02:00
56c511d905 Fix module-level asyncio locks in jail_service
Initialize jail_service locks lazily to avoid import-time event loop binding and add regression tests for lock creation.
2026-04-15 09:10:38 +02:00
a8f2d2d7b9 Refactor geo re-resolve endpoint into geo_service and add typed response 2026-04-15 08:56:37 +02:00
2451ec77b2 Refactor config file service facade wrappers and mark TASK-06 complete in Docs/Tasks.md 2026-04-15 08:25:12 +02:00
b70dc6fa7a Refactor blocklist schedule management into service 2026-04-14 15:25:36 +02:00
58bb769a35 Refactor history sync into history_service and update docs/tests 2026-04-14 15:09:58 +02:00
86fa271c40 Remove FastAPI dependency from jail config service signatures 2026-04-14 15:01:05 +02:00
41f8c1f6cb Remove task import from jail_config_service and mark TASK-03 done 2026-04-14 14:38:43 +02:00
2a7766d206 Wrap blocking mkdir() calls in run_blocking for async startup and setup 2026-04-14 13:54:47 +02:00
6b436dc354 Fix undefined names and config router imports / task status update 2026-04-14 13:53:39 +02:00
09c764cebc Task 25: extend service/repository protocol coverage and wire DI aliases 2026-04-14 12:32:42 +02:00
b1fba79a2e Remove unused asyncio import from log_service
Clean log_service.py by deleting the unused asyncio import and mark Task 24 completed in Docs/Tasks.md.
2026-04-14 12:19:35 +02:00
53cdd63b6a Add no-op session cache when session cache is disabled
Use NoOpSessionCache in backend/app/main.py and dynamically switch cache implementation in backend/app/dependencies.py so disabled cache mode remains safe while get_session_cache always returns a valid object.
2026-04-14 12:14:50 +02:00
ec91c1c8b2 Use shared blocking executor in run_blocking
Wire DEFAULT_BLOCKING_EXECUTOR as the default executor in backend/app/utils/async_utils.py, preserving custom executors and marking Task 22 completed in Docs/Tasks.md.
2026-04-14 12:07:35 +02:00
fdede3e7dd Offload ensure_jail_configs to a thread during startup
Use run_blocking for synchronous jail config file creation in backend/app/startup.py and mark Task 20 completed in Docs/Tasks.md.
2026-04-14 12:01:27 +02:00
5379cca238 Remove unused asyncio import from auth_service 2026-04-14 10:37:28 +02:00
0e5e08374f Use shared SESSION_COOKIE_NAME in auth router tests 2026-04-14 10:33:32 +02:00
5e4d3fcf12 Remove Mock fallback from runtime_state and add runtime settings regression tests 2026-04-14 10:30:25 +02:00
0e84f1f60c Fix config sub-router prefixes and router tags 2026-04-14 10:25:36 +02:00
cee5372690 Add backend capability cache reset helper for jail service tests 2026-04-14 10:24:14 +02:00
41a67d52ab Remove ghost service imports from config router 2026-04-14 10:20:28 +02:00
56ade7fb08 Task 13: wire geo_batch_lookup through dependency injection and mark task completed 2026-04-14 09:51:23 +02:00
88715ab07f Complete Task 11 by moving history_archive_repo import to history_sync top-level 2026-04-14 09:28:22 +02:00
21eabb1f0f Resolve Task 10 by moving history_archive_repo imports to ban_service top-level 2026-04-14 09:25:23 +02:00
a564830abb Fix blocklist service injection and centralize session cookie name 2026-04-14 09:21:38 +02:00
5a9d226cca Consolidate fail2ban truthy values into shared constants 2026-04-14 09:03:49 +02:00
b4959133dd Task 5: finalize config_file_service wrapper refactor and mark task done 2026-04-14 08:51:01 +02:00
37646e57f7 Remove helper indirection and import shared service helpers directly 2026-04-14 07:56:59 +02:00
a5674f9e4c Consolidate domain exceptions into app.exceptions
Move all shared domain exception classes to backend/app/exceptions.py and update services/routers to import the canonical exceptions. Update docs to reflect the shared exceptions source.
2026-04-13 19:35:12 +02:00
4b2e86edbb Fix filter_config router import and mark Task 3 complete 2026-04-13 19:10:24 +02:00
5957d851b5 Fix stale run_blocking call sites in log preview and config services 2026-04-12 20:34:35 +02:00
8e43ef9ad2 Fix setup_service to mark setup_complete only after successful runtime DB init 2026-04-12 20:30:22 +02:00
e6df045e5e Fix startup runtime settings ordering and use effective database path for request DB connections 2026-04-12 20:02:40 +02:00
ee880e6296 Introduce explicit ApplicationContext and remove raw request.app.state usage 2026-04-12 19:56:01 +02:00
72488b14b2 Centralize fail2ban metadata resolution and cache DB path discovery 2026-04-12 19:48:33 +02:00
e221cd414f Split monolithic config router into focused subrouters 2026-04-12 19:41:43 +02:00
e271207795 Refactor fail2ban client to use vendored adapter 2026-04-12 19:25:56 +02:00
21b38365c4 Add runtime DB schema migration and version tracking 2026-04-12 19:13:36 +02:00
ffe7ada469 Consolidate setup persistence into bootstrap metadata and runtime DB 2026-04-11 20:57:55 +02:00
cd69550053 Standardize async offloading behind shared executor helper 2026-04-11 20:40:08 +02:00
ae81a8f5be Refactor periodic tasks to use injected scheduler resources 2026-04-11 20:32:36 +02:00
9cba5a9fcb Refactor blocklist import registration to async startup flow 2026-04-11 20:07:00 +02:00
952469e667 Task 7 complete: move config operational orchestration from routers into service/task layer 2026-04-10 21:24:54 +02:00
91e5792caf Mark startup runtime configuration task complete and update startup resource resolution 2026-04-10 21:13:51 +02:00
f61d497e4e Refactor backend auth, setup, router, and runtime state handling 2026-04-10 21:00:36 +02:00
3371ff8324 Introduce service/repository dependency protocols and tests 2026-04-10 19:51:19 +02:00
3b6e39ddad Separate bootstrap settings from runtime overrides with a dedicated runtime settings manager 2026-04-10 19:31:51 +02:00
9b4cd17e3b Harden SQLite connection defaults with WAL and busy timeout 2026-04-10 19:24:21 +02:00
1dfc17f4f5 Replace process-local session cache with pluggable session cache backend 2026-04-10 19:22:02 +02:00
2157502670 Eliminate direct app.state access from routers 2026-04-10 19:15:37 +02:00
ff92733f90 Move runtime application state into a dedicated runtime state manager 2026-04-10 19:07:35 +02:00
6b177f1881 Mark async socket handling task done and implement startup cleanup 2026-04-09 22:13:22 +02:00
148756fb79 Finish external HTTP client resilience: add shared aiohttp config, retry support, and update task status 2026-04-09 22:01:11 +02:00
e1d741956e Disable session cache by default and make it opt-in for single-process deployments 2026-04-09 21:52:57 +02:00
4043cdfa3c Harden session cookie security with configurable cookie flags 2026-04-09 21:43:32 +02:00
208f98dc97 Use session_secret for signed auth session tokens 2026-04-09 21:30:08 +02:00
6eab47f7ba Fix setup persistence and load persisted runtime configuration 2026-04-07 21:41:55 +02:00
be46547114 backup 2026-04-07 21:15:41 +02:00
1fc04ed978 Extract startup resource initialization from main.py
Move lifespan startup logic into app.startup and remove local imports from app.main._lifespan. Mark startup wiring issue as done.
2026-04-07 20:48:29 +02:00
effcc65e1b Document process-local auth session cache semantics
Clarify that dependencies.py session cache is process-local and not cluster-safe, and document the limitation in architecture docs.
2026-04-07 20:42:31 +02:00
3cc495dfce Remove obsolete utility modules after helper refactor 2026-04-07 20:40:38 +02:00
1e39e5a1d6 Refactor app helpers and use AppStateDep in config router
Move service-dependent helper wrappers from app.utils to app.helpers and update config router activation/rollback to use explicit AppState dependency.
2026-04-07 20:39:56 +02:00
ed3aa61c35 Refactor routers to use explicit FastAPI app dependencies 2026-04-07 20:27:06 +02:00
30e0dd71c9 Use explicit AppState dependency in config router and update task status 2026-04-07 20:15:28 +02:00
59a56f2e4f Use dependency injection for health status and add health router regression test 2026-04-07 20:05:54 +02:00
e21f153946 Remove dead task DB fast-path and update task tests 2026-04-07 20:00:13 +02:00
0a70e40d8b Refactor config router to use explicit dependency injection 2026-04-06 21:11:02 +02:00
ca4b0ed324 backup 2026-04-06 21:02:48 +02:00
95f72018f7 Add backend lifecycle regression tests and fix lifespan cleanup 2026-04-06 20:56:57 +02:00
c2982116a8 Add deployment-safe backend config and production-safe CORS defaults 2026-04-06 20:47:31 +02:00
1a7096b276 Add environment-driven CORS settings and backend regression tests 2026-04-06 20:42:33 +02:00
89ab41cc9e Convert setup guard to startup-driven cache and update tests 2026-04-06 20:38:15 +02:00
3ccfc20c64 Harden fail2ban integration and mark task complete 2026-04-06 20:20:14 +02:00
594f55d157 Refactor router dependency wiring to explicit app state providers 2026-04-06 20:12:04 +02:00
f0ee466603 backup 2026-04-06 19:49:53 +02:00
5107ff10d7 Mark backend SQLite request-scoping task done and clean up test fixture 2026-04-06 19:49:29 +02:00
3b58179845 Refactor router dependencies to use explicit fail2ban socket and HTTP session injection 2026-04-06 16:38:17 +02:00
42c030c706 Refactor backend to use request-scoped SQLite connections 2026-04-05 23:14:46 +02:00
fde4c480fa backup 2026-04-05 22:48:33 +02:00
96f75db75f chore: release v0.9.19 2026-04-05 22:47:42 +02:00
554c75247f Update Docs/Tasks.md 2026-04-05 22:45:41 +02:00
6e2abe9d97 Fix world map country selection handling and preserve map during re-fetch 2026-04-05 22:44:50 +02:00
15d53a8e96 Merge branch 'fix/worldmap-hover-highlight' 2026-04-05 22:19:35 +02:00
acdb0e1f03 backup 2026-04-05 22:18:06 +02:00
f1e3d4c4c9 Make Map companion table header and pagination sticky 2026-04-05 22:17:24 +02:00
c51858ec71 Add country-specific companion table filtering for map page 2026-04-05 22:12:06 +02:00
550 changed files with 89945 additions and 20177 deletions

33
.editorconfig Normal file
View File

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

60
.env.example Normal file
View File

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

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

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

18
.gitignore vendored
View File

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

1
.husky/pre-commit Normal file
View File

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

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

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

157
CONTRIBUTING.md Normal file
View File

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

View File

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

View File

@@ -1 +1 @@
v0.9.18
v0.9.19

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1338
Docs/API-Reference.md Normal file

File diff suppressed because it is too large Load Diff

730
Docs/API_STATUS_CODES.md Normal file
View File

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

166
Docs/API_VERSIONING.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

194
Docs/CONFIGURATION.md Normal file
View File

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

347
Docs/DATABASE_SCHEMA.md Normal file
View File

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

124
Docs/DOMAIN_MODELS.md Normal file
View File

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

1071
Docs/Deployment.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
### Options
- **Master Password** — Set a single global password that protects the entire web interface.
- **Master Password** — Set a single global password that protects the entire web interface. Must be between 8 and 72 characters long (72-byte limit is due to bcrypt truncation) and include one uppercase letter, one number, and one special character from `!@#$%^&*()`.
- **Database Path** — Define where the application stores its own SQLite database.
- **fail2ban Connection** — Specify how the application connects to the running fail2ban instance (socket path or related settings).
- **General Preferences** — Any additional application-level settings such as default time zone, date format, or session duration.
@@ -30,6 +30,22 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
- After entering the correct password the user is taken to the page they originally requested.
- A logout option is available from every page so the user can end their session.
### Session Validation on App Load
- On app mount (page reload or initial load), the frontend validates the cached session with the backend by calling `GET /api/auth/session`.
- While the validation check is in flight, a loading spinner is displayed to avoid UI flicker.
- If the backend returns **200**, the session is valid and the app proceeds normally.
- If the backend returns **401**, the session has expired or been revoked (server-side DB deletion, restart, etc.), and the user is logged out and redirected to the login page.
- If a **network error** occurs (backend temporarily unreachable), the user is not logged out — the app assumes the backend will recover and continues with the cached session state. The next API call will trigger a 401 if the session is actually invalid.
### Login Rate Limiting
- The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks with per-IP rate limiting.
- **Rate limit:** 5 login attempts per minute per IP address.
- When the limit is exceeded, the server returns **HTTP 429 Too Many Requests** with a `Retry-After` header indicating when requests will be accepted again.
- Each failed login attempt triggers a progressive server-side delay (exponential back-off from 1 to 10 seconds) to further slow down attack attempts, on top of the bcrypt password hashing cost. The penalty grows with consecutive failures and resets after the rate-limit window expires.
- The rate limiter tracks attempts in memory per IP, ensuring that rapid-fire attacks from a single source are quickly throttled.
---
## 3. Ban Overview (Dashboard)
@@ -74,7 +90,7 @@ A geographical overview of ban activity.
- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner.
- For every country that has bans, the total count is shown only in the country tooltip, not rendered on the map itself.
- Countries with zero banned IPs show no tooltip and remain blank and transparent.
- Clicking a country filters the companion table below to show only bans from that country.
- Clicking a country filters the companion table below to show only bans from that country. When a country is selected the server returns the **complete** list of bans for that country in the chosen time window — the default 200-row companion cap is lifted for filtered queries. Clicking the same country again or using the "Clear filter" button reverts to the standard unfiltered view.
- Time-range selector with the same quick presets:
- Last 24 hours
- Last 7 days
@@ -83,6 +99,11 @@ A geographical overview of ban activity.
- **Data source selection:** Same rule as the Dashboard — "Last 24 hours" uses the live fail2ban database; all other ranges use the BanGUI archive.
- A **data-source badge** is displayed alongside the time-range selector indicating **Live (fail2ban DB)** or **Archive (BanGUI DB)**.
### Companion Table
- The column header row is always visible at the top of the scrollable table area (sticky positioning) so column labels remain readable regardless of scroll position.
- The pagination / page-size bar is always visible at the bottom of the scrollable table area (sticky positioning) so the user can navigate pages without scrolling back down.
---
## 5. Jail Management
@@ -191,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
@@ -206,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.
@@ -242,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.
---
@@ -290,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).
@@ -301,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.
@@ -317,6 +358,13 @@ Automated downloading and applying of external IP blocklists to block known mali
- Display the import log in the web interface, filterable by source and date range.
- Show a warning badge in the navigation if the most recent import encountered errors.
### Data Retention & Deletion
- Import logs are retained for audit and troubleshooting purposes.
- A blocklist source **cannot be deleted** while it has associated import logs (foreign key RESTRICT constraint).
- Before deleting a source, delete all its import logs first via the API.
- Attempting to delete a source with logs returns **HTTP 409 Conflict** with error code `blocklist_source_has_logs`.
### Error Handling
- If a blocklist URL is unreachable, log the error and continue with remaining sources.

View File

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

845
Docs/Observability.md Normal file
View File

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

146
Docs/PERFORMANCE.md Normal file
View File

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

View File

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

176
Docs/Security.md Normal file
View File

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

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

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

487
Docs/TROUBLESHOOTING.md Normal file
View File

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

145
Docs/TYPE_SAFETY.md Normal file
View File

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

View File

@@ -1,78 +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
### Backend Architecture
- **Replace the single shared SQLite connection.**
- Current startup code opens one `aiosqlite.Connection` and reuses it for every request.
- This should be replaced with either a connection pool or request-scoped connections to avoid concurrency and locking issues.
- Update request dependencies, application lifecycle, and tests to use the new pattern.
- **Refactor dependency wiring and shared resource management.**
- Remove hidden module-level import coupling between routers, services, and shared utilities.
- Introduce explicit factories or providers for shared resources such as DB, HTTP client session, scheduler, and settings.
- Ensure routers depend on injected providers rather than global state or dynamic imports.
- **Harden fail2ban integration.**
- Remove the `sys.path` hack that locates `fail2ban-master` at runtime.
- Replace it with a deterministic packaging or configuration model so the backend does not depend on repository layout.
- Refactor `Fail2BanClient` so concurrency control is instance-based and not backed by hidden module globals.
- **Improve startup / setup guard behavior.**
- Convert `SetupRedirectMiddleware` from an on-demand DB check into a startup/initialisation guard where possible.
- Cache setup completion in a safe way and provide an explicit invalidation path if the application state changes.
- Reduce middleware responsibility and avoid DB access during normal request dispatch.
- **Make deployment configuration explicit.**
- Move hard-coded environment assumptions such as CORS origins into settings.
- Ensure `fail2ban_socket`, `fail2ban_config_dir`, and startup commands are fully configurable via `Settings`.
- Document production-ready defaults separately from development defaults.
### Reliability and Resilience
- **Add backend lifecycle tests for resource cleanup.**
- Verify startup opens and initialises DB, HTTP session, scheduler, and geo cache correctly.
- Verify shutdown closes those resources cleanly.
- **Add concurrency/regression coverage for DB and fail2ban socket use.**
- Add tests that simulate multiple concurrent requests using the same DB dependency.
- Add tests around fail2ban socket retries, protocol errors, and rate limiting.
- **Improve state caching and invalidation.**
- Add tests for session cache invalidation on logout.
- Add tests for setup completion caching so stale state is never served.
### Backend Feature Work
- **Document and implement backend-safe environment-driven CORS.**
- Add support for production and local development origins through configuration.
- Avoid a hardcoded Vite origin in the core app factory.
- **Centralise scheduler job registration.**
- Refactor APScheduler registration so background tasks are registered through a common lifecycle helper.
- Ensure jobs can be discovered, replaced, and tested without requiring implicit `app.state` side effects.
- **Strengthen fail2ban error handling and reporting.**
- Standardise `502` responses for connection/protocol failures across all endpoints.
- Add structured logging for retries and fatal socket failures.
- Ensure the UI can distinguish offline fail2ban from internal backend failures.
- **Improve documentation of backend responsibilities.**
- Keep `Docs/Tasks.md` aligned with the backend architecture review.
- Add references to the backend modules, resource lifecycle, and dependency model in the documentation.
### Priority Execution Plan
1. Fix the global SQLite connection pattern and tests.
2. Refactor dependency injection / explicit shared resources.
3. Harden fail2ban client concurrency and packaging.
4. Convert setup guard to a safer startup-driven model.
5. Add deployment-safe configuration and production-ready CORS.
6. Add lifecycle and concurrency regression tests.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

154
Docs/runner.csx Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,21 @@ Follows pydantic-settings patterns: all values are prefixed with BANGUI_
and validated at startup via the Settings singleton.
"""
from pydantic import Field
import ipaddress
import os
import shlex
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.utils.constants import (
DEFAULT_DATABASE_PATH,
DEFAULT_FAIL2BAN_SOCKET,
DEFAULT_SESSION_DURATION_MINUTES,
)
class Settings(BaseSettings):
"""BanGUI runtime configuration.
@@ -18,38 +30,287 @@ class Settings(BaseSettings):
"""
database_path: str = Field(
default="bangui.db",
default=DEFAULT_DATABASE_PATH,
description="Filesystem path to the BanGUI SQLite application database.",
)
fail2ban_socket: str = Field(
default="/var/run/fail2ban/fail2ban.sock",
default=DEFAULT_FAIL2BAN_SOCKET,
description="Path to the fail2ban Unix domain socket.",
)
session_secret: str = Field(
...,
min_length=32,
description=(
"Secret key used when generating session tokens. "
"Must be 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,21 +321,293 @@ 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=(
"Shell command used to start (not reload) the fail2ban daemon during "
"recovery rollback. Split by whitespace to build the argument list — "
"no shell interpretation is performed. "
"recovery rollback. Split by whitespace to build the argument list — "
"no shell interpretation is performed. "
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
),
)
enable_docs: bool = Field(
default=False,
description=(
"Enable FastAPI interactive API documentation at /api/docs (Swagger UI) "
"and /api/redoc (ReDoc). Should be true only in development environments. "
"In production, leave unset (defaults to false) to avoid exposing API schema."
),
)
trusted_proxies: str | list[str] = Field(
default_factory=list,
description=(
"Comma-separated list of trusted reverse proxy IP addresses or CIDR ranges. "
"Only requests from these IPs/ranges are allowed to set X-Forwarded-For and X-Real-IP headers. "
"Examples: '192.168.1.1' or '10.0.0.0/8' or '192.168.1.1,10.0.0.0/8'. "
"Leave empty to disable proxy header forwarding (default). "
"This is critical for correct client IP extraction behind reverse proxies like nginx."
),
)
@field_validator("trusted_proxies", mode="before")
@classmethod
def _normalize_trusted_proxies(cls, value: str | list[str] | None) -> list[str]:
"""Normalize trusted_proxies from comma-separated string to list.
Args:
value: A comma-separated string or list of trusted proxy IPs/CIDRs.
Returns:
A list of normalized proxy IP/CIDR strings.
"""
if value is None:
return []
if isinstance(value, str):
return [proxy.strip() for proxy in value.split(",") if proxy.strip()]
return value
@field_validator("trusted_proxies", mode="after")
@classmethod
def _validate_trusted_proxies(cls, value: list[str]) -> list[str]:
"""Validate trusted_proxies as valid IPs or CIDR ranges.
Args:
value: A list of proxy IP addresses or CIDR ranges.
Returns:
The validated list.
Raises:
ValueError: If any item is not a valid IP address or CIDR range.
"""
for proxy in value:
try:
# Try to parse as a CIDR network first
ipaddress.ip_network(proxy, strict=False)
except ValueError:
try:
# Fall back to parsing as a single IP address
ipaddress.ip_address(proxy)
except ValueError as exc:
raise ValueError(
f"Invalid IP address or CIDR range: {proxy!r}. "
f"Expected format: '192.168.1.1' or '10.0.0.0/8'"
) from exc
return value
@field_validator("fail2ban_start_command", mode="after")
@classmethod
def _validate_fail2ban_start_command(cls, value: str) -> str:
"""Validate fail2ban_start_command by attempting to parse it with shlex.
Ensures the command can be split into arguments without shell interpretation.
Raises ValueError if the command contains mismatched quotes.
Args:
value: The fail2ban start command string.
Returns:
The validated command string.
Raises:
ValueError: If the command contains mismatched quotes.
"""
try:
shlex.split(value)
except ValueError as e:
raise ValueError(
f"fail2ban_start_command contains mismatched quotes or is otherwise "
f"unparseable: {value!r}{e}"
) from e
return value
external_logging_enabled: bool = Field(
default=False,
description=(
"Enable sending logs to an external centralized logging platform. "
"When disabled (default), logs are written to stdout only. "
"When enabled, set external_logging_provider and provider-specific settings."
),
)
external_logging_provider: Literal["datadog", "papertrail", "elasticsearch"] | None = Field(
default=None,
description=(
"External logging platform provider. "
"Set to 'datadog', 'papertrail', or 'elasticsearch'. "
"Only used when external_logging_enabled is true."
),
)
external_logging_buffer_size: int = Field(
default=1000,
ge=10,
description=(
"Maximum number of log records to buffer in memory before dropping oldest logs. "
"Prevents unbounded memory growth if the external system is temporarily unavailable."
),
)
external_logging_flush_interval_seconds: float = Field(
default=5.0,
gt=0.0,
description=(
"Maximum time in seconds to buffer logs before sending to the external system. "
"Logs are sent earlier if the batch size is reached."
),
)
external_log_required: bool = Field(
default=False,
description=(
"When enabled and external logging is configured, startup aborts if the "
"external log handler fails to initialize. When disabled (default), a failed "
"handler is treated as a warning and the application continues without external "
"logging. Set to true in production environments where logs must reach the "
"monitoring system."
),
)
datadog_api_key: str | None = Field(
default=None,
description=(
"Datadog API key for sending logs. Required when external_logging_provider is 'datadog'. "
"Obtain from Datadog organization settings."
),
)
datadog_site: str = Field(
default="datadoghq.com",
description=(
"Datadog site: 'datadoghq.com' for US or 'datadoghq.eu' for EU. "
"Only used when external_logging_provider is 'datadog'."
),
)
datadog_batch_size: int = Field(
default=10,
ge=1,
description=(
"Number of log records to batch before sending to Datadog. "
"Smaller batches send logs faster; larger batches are more efficient."
),
)
papertrail_host: str | None = Field(
default=None,
description=(
"Papertrail host address (e.g., 'logs1.papertrailapp.com'). "
"Required when external_logging_provider is 'papertrail'."
),
)
papertrail_port: int | None = Field(
default=None,
ge=1,
le=65535,
description=(
"Papertrail port number. Required when external_logging_provider is 'papertrail'. "
"Typically 12345 or in range 10000-32768."
),
)
papertrail_program_name: str = Field(
default="bangui",
description=(
"Program name to include in Syslog messages sent to Papertrail. "
"Useful for filtering logs by program in Papertrail UI."
),
)
elasticsearch_hosts: str | list[str] = Field(
default_factory=list,
description=(
"Elasticsearch host addresses. Can be comma-separated string or list. "
"Examples: 'http://elasticsearch:9200' or 'http://es1:9200,http://es2:9200'. "
"Required when external_logging_provider is 'elasticsearch'."
),
)
elasticsearch_index_prefix: str = Field(
default="bangui",
description=(
"Prefix for Elasticsearch indices where logs are stored. "
"Final index names will be '{prefix}-{date}' or similar."
),
)
elasticsearch_batch_size: int = Field(
default=10,
ge=1,
description=(
"Number of log documents to batch before sending to Elasticsearch. "
"Larger batches are more efficient but introduce slight latency."
),
)
# Rate limit configuration (per IP)
rate_limit_bans_per_minute: int = Field(
default=100,
ge=1,
description="Max ban/unban requests per IP per minute.",
)
rate_limit_blocklist_import_per_hour: int = Field(
default=10,
ge=1,
description="Max blocklist import requests per IP per hour.",
)
rate_limit_config_update_per_minute: int = Field(
default=50,
ge=1,
description="Max config update requests per IP per minute.",
)
# -------------------------------------------------------------------------
# Pagination & display limits (configurable per deployment)
# -------------------------------------------------------------------------
max_page_size: int = Field(
default=500,
ge=1,
le=10000,
description=(
"Maximum number of records returned per paginated API response. "
"Individual endpoints may further limit this value. "
"Must be between 1 and 10000."
),
)
blocklist_preview_max_lines: int = Field(
default=100,
ge=1,
description=(
"Maximum number of IP lines returned in a blocklist source preview. "
"Must be at least 1."
),
)
history_retention_days: int = Field(
default=90,
ge=1,
description=(
"Number of days historical ban records are retained before being "
"archived or purged by the cleanup task. Must be at least 1."
),
)
@field_validator("elasticsearch_hosts", mode="before")
@classmethod
def _normalize_elasticsearch_hosts(cls, value: str | list[str] | None) -> list[str]:
"""Normalize elasticsearch_hosts from comma-separated string to list.
Args:
value: A comma-separated string or list of host URLs.
Returns:
A list of normalized host URLs.
"""
if value is None or (isinstance(value, list) and len(value) == 0):
return []
if isinstance(value, str):
return [host.strip() for host in value.split(",") if host.strip()]
return value
model_config = SettingsConfigDict(
env_prefix="BANGUI_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
@@ -85,4 +618,4 @@ def get_settings() -> Settings:
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
if required keys are absent or values fail validation.
"""
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
return Settings()

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,16 @@ service layers can focus on business logic and formatting.
from __future__ import annotations
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
import aiosqlite
from app.utils.fail2ban_db_utils import escape_like
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import Iterable
from app.models.ban import BanOrigin
@@ -70,6 +74,53 @@ def _make_db_uri(db_path: str) -> str:
return f"file:{db_path}?mode=ro"
async def _acquire_readonly_connection(
db_path: str,
) -> aiosqlite.Connection:
"""Open a read-only connection to the fail2ban database.
Defense-in-depth: both the ``?mode=ro`` URI flag AND the SQLite-level
``PRAGMA query_only = ON`` are applied. The URI flag is a library-level hint
that can be bypassed by malformed URIs or version inconsistencies;
``query_only`` is a connection-level enforcement that makes all write
operations fail. We verify enforcement by reading back the PRAGMA value.
Args:
db_path: Path to the fail2ban SQLite database.
Returns:
An aiosqlite connection in guaranteed read-only mode.
Raises:
AssertionError: If PRAGMA query_only is not confirmed as enabled.
"""
conn = await aiosqlite.connect(_make_db_uri(db_path), uri=True)
# Set connection-level read-only enforcement and verify in one statement.
# Even if the ?mode=ro URI flag is bypassed, this PRAGMA blocks writes.
cursor = await conn.execute("PRAGMA query_only = ON")
await cursor.close()
# Verify the PRAGMA took effect.
cursor = await conn.execute("PRAGMA query_only")
row = await cursor.fetchone()
await cursor.close()
if not row or row[0] != 1:
await conn.close()
raise AssertionError(
"PRAGMA query_only is not enabled; connection may be writable"
)
return conn
@asynccontextmanager
async def _readonly_connection(db_path: str) -> AsyncIterator[aiosqlite.Connection]:
"""Async context manager that yields a read-only fail2ban DB connection."""
conn = await _acquire_readonly_connection(db_path)
try:
yield conn
finally:
await conn.close()
def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
"""Return a SQL fragment and parameters for the origin filter."""
@@ -114,7 +165,7 @@ def _rows_to_history_records(rows: Iterable[aiosqlite.Row]) -> list[HistoryRecor
async def check_db_nonempty(db_path: str) -> bool:
"""Return True if the fail2ban database contains at least one ban row."""
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db, db.execute(
async with _readonly_connection(db_path) as db, db.execute(
"SELECT 1 FROM bans LIMIT 1"
) as cur:
row = await cur.fetchone()
@@ -126,6 +177,7 @@ async def get_currently_banned(
since: int,
origin: BanOrigin | None = None,
*,
ip_filter: list[str] | None = None,
limit: int | None = None,
offset: int | None = None,
) -> tuple[list[BanRecord], int]:
@@ -135,6 +187,7 @@ async def get_currently_banned(
db_path: File path to the fail2ban SQLite database.
since: Unix timestamp to filter bans newer than or equal to.
origin: Optional origin filter.
ip_filter: Optional list of IP addresses to restrict the result to.
limit: Optional maximum number of rows to return.
offset: Optional offset for pagination.
@@ -142,14 +195,21 @@ async def get_currently_banned(
A ``(records, total)`` tuple.
"""
origin_clause, origin_params = _origin_sql_filter(origin)
if ip_filter is not None and len(ip_filter) == 0:
return [], 0
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
origin_clause, origin_params = _origin_sql_filter(origin)
ip_filter_clause = ""
if ip_filter is not None:
placeholder = ", ".join("?" for _ in ip_filter)
ip_filter_clause = f" AND ip IN ({placeholder})"
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause,
(since, *origin_params),
"SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause + ip_filter_clause,
(since, *origin_params, *(ip_filter or [])),
) as cur:
count_row = await cur.fetchone()
total: int = int(count_row[0]) if count_row else 0
@@ -157,9 +217,9 @@ async def get_currently_banned(
query = (
"SELECT jail, ip, timeofban, bancount, data "
"FROM bans "
"WHERE timeofban >= ?" + origin_clause + " ORDER BY timeofban DESC"
"WHERE timeofban >= ?" + origin_clause + ip_filter_clause + " ORDER BY timeofban DESC"
)
params: list[object] = [since, *origin_params]
params: list[object] = [since, *origin_params, *(ip_filter or [])]
if limit is not None:
query += " LIMIT ?"
params.append(limit)
@@ -184,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, "
@@ -214,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 "
@@ -239,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(
@@ -272,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"
@@ -312,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:
@@ -326,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(
@@ -351,7 +411,7 @@ async def get_history_page(
async def get_history_for_ip(db_path: str, ip: str) -> list[HistoryRecord]:
"""Return the full ban timeline for a specific IP."""
async with aiosqlite.connect(_make_db_uri(db_path), uri=True) as db:
async with _readonly_connection(db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT jail, ip, timeofban, bancount, data "

View File

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

View File

@@ -2,14 +2,23 @@
Provides persistence APIs for the BanGUI archival history table in the
application database.
Supports both offset-based and cursor-based pagination:
- **Offset pagination** (legacy): ``get_archived_history(page=2, page_size=100)``
- convenient for small datasets but degrades on large offsets.
- **Cursor pagination** (recommended): ``get_archived_history_keyset(page_size=100, last_ban_id=None)``
- constant-time performance regardless of dataset size.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
from app.utils.fail2ban_db_utils import escape_like
if TYPE_CHECKING:
import aiosqlite
@@ -36,17 +45,29 @@ 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,
jail: str | None = None,
ip_filter: 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], 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
wheres: list[str] = []
params: list[object] = []
@@ -59,8 +80,13 @@ async def get_archived_history(
params.append(jail)
if ip_filter is not None:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
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 = ?")
@@ -81,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 ?",
@@ -91,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
]
@@ -108,32 +135,62 @@ async def get_all_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | None = None,
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:
@@ -146,3 +203,302 @@ async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) ->
deleted = cursor.rowcount
await db.commit()
return deleted
async def get_archived_history_keyset(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page_size: int = 100,
last_ban_id: int | None = None,
) -> tuple[list[dict[str, Any]], bool]:
"""Return cursor-paginated archived history using keyset pagination.
Uses keyset pagination (WHERE id < last_id) for constant-time performance
regardless of result set size. This is the recommended pagination method
for large result sets.
Ordering is by timeofban DESC (newest first), with id DESC as tiebreaker for
events with identical timestamps. This ensures stable, deterministic pagination.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
jail: If given, filter to events for this jail.
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
page_size: Number of items per page (max returned is page_size + 1 to detect overflow).
last_ban_id: The ID of the last item from the previous page (for cursor).
None for the first page.
Returns:
A 2-tuple ``(records, has_more)`` where:
- *records* is a list of up to page_size dicts with ban details
- *has_more* is True if there are additional pages beyond this one
"""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return [], False
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
if last_ban_id is not None:
wheres.append("id < ?")
params.append(last_ban_id)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
# Fetch page_size + 1 to detect if there are more pages
fetch_limit = page_size + 1
params.append(fetch_limit)
async with db.execute(
"SELECT id, jail, ip, timeofban, bancount, data, action "
"FROM history_archive "
f"{where_sql} "
"ORDER BY id DESC "
"LIMIT ?", # noqa: S608
params,
) as cur:
rows_iterable = await cur.fetchall()
rows = list(rows_iterable)
records = [
{
"id": int(r[0]),
"jail": str(r[1]),
"ip": str(r[2]),
"timeofban": int(r[3]),
"bancount": int(r[4]),
"data": str(r[5]),
"action": str(r[6]),
}
for r in rows[:page_size]
]
has_more = len(rows) > page_size
return records, has_more
async def get_ip_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict[str, Any]]:
"""Return ban event counts grouped by IP using SQL aggregation.
Uses SQL GROUP BY to aggregate in the database rather than loading
all rows into Python memory. Returns lightweight {ip, event_count} dicts
suitable for downstream aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
jail: If given, filter to events for this jail.
ip_filter: If given, filter by IP (exact match list or LIKE prefix).
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of {ip: str, event_count: int} dicts.
"""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return []
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ? ESCAPE '\\'")
params.append(f"{escape_like(ip_filter)}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
"SELECT ip, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY ip",
params,
) as cur:
rows = await cur.fetchall()
return [
{"ip": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_jail_ban_counts(
db: aiosqlite.Connection,
since: int | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
"""Return per-jail ban counts and total using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: If given, filter to events on or after this Unix timestamp.
origin: If given, filter by ban origin ('blocklist' or 'selfblock').
action: If given, filter to this action type ('ban' or 'unban').
Returns:
A 2-tuple (total_count, jail_counts) where jail_counts is a list
of {jail: str, event_count: int} dicts sorted descending by count.
"""
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
async with db.execute(
f"SELECT COUNT(*) FROM history_archive {where_sql}", params
) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, COUNT(*) AS event_count "
"FROM history_archive "
f"{where_sql} "
"GROUP BY jail "
"ORDER BY event_count DESC",
params,
) as cur:
rows = await cur.fetchall()
return total, [
{"jail": str(r[0]), "event_count": int(r[1])}
for r in rows
]
async def get_ban_counts_by_bucket(
db: aiosqlite.Connection,
since: int,
bucket_secs: int,
num_buckets: int,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[int]:
"""Return ban counts bucketed by time using SQL aggregation.
Args:
db: Active aiosqlite connection.
since: Start of the time window (Unix timestamp).
bucket_secs: Width of each bucket in seconds.
num_buckets: Total number of buckets in the window.
origin: If given, filter by ban origin.
action: If given, filter to this action type ('ban' or 'unban').
Returns:
List of int counts, one per bucket, indexed by bucket index.
"""
wheres: list[str] = ["timeofban >= ?"]
params: list[object] = [since]
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres)
async with db.execute(
"SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, "
"COUNT(*) AS cnt "
"FROM history_archive "
f"{where_sql} GROUP BY bucket_idx ORDER BY bucket_idx",
(since, bucket_secs, *params),
) as cur:
rows = await cur.fetchall()
counts: list[int] = [0] * num_buckets
for row in rows:
idx: int = int(row[0])
if 0 <= idx < num_buckets:
counts[idx] = int(row[1])
return counts

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,46 +10,110 @@ 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.
Returns:
:class:`fastapi.HTTPException` with status 502.
"""
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
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
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,
)
@router.get(
"/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(
socket_path,
geo_batch_lookup=geo_service.lookup_batch,
http_session=http_session,
app_db=app_db,
)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
domain_result = await ban_service.get_active_bans(
socket_path,
geo_cache=geo_cache,
http_session=http_session,
app_db=ban_ctx.db,
)
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)
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
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,
)
@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)
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
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 "*",
)
@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
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,
)

View File

@@ -22,15 +22,26 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly.
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
from app.utils.logging_compat import get_logger
from fastapi import APIRouter, Depends, Query, Request, status
import aiosqlite
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from app.dependencies import AuthDep, get_db
from app.dependencies import (
AuthDep,
BlocklistServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
GlobalRateLimiterDep,
HttpSessionDep,
SchedulerDep,
SettingsDep,
)
from app.exceptions import (
BadRequestError,
BlocklistSourceAlreadyExistsError,
BlocklistSourceNotFoundError,
RateLimitError,
)
from app.mappers import blocklist_mappers
from app.models.blocklist import (
BlocklistListResponse,
BlocklistSource,
@@ -42,12 +53,43 @@ from app.models.blocklist import (
ScheduleConfig,
ScheduleInfo,
)
from app.services import blocklist_service, geo_service
from app.tasks import blocklist_import as blocklist_import_task
from app.services import ban_service, blocklist_service
from app.tasks.blocklist_import import run_import_with_resources
from app.utils.constants import DEFAULT_PAGE_SIZE, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS
router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"])
router: APIRouter = APIRouter(prefix="/api/v1/blocklists", tags=["Blocklists"])
DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
#: Rate limit bucket constants
_BLOCKLIST_IMPORT_BUCKET = "blocklist:import"
# 3600 seconds per hour
_HOUR = 3600
log = get_logger(__name__)
def _check_blocklist_import_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for blocklist import operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_BLOCKLIST_IMPORT_BUCKET, client_ip, RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS, _HOUR
)
if not is_allowed:
log.warning(
"blocklist_import_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for blocklist import. Please try again later.",
retry_after_seconds=retry_after,
)
# ---------------------------------------------------------------------------
@@ -59,21 +101,25 @@ DbDep = Annotated[aiosqlite.Connection, Depends(get_db)]
"",
response_model=BlocklistListResponse,
summary="List all blocklist sources",
responses={
200: {"description": "Blocklist sources returned", "model": BlocklistListResponse},
401: {"description": "Session missing, expired, or invalid"},
},
)
async def list_blocklists(
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistListResponse:
"""Return all configured blocklist source definitions.
Args:
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.blocklist.BlocklistListResponse` with all sources.
"""
sources = await blocklist_service.list_sources(db)
sources = await blocklist_service.list_sources(blocklist_ctx.db)
return BlocklistListResponse(sources=sources)
@@ -82,25 +128,39 @@ async def list_blocklists(
response_model=BlocklistSource,
status_code=status.HTTP_201_CREATED,
summary="Add a new blocklist source",
responses={
201: {"description": "Blocklist source created", "model": BlocklistSource},
400: {"description": "URL validation failed"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "A blocklist source with this URL already exists"},
},
)
async def create_blocklist(
payload: BlocklistSourceCreate,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> BlocklistSource:
"""Create a new blocklist source definition.
Args:
payload: New source data (name, url, enabled).
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Returns:
The newly created :class:`~app.models.blocklist.BlocklistSource`.
Raises:
HTTPException: 400 if URL validation fails.
"""
return await blocklist_service.create_source(
db, payload.name, payload.url, enabled=payload.enabled
)
try:
return await blocklist_service.create_source(
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.
"""
updated = await blocklist_service.update_source(
db,
source_id,
name=payload.name,
url=payload.url,
enabled=payload.enabled,
)
try:
updated = await blocklist_service.update_source(
blocklist_ctx.db,
source_id,
name=payload.name,
url=str(payload.url) if payload.url is not None else None,
enabled=payload.enabled,
)
except ValueError as exc:
raise BadRequestError(str(exc)) from exc
if updated is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
return updated
@@ -306,36 +399,48 @@ async def update_blocklist(
"/{source_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a blocklist source",
responses={
204: {"description": "Blocklist source deleted successfully"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
},
)
async def delete_blocklist(
source_id: int,
db: DbDep,
blocklist_ctx: BlocklistServiceContextDep,
_auth: AuthDep,
) -> None:
"""Delete a blocklist source by id.
Args:
source_id: Primary key of the source to remove.
db: Application database connection (injected).
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
"""
deleted = await blocklist_service.delete_source(db, source_id)
deleted = await blocklist_service.delete_source(blocklist_ctx.db, source_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
@router.get(
"/{source_id}/preview",
response_model=PreviewResponse,
summary="Preview the contents of a blocklist source",
responses={
200: {"description": "Blocklist preview returned", "model": PreviewResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Blocklist source not found"},
502: {"description": "URL could not be reached"},
},
)
async def preview_blocklist(
source_id: int,
request: Request,
db: DbDep,
http_session: HttpSessionDep,
blocklist_ctx: BlocklistServiceContextDep,
settings: SettingsDep,
_auth: AuthDep,
) -> PreviewResponse:
"""Download and preview a sample of a blocklist source.
@@ -345,23 +450,22 @@ async def preview_blocklist(
Args:
source_id: Primary key of the source to preview.
request: Incoming request (used to access the HTTP session).
db: Application database connection (injected).
http_session: Shared HTTP session for downloading.
blocklist_ctx: Blocklist service context containing db and repositories.
_auth: Validated session — enforces authentication.
Raises:
HTTPException: 404 if the source does not exist.
HTTPException: 502 if the URL cannot be reached.
"""
source = await blocklist_service.get_source(db, source_id)
source = await blocklist_service.get_source(blocklist_ctx.db, source_id)
if source is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.")
raise BlocklistSourceNotFoundError(source_id)
http_session: aiohttp.ClientSession = request.app.state.http_session
try:
return await blocklist_service.preview_source(source.url, http_session)
domain_result = await blocklist_service.preview_source(
source.url, http_session, sample_lines=settings.blocklist_preview_max_lines
)
return blocklist_mappers.map_domain_preview_result_to_response(domain_result)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Could not fetch blocklist: {exc}",
) from exc
raise BadRequestError(f"Could not fetch blocklist: {exc}") from exc

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,33 +12,44 @@ Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table,
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from typing import Literal
if TYPE_CHECKING:
import aiohttp
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, Query
from app import __version__
from app.dependencies import AuthDep
from app.dependencies import (
AuthDep,
BanServiceContextDep,
Fail2BanSocketDep,
GeoCacheDep,
HttpSessionDep,
ServerStatusDep,
SettingsDep,
)
from app.mappers import (
map_domain_ban_trend_to_response,
map_domain_bans_by_country_to_response,
map_domain_bans_by_jail_to_response,
map_domain_dashboard_ban_list_to_response,
)
from app.models._common import TimeRange
from app.models.ban import (
BanOrigin,
BansByCountryResponse,
BansByJailResponse,
BanTrendResponse,
DashboardBanListResponse,
TimeRange,
)
from app.models.server import ServerStatus, ServerStatusResponse
from app.services import ban_service, geo_service
from app.services import ban_service
from app.utils.constants import DEFAULT_PAGE_SIZE
router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
router: APIRouter = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
# ---------------------------------------------------------------------------
# Default pagination constants
# ---------------------------------------------------------------------------
_DEFAULT_PAGE_SIZE: int = 100
_DEFAULT_RANGE: TimeRange = "24h"
@@ -46,9 +57,14 @@ _DEFAULT_RANGE: TimeRange = "24h"
"/status",
response_model=ServerStatusResponse,
summary="Return the cached fail2ban server status",
responses={
200: {"description": "Server status returned", "model": ServerStatusResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
async def get_server_status(
request: Request,
server_status: ServerStatusDep,
_auth: AuthDep,
) -> ServerStatusResponse:
"""Return the most recent fail2ban health snapshot.
@@ -58,18 +74,14 @@ async def get_server_status(
returned so the response is always well-formed.
Args:
request: The incoming request (used to access ``app.state``).
server_status: Cached fail2ban server health snapshot (injected).
_auth: Validated session — enforces authentication on this endpoint.
Returns:
:class:`~app.models.server.ServerStatusResponse` containing the
current health snapshot.
"""
cached: ServerStatus = getattr(
request.app.state,
"server_status",
ServerStatus(online=False),
)
cached: ServerStatus = server_status
cached.version = __version__
return ServerStatusResponse(status=cached)
@@ -78,14 +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'."),
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.",
@@ -100,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.
@@ -112,36 +139,50 @@ 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", description="Data source: 'fail2ban' or 'archive'."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
),
country_code: str | None = Query(
default=None,
description="ISO alpha-2 country code to filter companion rows.",
),
) -> BansByCountryResponse:
"""Return ban counts aggregated by ISO country code.
@@ -152,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.
@@ -161,31 +205,39 @@ 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", description="Data source: 'fail2ban' or 'archive'."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -206,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.
@@ -215,27 +268,35 @@ 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", description="Data source: 'fail2ban' or 'archive'."),
source: Literal["fail2ban", "archive"] = Query(
default="fail2ban",
description="Data source: 'fail2ban' or 'archive'.",
),
origin: BanOrigin | None = Query(
default=None,
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
@@ -248,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.
@@ -258,12 +320,11 @@ async def get_bans_by_jail(
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
sorted descending and the total for the selected window.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
return await ban_service.bans_by_jail(
domain_result = await ban_service.bans_by_jail(
socket_path,
range,
source=source,
app_db=request.app.state.db,
app_db=ban_ctx.db,
origin=origin,
)
return map_domain_bans_by_jail_to_response(domain_result)

View File

@@ -31,9 +31,9 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Request, status
from fastapi import APIRouter, Path, status
from app.dependencies import AuthDep
from app.dependencies import AuthDep, Fail2BanConfigDirDep
from app.models.config import (
ActionConfig,
ActionConfigUpdate,
@@ -52,15 +52,8 @@ from app.models.file_config import (
JailConfigFilesResponse,
)
from app.services import raw_config_io_service
from app.services.raw_config_io_service import (
ConfigDirError,
ConfigFileExistsError,
ConfigFileNameError,
ConfigFileNotFoundError,
ConfigFileWriteError,
)
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
router: APIRouter = APIRouter(prefix="/api/v1/config", tags=["Config"])
# ---------------------------------------------------------------------------
# Path type aliases
@@ -73,39 +66,6 @@ _NamePath = Annotated[
str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).")
]
# ---------------------------------------------------------------------------
# Error helpers
# ---------------------------------------------------------------------------
def _not_found(filename: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Config file not found: {filename!r}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
def _conflict(filename: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Config file already exists: {filename!r}",
)
def _service_unavailable(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=message,
)
# ---------------------------------------------------------------------------
# Jail config file endpoints (Task 4a)
# ---------------------------------------------------------------------------
@@ -115,9 +75,14 @@ def _service_unavailable(message: str) -> HTTPException:
"/jail-files",
response_model=JailConfigFilesResponse,
summary="List all jail config files",
responses={
200: {"description": "Jail config files returned", "model": JailConfigFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_jail_config_files(
request: Request,
config_dir: Fail2BanConfigDirDep,
_auth: AuthDep,
) -> JailConfigFilesResponse:
"""Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``.
@@ -126,26 +91,27 @@ async def list_jail_config_files(
file (defaulting to ``true`` when the key is absent).
Args:
request: Incoming request (used for ``app.state.settings``).
config_dir: Config directory path injected from application settings.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.file_config.JailConfigFilesResponse`.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await raw_config_io_service.list_jail_config_files(config_dir)
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return await raw_config_io_service.list_jail_config_files(config_dir)
@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
return await raw_config_io_service.get_jail_config_file(config_dir, filename)
@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
await raw_config_io_service.write_jail_config_file(config_dir, filename, body)
@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
await raw_config_io_service.set_jail_config_enabled(
config_dir, filename, body.enabled
)
@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
filename = await raw_config_io_service.create_jail_config_file(config_dir, body)
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
return await raw_config_io_service.get_filter_file(config_dir, name)
@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
await raw_config_io_service.write_filter_file(config_dir, name, body)
@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
filename = await raw_config_io_service.create_filter_file(config_dir, body)
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
return await raw_config_io_service.list_action_files(config_dir)
@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
return await raw_config_io_service.get_action_file(config_dir, name)
@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
await raw_config_io_service.write_action_file(config_dir, name, body)
@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
filename = await raw_config_io_service.create_action_file(config_dir, body)
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
return await raw_config_io_service.get_parsed_filter_file(config_dir, name)
@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
await raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
# ---------------------------------------------------------------------------
# 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
return await raw_config_io_service.get_parsed_action_file(config_dir, name)
@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
await raw_config_io_service.update_parsed_action_file(config_dir, name, body)
# ---------------------------------------------------------------------------
# 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
return await raw_config_io_service.get_parsed_jail_file(config_dir, filename)
@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
await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)

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