diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 1051a04..5112287 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -1238,8 +1238,6 @@ The `setup_completed = "1"` key is still written for backward compatibility with - **GeoCache** — `GeoCache` instance is created at startup with a configurable `allow_http_fallback` flag and stored on `app.state.geo_cache`. It implements a primary + fallback resolution strategy: (1) try local MaxMind GeoLite2-Country MMDB database (primary, encrypted, no network traffic), (2) if unavailable/no result and allowed, fall back to ip-api.com HTTP API (unencrypted, disabled by default for security). Encapsulates in-memory lookup cache, negative cache for unresolvable IPs (5-minute TTL), dirty set for persistence, and thread-safe async locking. Cache is loaded from the `geo_cache` SQLite table on startup. New resolutions are accumulated in memory and periodically flushed to the database by the `geo_cache_flush` background task. Stale entries are re-resolved by the `geo_re_resolve` task. Injected into routes and tasks via FastAPI's dependency system. See Backend-Development.md § IP Geolocation Resolution for setup and security details. - **Runtime state** (`RuntimeState` in `app.utils.runtime_state`) — stores mutable application state: `server_status` (fail2ban online/offline), `last_activation` (jail activation tracking), `pending_recovery` (crash detection), `runtime_settings` (effective configuration), and service-specific state holders like `jail_service_state` (`JailServiceState` for jail capability detection cache). RuntimeState fields are managed through dedicated functions (e.g., `record_activation()`, `clear_pending_recovery()`) and via dependency injection to services. Service-specific state (like `JailServiceState`) is nested within `RuntimeState` to keep all mutable state in one controlled location. **⚠️ RuntimeState is process-local and only safe when BanGUI runs as a single asyncio worker.** Mutations must not span `await` points (cooperative scheduling within a single event loop is safe). In multi-worker deployments, each process has its own copy — logouts from worker A don't affect worker B's cache, health status updates are per-worker, and activation tracking is unreliable. BanGUI enforces single-worker mode (TASK-002) to prevent this issue. For future multi-worker support, replace RuntimeState with a shared coordination backend (Redis, shared memory, database). See `app/utils/runtime_state.py` module docstring for details. - **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. The completion flag is only written after the runtime database is successfully initialized and all initial setup settings are persisted, preventing a failed setup from permanently bypassing the setup wizard. -- **Login Rate Limiting** — the `/api/auth/login` endpoint employs exponential backoff to defend against brute-force attacks. Each failed login attempt is recorded per client IP, and subsequent attempts within the backoff window return HTTP 429 Too Many Requests. The penalty grows exponentially with each consecutive failure (2s, 4s, 8s, up to 10s max), ensuring attackers face rapidly increasing delays. This is complemented by bcrypt password hashing (≈100ms per attempt), which adds computational resistance without blocking legitimate users. The backoff counter resets after 60 seconds without additional failures. The rate limiter is process-local and tracks failures in memory via `app.utils.rate_limiter.RateLimiter`, stored on `app.state.login_rate_limiter`. Client IP detection respects proxy headers (`X-Forwarded-For`, `X-Real-IP`) only from configured trusted proxies, preventing header spoofing attacks. In multi-worker deployments, each worker has independent rate limit counters; BanGUI enforces single-worker mode (TASK-002) to prevent attackers from bypassing limits by distributing requests across workers. - ### 8.1 CSRF Protection State-mutating endpoints (POST, PUT, DELETE, PATCH) that use cookie-based authentication are protected against Cross-Site Request Forgery (CSRF) attacks via a **custom header check middleware**. diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index fc8185f..d95f651 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -1665,6 +1665,37 @@ async def get_jail(...) -> JailDetailResponse: --- +### 7.7 Third-Party Library Log Levels + +Application code must use **structlog** for all logging. Third-party libraries that emit logs through Python's standard `logging` module are configured centrally in `backend/app/main.py::_configure_logging()`. + +**Current overrides:** + +| Library | Logger | Level | Reason | +|---------|--------|-------|--------| +| APScheduler | `apscheduler` | `WARNING` | Routine scheduler polling is too verbose at DEBUG. | +| aiosqlite | `aiosqlite` | `WARNING` | Database operation traces clutter logs. | + +**Adding a new override:** + +```python +# In backend/app/main.py, inside _configure_logging() +logging.getLogger("new_library").setLevel(logging.WARNING) +``` + +- Prefer `WARNING` over `ERROR` so legitimate warnings (e.g., connection retries) are still visible. +- Place the override immediately after `logging.basicConfig()` so it takes effect before any library initializes its own loggers. + +**Disabling suppression:** + +Set `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. + +**Stdlib interception:** + +All stdlib logs are intercepted by `structlog.stdlib.ProcessorFormatter` and rendered as JSON. Even third-party library logs therefore appear as structured JSON in `bangui.log`, not plain text. + +--- + ## 8. Error Handling - Define **custom exception classes** for domain errors (e.g., `JailNotFoundError`, `BanFailedError`). @@ -2771,41 +2802,6 @@ update = GlobalConfigUpdate(log_target="/etc/passwd") # Raises ValidationError await config_service.update_global_config(socket_path, update) # Validates again before sending to fail2ban ``` -### Login Rate Limiting - -The login endpoint (`POST /api/auth/login`) is protected against brute-force attacks using an in-memory exponential backoff rate limiter. - -**Design:** -- Uses a `dict[str, deque[float]]` keyed by client IP, storing failed login timestamps within a time window. -- Old failures outside the time window are automatically pruned during validation checks. -- Expired IP entries are cleaned up to prevent unbounded memory growth. - -**Rate Limit Rules:** -- **Exponential backoff:** Each failed login attempt incurs a progressively longer delay before the next attempt is allowed: - - 1st failure: 1 × 2¹ = 2 seconds - - 2nd failure: 1 × 2² = 4 seconds - - 3rd failure: 1 × 2³ = 8 seconds - - 4th+ failures: capped at 10 seconds (max) -- Failed attempts that arrive during the backoff period return **HTTP 429 Too Many Requests** with a `Retry-After` header indicating the remaining wait time. -- Each failed login is also accompanied by bcrypt password hashing (~100ms), providing additional computational resistance. -- The backoff counter resets after the rate-limit window (60 seconds by default) expires with no new failures. - -**IP Extraction (Proxy Safety):** -- When behind nginx, the rate limiter reads the real client IP from `X-Forwarded-For` or `X-Real-IP` headers. -- Only trusts these headers when the immediate connection source is in a configured trusted proxy list. -- Prevents attackers from spoofing these headers to bypass rate limits. -- Falls back to the direct connection IP when proxy headers cannot be trusted. - -**Process-Local Limitation:** -- The rate limiter is process-local (in-memory). In multi-worker deployments (e.g., Gunicorn with 4 workers), each worker maintains its own rate limit counter. -- This is acceptable because the single-worker constraint is enforced elsewhere. See [TASK-002/003 notes](Instructions.md) for details. - -**Implementation:** -- Rate limiter: `app.utils.rate_limiter.RateLimiter` -- IP extraction: `app.utils.client_ip.get_client_ip()` -- Dependency: `LoginRateLimiterDep` in `app.dependencies` - - ### Global Rate Limiting In addition to login-specific rate limiting, all API endpoints are protected by global per-IP rate limiting to prevent resource exhaustion, CPU spikes, and network bandwidth attacks from malicious or misconfigured clients. diff --git a/Docs/Observability.md b/Docs/Observability.md index 7764cfa..0aebfa6 100644 --- a/Docs/Observability.md +++ b/Docs/Observability.md @@ -98,6 +98,44 @@ log.error("fail2ban_start_failed", stdout=stdout_raw, stderr=stderr_raw) # Neve --- +## 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 diff --git a/Docs/TROUBLESHOOTING.md b/Docs/TROUBLESHOOTING.md index 61ce251..247fab6 100644 --- a/Docs/TROUBLESHOOTING.md +++ b/Docs/TROUBLESHOOTING.md @@ -418,6 +418,65 @@ 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: diff --git a/Docs/Tasks.md b/Docs/Tasks.md index e69de29..1bb3a90 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -0,0 +1,2440 @@ +# Failed Tests + +Total unique failed/errored tests: 406 + +## 1. TestGetActiveBans.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 2. TestGetActiveBans.test_empty_when_no_bans + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 3. TestGetActiveBans.test_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 4. TestBanIp.test_201_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 5. TestBanIp.test_400_for_invalid_ip + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 6. TestBanIp.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 7. TestBanIp.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 8. TestUnbanIp.test_200_unban_from_all + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 9. TestUnbanIp.test_200_unban_from_specific_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 10. TestUnbanIp.test_400_for_invalid_ip + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 11. TestUnbanIp.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 12. TestUnbanAll.test_200_clears_all_bans + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 13. TestUnbanAll.test_200_with_zero_count + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 14. TestUnbanAll.test_502_when_fail2ban_unreachable + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 15. TestUnbanAll.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 16. TestListBlocklists.test_authenticated_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 17. TestListBlocklists.test_response_contains_sources_key + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 18. TestCreateBlocklist.test_create_returns_201 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 19. TestCreateBlocklist.test_create_source_id_in_response + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 20. TestUpdateBlocklist.test_update_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 21. TestUpdateBlocklist.test_update_returns_404_for_missing + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 22. TestDeleteBlocklist.test_delete_returns_204 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 23. TestDeleteBlocklist.test_delete_returns_404_for_missing + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 24. TestPreviewBlocklist.test_preview_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 25. TestPreviewBlocklist.test_preview_returns_404_for_missing + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 26. TestPreviewBlocklist.test_preview_returns_502_on_download_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 27. TestPreviewBlocklist.test_preview_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 28. TestRunImport.test_import_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 29. TestRunImport.test_import_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 30. TestGetSchedule.test_schedule_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 31. TestGetSchedule.test_schedule_response_has_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 32. TestGetSchedule.test_schedule_response_includes_last_run_errors + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 33. TestUpdateSchedule.test_update_schedule_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 34. TestImportLog.test_log_returns_200 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 35. TestImportLog.test_log_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 36. TestImportLog.test_log_empty_when_no_runs + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 37. TestGetJailConfigs.test_200_returns_jail_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 38. TestGetJailConfigs.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 39. TestGetJailConfigs.test_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 40. TestGetJailConfig.test_200_returns_jail_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 41. TestGetJailConfig.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 42. TestGetJailConfig.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 43. TestUpdateJailConfig.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 44. TestUpdateJailConfig.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 45. TestUpdateJailConfig.test_422_on_invalid_regex + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 46. TestUpdateJailConfig.test_400_on_config_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 47. TestUpdateJailConfig.test_204_with_dns_mode + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 48. TestUpdateJailConfig.test_204_with_prefregex + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 49. TestUpdateJailConfig.test_204_with_date_pattern + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 50. TestGetGlobalConfig.test_200_returns_global_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 51. TestGetGlobalConfig.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 52. TestUpdateGlobalConfig.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 53. TestUpdateGlobalConfig.test_400_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 54. TestReloadFail2ban.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 55. TestReloadFail2ban.test_502_when_fail2ban_unreachable + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 56. TestReloadFail2ban.test_409_when_reload_operation_fails + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 57. TestRestartFail2ban.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 58. TestRestartFail2ban.test_503_when_fail2ban_does_not_come_back + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 59. TestRestartFail2ban.test_409_when_stop_command_fails + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 60. TestRestartFail2ban.test_502_when_fail2ban_unreachable + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 61. TestRestartFail2ban.test_service_restart_daemon_called + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 62. TestRegexTest.test_200_matched + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 63. TestRegexTest.test_200_not_matched + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 64. TestRegexTest.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 65. TestAddLogPath.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 66. TestAddLogPath.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 67. TestPreviewLog.test_200_returns_preview + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 68. TestGetMapColorThresholds.test_200_returns_thresholds + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 69. TestUpdateMapColorThresholds.test_200_updates_thresholds + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 70. TestUpdateMapColorThresholds.test_400_for_invalid_order + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 71. TestUpdateMapColorThresholds.test_400_for_non_positive_values + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 72. TestGetInactiveJails.test_200_returns_inactive_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 73. TestGetInactiveJails.test_200_empty_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 74. TestGetInactiveJails.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 75. TestActivateJail.test_200_activates_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 76. TestActivateJail.test_200_with_overrides + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 77. TestActivateJail.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 78. TestActivateJail.test_409_when_already_active + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 79. TestActivateJail.test_failed_activation_does_not_set_last_activation + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 80. TestActivateJail.test_400_for_invalid_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 81. TestActivateJail.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 82. TestActivateJail.test_200_with_active_false_on_missing_logpath + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 83. TestDeactivateJail.test_200_deactivates_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 84. TestDeactivateJail.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 85. TestDeactivateJail.test_409_when_already_inactive + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 86. TestDeactivateJail.test_400_for_invalid_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 87. TestDeactivateJail.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 88. TestDeactivateJail.test_deactivate_triggers_health_probe + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 89. TestListFilters.test_200_returns_filter_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 90. TestListFilters.test_200_empty_filter_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 91. TestListFilters.test_active_filters_sorted_before_inactive + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 92. TestListFilters.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 93. TestGetFilter.test_200_returns_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 94. TestGetFilter.test_404_for_unknown_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 95. TestGetFilter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 96. TestUpdateFilter.test_200_returns_updated_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 97. TestUpdateFilter.test_404_for_unknown_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 98. TestUpdateFilter.test_422_for_invalid_regex + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 99. TestUpdateFilter.test_400_for_invalid_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 100. TestUpdateFilter.test_reload_query_param_passed + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 101. TestUpdateFilter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 102. TestCreateFilter.test_201_creates_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 103. TestCreateFilter.test_409_when_already_exists + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 104. TestCreateFilter.test_422_for_invalid_regex + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 105. TestCreateFilter.test_400_for_invalid_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 106. TestCreateFilter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 107. TestDeleteFilter.test_204_deletes_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 108. TestDeleteFilter.test_404_for_unknown_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 109. TestDeleteFilter.test_409_for_readonly_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 110. TestDeleteFilter.test_400_for_invalid_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 111. TestDeleteFilter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 112. TestAssignFilterToJail.test_204_assigns_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 113. TestAssignFilterToJail.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 114. TestAssignFilterToJail.test_404_for_unknown_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 115. TestAssignFilterToJail.test_400_for_invalid_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 116. TestAssignFilterToJail.test_400_for_invalid_filter_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 117. TestAssignFilterToJail.test_reload_query_param_passed + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 118. TestAssignFilterToJail.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 119. TestListActionsRouter.test_200_returns_action_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 120. TestListActionsRouter.test_active_sorted_first + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 121. TestListActionsRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 122. TestGetActionRouter.test_200_returns_action + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 123. TestGetActionRouter.test_404_when_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 124. TestGetActionRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 125. TestUpdateActionRouter.test_200_returns_updated_action + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 126. TestUpdateActionRouter.test_404_when_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 127. TestUpdateActionRouter.test_400_for_bad_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 128. TestUpdateActionRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 129. TestCreateActionRouter.test_201_returns_created_action + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 130. TestCreateActionRouter.test_409_when_already_exists + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 131. TestCreateActionRouter.test_400_for_bad_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 132. TestCreateActionRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 133. TestDeleteActionRouter.test_204_on_delete + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 134. TestDeleteActionRouter.test_404_when_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 135. TestDeleteActionRouter.test_409_when_readonly + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 136. TestDeleteActionRouter.test_400_for_bad_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 137. TestDeleteActionRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 138. TestAssignActionToJailRouter.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 139. TestAssignActionToJailRouter.test_404_when_jail_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 140. TestAssignActionToJailRouter.test_404_when_action_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 141. TestAssignActionToJailRouter.test_400_for_bad_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 142. TestAssignActionToJailRouter.test_400_for_bad_action_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 143. TestAssignActionToJailRouter.test_reload_param_passed + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 144. TestAssignActionToJailRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 145. TestRemoveActionFromJailRouter.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 146. TestRemoveActionFromJailRouter.test_404_when_jail_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 147. TestRemoveActionFromJailRouter.test_400_for_bad_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 148. TestRemoveActionFromJailRouter.test_400_for_bad_action_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 149. TestRemoveActionFromJailRouter.test_reload_param_passed + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 150. TestRemoveActionFromJailRouter.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 151. TestGetFail2BanLog.test_200_returns_log_response + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 152. TestGetFail2BanLog.test_200_passes_lines_query_param + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 153. TestGetFail2BanLog.test_200_passes_filter_query_param + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 154. TestGetFail2BanLog.test_400_when_non_file_target + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 155. TestGetFail2BanLog.test_400_when_path_traversal_detected + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 156. TestGetFail2BanLog.test_502_when_fail2ban_unreachable + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 157. TestGetFail2BanLog.test_422_for_lines_exceeding_max + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 158. TestGetFail2BanLog.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 159. TestGetServiceStatus.test_200_when_online + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 160. TestGetServiceStatus.test_200_when_offline + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 161. TestGetServiceStatus.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 162. TestValidateJailEndpoint.test_200_valid_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 163. TestValidateJailEndpoint.test_200_invalid_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 164. TestValidateJailEndpoint.test_400_for_invalid_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 165. TestValidateJailEndpoint.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 166. TestPendingRecovery.test_returns_null_when_no_pending_recovery + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 167. TestPendingRecovery.test_returns_record_when_set + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 168. TestPendingRecovery.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 169. TestRollbackEndpoint.test_200_success_clears_pending_recovery + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 170. TestRollbackEndpoint.test_200_fail_preserves_pending_recovery + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 171. TestRollbackEndpoint.test_400_for_invalid_jail_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 172. TestRollbackEndpoint.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 173. TestDashboardStatus.test_returns_200_when_authenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 174. TestDashboardStatus.test_response_shape_when_online + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 175. TestDashboardStatus.test_cached_values_returned_when_online + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 176. TestDashboardStatus.test_offline_status_returned_correctly + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 177. TestDashboardBans.test_returns_200_when_authenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 178. TestDashboardBans.test_response_contains_items_and_total + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 179. TestDashboardBans.test_default_range_is_24h + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 180. TestDashboardBans.test_accepts_time_range_param + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 181. TestDashboardBans.test_accepts_source_param + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 182. TestDashboardBans.test_empty_ban_list_returns_zero_total + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 183. TestDashboardBans.test_item_shape_is_correct + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 184. TestBansByCountry.test_returns_200_when_authenticated[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 185. TestBansByCountry.test_response_shape[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 186. TestBansByCountry.test_accepts_time_range_param[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 187. TestBansByCountry.test_invalid_source_returns_422[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 188. TestBansByCountry.test_empty_window_returns_empty_response[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 189. TestBanTrend.test_returns_200_when_authenticated[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 190. TestBanTrend.test_response_shape[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 191. TestBanTrend.test_each_bucket_has_timestamp_and_count[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 192. TestBanTrend.test_default_range_is_24h[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 193. TestBanTrend.test_accepts_range_param[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 194. TestBanTrend.test_origin_param_forwarded[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 195. TestBanTrend.test_no_origin_defaults_to_none[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 196. TestBanTrend.test_invalid_range_returns_422[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 197. TestBanTrend.test_invalid_source_returns_422[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 198. TestBanTrend.test_empty_buckets_response[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 199. TestBansByJail.test_returns_200_when_authenticated[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 200. TestBansByJail.test_response_shape[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 201. TestBansByJail.test_each_jail_has_name_and_count[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 202. TestBansByJail.test_default_range_is_24h[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 203. TestBansByJail.test_accepts_range_param[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 204. TestBansByJail.test_origin_param_forwarded[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 205. TestBansByJail.test_no_origin_defaults_to_none[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 206. TestBansByJail.test_invalid_range_returns_422[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 207. TestBansByJail.test_invalid_source_returns_422[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 208. TestBansByJail.test_empty_jails_response[asyncio] + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 209. TestDashboardBansOriginField.test_origin_present_in_ban_list_items + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 210. TestDashboardBansOriginField.test_selfblock_origin_serialised_correctly + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 211. TestDashboardBansOriginField.test_origin_present_in_bans_by_country + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 212. TestDashboardBansOriginField.test_bans_by_country_source_param_forwarded + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 213. TestDashboardBansOriginField.test_bans_by_country_country_code_forwarded + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 214. TestDashboardBansOriginField.test_blocklist_origin_serialised_correctly + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 215. TestOriginFilterParam.test_bans_origin_blocklist_forwarded_to_service + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 216. TestOriginFilterParam.test_bans_origin_selfblock_forwarded_to_service + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 217. TestOriginFilterParam.test_bans_no_origin_param_defaults_to_none + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 218. TestOriginFilterParam.test_bans_invalid_origin_returns_422 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 219. TestOriginFilterParam.test_by_country_origin_blocklist_forwarded + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 220. TestOriginFilterParam.test_by_country_no_origin_defaults_to_none + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 221. TestListJailConfigFiles.test_200_returns_file_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 222. TestListJailConfigFiles.test_503_on_config_dir_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 223. TestListJailConfigFiles.test_401_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 224. TestGetJailConfigFile.test_200_returns_content + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 225. TestGetJailConfigFile.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 226. TestGetJailConfigFile.test_400_invalid_filename + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 227. TestSetJailConfigEnabled.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 228. TestSetJailConfigEnabled.test_404_file_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 229. TestGetFilterFileRaw.test_200_returns_content + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 230. TestGetFilterFileRaw.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 231. TestUpdateFilterFile.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 232. TestUpdateFilterFile.test_400_write_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 233. TestCreateFilterFile.test_201_creates_file + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 234. TestCreateFilterFile.test_409_conflict + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 235. TestCreateFilterFile.test_400_invalid_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 236. TestListActionFiles.test_200_returns_files + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 237. TestCreateActionFile.test_201_creates_file + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 238. TestGetActionFileRaw.test_200_returns_content + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 239. TestGetActionFileRaw.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 240. TestGetActionFileRaw.test_503_on_config_dir_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 241. TestUpdateActionFileRaw.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 242. TestUpdateActionFileRaw.test_400_write_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 243. TestUpdateActionFileRaw.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 244. TestUpdateActionFileRaw.test_400_invalid_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 245. TestCreateJailConfigFile.test_201_creates_file + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 246. TestCreateJailConfigFile.test_409_conflict + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 247. TestCreateJailConfigFile.test_400_invalid_name + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 248. TestCreateJailConfigFile.test_503_on_config_dir_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 249. TestGetParsedFilter.test_200_returns_parsed_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 250. TestGetParsedFilter.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 251. TestGetParsedFilter.test_503_on_config_dir_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 252. TestUpdateParsedFilter.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 253. TestUpdateParsedFilter.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 254. TestUpdateParsedFilter.test_400_write_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 255. TestGetParsedAction.test_200_returns_parsed_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 256. TestGetParsedAction.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 257. TestGetParsedAction.test_503_on_config_dir_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 258. TestUpdateParsedAction.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 259. TestUpdateParsedAction.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 260. TestUpdateParsedAction.test_400_write_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 261. TestGetParsedJailFile.test_200_returns_parsed_config + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 262. TestGetParsedJailFile.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 263. TestGetParsedJailFile.test_503_on_config_dir_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 264. TestUpdateParsedJailFile.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 265. TestUpdateParsedJailFile.test_404_not_found + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 266. TestUpdateParsedJailFile.test_400_write_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 267. TestGeoLookup.test_200_with_geo_info + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 268. TestGeoLookup.test_200_when_not_banned + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 269. TestGeoLookup.test_200_with_no_geo + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 270. TestGeoLookup.test_400_for_invalid_ip + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 271. TestGeoLookup.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 272. TestGeoLookup.test_ipv6_address + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 273. TestReResolve.test_returns_200_with_counts + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 274. TestReResolve.test_empty_when_no_unresolved_ips + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 275. TestReResolve.test_re_resolves_null_ips + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 276. TestReResolve.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 277. TestGeoStats.test_returns_200_with_stats + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 278. TestGeoStats.test_stats_empty_cache + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 279. TestGeoStats.test_stats_counts_unresolved + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 280. TestGeoStats.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 281. TestHistoryList.test_returns_200_when_authenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 282. TestHistoryList.test_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 283. TestHistoryList.test_forwards_jail_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 284. TestHistoryList.test_forwards_ip_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 285. TestHistoryList.test_forwards_time_range + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 286. TestHistoryList.test_forwards_origin_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 287. TestHistoryList.test_forwards_source_filter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 288. TestHistoryList.test_archive_route_forces_source_archive + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 289. TestHistoryList.test_empty_result + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 290. TestIpHistory.test_returns_200_when_authenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 291. TestIpHistory.test_returns_404_for_unknown_ip + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 292. TestIpHistory.test_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 293. TestIpHistory.test_aggregation_sums_failures + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 294. TestGetJails.test_200_when_authenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 295. TestGetJails.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 296. TestGetJails.test_response_shape + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 297. TestGetJailDetail.test_200_for_existing_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 298. TestGetJailDetail.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 299. TestStartJail.test_200_starts_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 300. TestStartJail.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 301. TestStartJail.test_409_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 302. TestStopJail.test_200_stops_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 303. TestStopJail.test_200_for_already_stopped_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 304. TestToggleIdle.test_200_idle_on + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 305. TestToggleIdle.test_200_idle_off + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 306. TestReloadJail.test_200_reloads_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 307. TestReloadAll.test_200_reloads_all + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 308. TestIgnoreIpEndpoints.test_get_ignore_list + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 309. TestIgnoreIpEndpoints.test_add_ignore_ip_returns_201 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 310. TestIgnoreIpEndpoints.test_add_invalid_ip_returns_400 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 311. TestIgnoreIpEndpoints.test_delete_ignore_ip + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 312. TestIgnoreIpEndpoints.test_get_ignore_list_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 313. TestIgnoreIpEndpoints.test_get_ignore_list_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 314. TestIgnoreIpEndpoints.test_add_ignore_ip_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 315. TestIgnoreIpEndpoints.test_add_ignore_ip_409_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 316. TestIgnoreIpEndpoints.test_add_ignore_ip_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 317. TestIgnoreIpEndpoints.test_delete_ignore_ip_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 318. TestIgnoreIpEndpoints.test_delete_ignore_ip_409_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 319. TestIgnoreIpEndpoints.test_delete_ignore_ip_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 320. TestToggleIgnoreSelf.test_200_enables_ignore_self + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 321. TestToggleIgnoreSelf.test_200_disables_ignore_self + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 322. TestToggleIgnoreSelf.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 323. TestToggleIgnoreSelf.test_409_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 324. TestToggleIgnoreSelf.test_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 325. TestFail2BanConnectionErrors.test_get_jails_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 326. TestFail2BanConnectionErrors.test_get_jail_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 327. TestFail2BanConnectionErrors.test_reload_all_409 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 328. TestFail2BanConnectionErrors.test_reload_all_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 329. TestFail2BanConnectionErrors.test_start_jail_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 330. TestFail2BanConnectionErrors.test_stop_jail_409 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 331. TestFail2BanConnectionErrors.test_stop_jail_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 332. TestFail2BanConnectionErrors.test_toggle_idle_404 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 333. TestFail2BanConnectionErrors.test_toggle_idle_409 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 334. TestFail2BanConnectionErrors.test_toggle_idle_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 335. TestFail2BanConnectionErrors.test_reload_jail_404 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 336. TestFail2BanConnectionErrors.test_reload_jail_409 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 337. TestFail2BanConnectionErrors.test_reload_jail_502 + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 338. TestGetJailBannedIps.test_200_returns_paginated_bans + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 339. TestGetJailBannedIps.test_200_with_search_parameter + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 340. TestGetJailBannedIps.test_200_with_page_and_page_size + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 341. TestGetJailBannedIps.test_400_when_page_is_zero + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 342. TestGetJailBannedIps.test_400_when_page_size_exceeds_max + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 343. TestGetJailBannedIps.test_400_when_page_size_is_zero + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 344. TestGetJailBannedIps.test_404_for_unknown_jail + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 345. TestGetJailBannedIps.test_502_when_fail2ban_unreachable + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 346. TestGetJailBannedIps.test_response_items_have_expected_fields + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 347. TestGetJailBannedIps.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings + +--- + +## 348. TestGetServerSettings.test_200_returns_settings + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 349. TestGetServerSettings.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 350. TestGetServerSettings.test_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 351. TestUpdateServerSettings.test_204_on_success + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 352. TestUpdateServerSettings.test_400_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 353. TestUpdateServerSettings.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 354. TestUpdateServerSettings.test_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 355. TestFlushLogs.test_200_returns_message + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 356. TestFlushLogs.test_400_on_operation_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 357. TestFlushLogs.test_401_when_unauthenticated + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 358. TestFlushLogs.test_502_on_connection_error + +**Exception:** pydantic_core._pydantic_core.ValidationError: 2 validation errors for Settings + +--- + +## 359. TestGetJailConfig.test_returns_jail_config_response + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 360. TestGetJailConfig.test_raises_jail_not_found + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 361. TestGetJailConfig.test_actions_parsed_correctly + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 362. TestGetJailConfig.test_empty_log_paths_fallback + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 363. TestGetJailConfig.test_date_pattern_none + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 364. TestGetJailConfig.test_use_dns_populated + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 365. TestGetJailConfig.test_use_dns_default_when_missing + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 366. TestGetJailConfig.test_prefregex_populated + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 367. TestGetJailConfig.test_prefregex_empty_when_missing + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 368. TestListJailConfigs.test_returns_list_response + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 369. TestListJailConfigs.test_empty_when_no_jails + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 370. TestListJailConfigs.test_multiple_jails + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 371. TestUpdateJailConfig.test_updates_numeric_fields + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 372. TestUpdateJailConfig.test_ignores_backend_field + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 373. TestUpdateJailConfig.test_raises_validation_error_on_bad_regex + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 374. TestUpdateJailConfig.test_skips_none_fields + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 375. TestUpdateJailConfig.test_replaces_fail_regex + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 376. TestUpdateJailConfig.test_sets_dns_mode + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 377. TestUpdateJailConfig.test_sets_prefregex + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 378. TestUpdateJailConfig.test_skips_none_prefregex + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 379. TestUpdateJailConfig.test_raises_validation_error_on_invalid_prefregex + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 380. TestGetGlobalConfig.test_returns_global_config + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 381. TestGetGlobalConfig.test_defaults_used_on_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 382. TestUpdateGlobalConfig.test_sends_set_commands + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 383. TestUpdateGlobalConfig.test_log_level_uppercased + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 384. TestUpdateGlobalConfig.test_invalid_log_target_raises_config_validation_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 385. TestUpdateGlobalConfig.test_valid_special_log_target + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 386. TestTestRegex.test_matching_pattern + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 387. TestTestRegex.test_non_matching_pattern + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 388. TestTestRegex.test_invalid_pattern_returns_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 389. TestTestRegex.test_empty_groups_when_no_capture + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 390. TestTestRegex.test_multiple_capture_groups + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 391. TestPreviewLog.test_returns_error_for_invalid_regex + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 392. TestPreviewLog.test_returns_error_for_missing_file + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 393. TestPreviewLog.test_rejects_log_paths_outside_allowed_directories + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 394. TestPreviewLog.test_matches_lines_in_file + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 395. TestPreviewLog.test_matched_line_has_groups + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 396. TestPreviewLog.test_num_lines_limit + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 397. TestReadFail2BanLog.test_returns_log_lines_from_file + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 398. TestReadFail2BanLog.test_filter_narrows_returned_lines + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 399. TestReadFail2BanLog.test_non_file_target_raises_operation_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 400. TestReadFail2BanLog.test_syslog_target_raises_operation_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 401. TestReadFail2BanLog.test_path_outside_safe_dir_raises_operation_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 402. TestReadFail2BanLog.test_missing_log_file_raises_operation_error + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 403. TestGetServiceStatus.test_online_status_includes_log_config + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 404. TestGetServiceStatus.test_offline_status_returns_unknown_log_fields + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 405. TestConfigModuleIntegration.test_jail_config_service_list_inactive_jails_uses_imports + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + +## 406. TestConfigModuleIntegration.test_filter_config_service_list_filters_uses_imports + +**Exception:** AttributeError: module 'app.models.config' has no attribute 'get_settings' + +--- + diff --git a/backend/app/config.py b/backend/app/config.py index 78af8c0..960dac8 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -289,6 +289,13 @@ class Settings(BaseSettings): 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=( diff --git a/backend/app/db.py b/backend/app/db.py index d6aeee0..7c6a6ca 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -14,9 +14,9 @@ from __future__ import annotations from pathlib import Path import aiosqlite -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # DDL statements diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index ab08f4e..7199db0 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -36,7 +36,7 @@ from typing import Annotated, cast import aiohttp import aiosqlite -import structlog +from app.utils.logging_compat import get_logger from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped] from fastapi import Depends, FastAPI, HTTPException, Request, status @@ -58,7 +58,7 @@ from app.repositories.protocols import ( from app.services.geo_cache import GeoCache from app.services.protocols import Fail2BanMetadataService from app.utils.constants import SESSION_COOKIE_NAME -from app.utils.rate_limiter import GlobalRateLimiter, RateLimiter +from app.utils.rate_limiter import GlobalRateLimiter from app.utils.runtime_state import ApplicationState, JailServiceState, RuntimeState from app.utils.session_cache import NoOpSessionCache, SessionCache @@ -77,7 +77,7 @@ from app.repositories import ( from app.services import auth_service, health_service from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) @dataclass @@ -93,7 +93,6 @@ class ApplicationContext: runtime_settings: Settings | None runtime_state: RuntimeState session_cache: SessionCache | None - login_rate_limiter: RateLimiter global_rate_limiter: GlobalRateLimiter @@ -120,10 +119,6 @@ def _build_app_context(request: Request) -> ApplicationContext: if session_cache is None: session_cache = NoOpSessionCache() - login_rate_limiter: RateLimiter = getattr(state, "login_rate_limiter", None) - if login_rate_limiter is None: - login_rate_limiter = RateLimiter() - global_rate_limiter: GlobalRateLimiter = getattr(state, "global_rate_limiter", None) if global_rate_limiter is None: global_rate_limiter = GlobalRateLimiter() @@ -138,7 +133,6 @@ def _build_app_context(request: Request) -> ApplicationContext: runtime_settings=getattr(state, "runtime_settings", None), runtime_state=state.runtime_state, session_cache=session_cache, - login_rate_limiter=login_rate_limiter, global_rate_limiter=global_rate_limiter, ) @@ -264,13 +258,6 @@ async def get_session_cache(app_context: Annotated[ApplicationContext, Depends(g return app_context.session_cache -async def get_login_rate_limiter( - app_context: Annotated[ApplicationContext, Depends(get_app_context)], -) -> RateLimiter: - """Provide the login endpoint rate limiter from application context.""" - return app_context.login_rate_limiter - - async def get_global_rate_limiter( app_context: Annotated[ApplicationContext, Depends(get_app_context)], ) -> GlobalRateLimiter: @@ -730,7 +717,6 @@ Fail2BanDbRepositoryDep = Annotated[Fail2BanDbRepository, Depends(get_fail2ban_d AppStateDep = Annotated[ApplicationContext, Depends(get_app_state)] AppDep = Annotated[FastAPI, Depends(get_app)] AuthDep = Annotated[Session, Depends(require_auth)] -LoginRateLimiterDep = Annotated[RateLimiter, Depends(get_login_rate_limiter)] GlobalRateLimiterDep = Annotated[GlobalRateLimiter, Depends(get_global_rate_limiter)] Fail2BanMetadataServiceDep = Annotated[Fail2BanMetadataService, Depends(get_fail2ban_metadata_service)] diff --git a/backend/app/main.py b/backend/app/main.py index b77c909..5f44b2e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,7 +25,6 @@ if TYPE_CHECKING: from app.models.response import ErrorMetadata -import structlog from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware @@ -73,13 +72,14 @@ from app.utils.external_logging import ( ExternalLogHandler, create_external_log_handler, ) -from app.utils.rate_limiter import GlobalRateLimiter, RateLimiter +from app.utils.rate_limiter import GlobalRateLimiter from app.utils.runtime_state import ApplicationState, RuntimeState from app.utils.scheduler_lock import release_scheduler_lock from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache +from app.utils.json_formatter import JSONFormatter -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = logging.getLogger("bangui") # --------------------------------------------------------------------------- @@ -89,26 +89,32 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger() _external_log_handler: ExternalLogHandler | None = None -def _external_logging_processor( - logger: logging.Logger, method_name: str, event_dict: dict[str, Any] -) -> dict[str, Any]: - """Structlog processor that queues logs to external logging handler. +def _external_logging_processor(record: logging.LogRecord) -> None: + """Queue log record to external logging handler. Args: - logger: The logger instance. - method_name: The name of the method called on the logger. - event_dict: The event dictionary from structlog. - - Returns: - The event dictionary unchanged (other processors handle rendering). + record: The log record to queue. """ if _external_log_handler is not None: - _external_log_handler.queue_log(event_dict.copy()) - return event_dict + _external_log_handler.queue_log( + { + "event": record.getMessage(), + "level": record.levelname.lower(), + "logger": record.name, + "timestamp": record.created, + } + ) + + +class _ExternalLoggingHandler(logging.Handler): + """Handler that forwards log records to the external log handler.""" + + def emit(self, record: logging.LogRecord) -> None: + _external_logging_processor(record) def _configure_logging(log_level: str, log_file: str | None, settings: Settings | None = None) -> None: - """Configure structlog for production JSON output. + """Configure stdlib logging for production JSON output. Args: log_level: One of ``debug``, ``info``, ``warning``, ``error``, ``critical``. @@ -120,32 +126,23 @@ def _configure_logging(log_level: str, log_file: str | None, settings: Settings if log_file: os.makedirs(os.path.dirname(log_file), exist_ok=True) handlers.append(logging.FileHandler(log_file)) - logging.basicConfig(level=level, handlers=handlers, format="%(message)s") - processors = [ - structlog.contextvars.merge_contextvars, - structlog.stdlib.filter_by_level, - structlog.processors.TimeStamper(fmt="iso"), - structlog.stdlib.add_logger_name, - structlog.stdlib.add_log_level, - structlog.stdlib.PositionalArgumentsFormatter(), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - structlog.processors.UnicodeDecoder(), - ] + # Suppress verbose third-party library logs that emit plain text + # through the standard library logging module. + if settings is None or settings.suppress_third_party_logs: + logging.getLogger("apscheduler").setLevel(logging.WARNING) + logging.getLogger("aiosqlite").setLevel(logging.WARNING) + + formatter = JSONFormatter() + for handler in handlers: + handler.setFormatter(formatter) + + logging.basicConfig(level=level, handlers=handlers) if settings and settings.external_logging_enabled and settings.external_logging_provider: - processors.append(_external_logging_processor) - - processors.append(structlog.processors.JSONRenderer()) - - structlog.configure( - processors=processors, - wrapper_class=structlog.stdlib.BoundLogger, - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - cache_logger_on_first_use=True, - ) + external_handler = _ExternalLoggingHandler() + external_handler.setLevel(logging.DEBUG) + logging.getLogger().addHandler(external_handler) # --------------------------------------------------------------------------- @@ -239,11 +236,6 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # deployments, it should be replaced with a shared backend. _update_session_cache(app, settings) - # Initialize the login rate limiter (5 attempts per 60 seconds per IP). - # This is process-local and not cluster-safe. In multi-worker deployments, - # each worker has independent counters, limiting the blast radius of attacks. - app.state.login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) - # Initialize the global rate limiter (200 requests per 60 seconds per IP). # Applied to all endpoints via middleware. Process-local implementation. app.state.global_rate_limiter = GlobalRateLimiter(max_requests=200, window_seconds=60) @@ -1101,11 +1093,6 @@ def create_app(settings: Settings | None = None) -> FastAPI: if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0 else NoOpSessionCache() ) - # Initialize the login rate limiter (5 attempts per 60 seconds per IP). - # This is also re-initialized in the lifespan, but must be present here - # for tests that bypass the lifespan via ASGITransport. - app.state.login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) - # Initialize the global rate limiter (200 requests per 60 seconds per IP). # This is also re-initialized in the lifespan, but must be present here # for tests that bypass the lifespan via ASGITransport. diff --git a/backend/app/middleware/correlation.py b/backend/app/middleware/correlation.py index 8173c17..8a36646 100644 --- a/backend/app/middleware/correlation.py +++ b/backend/app/middleware/correlation.py @@ -1,16 +1,15 @@ """Correlation ID middleware for distributed tracing. This middleware generates or extracts a correlation ID from each request, -stores it in structlog's contextvars, and includes it in error responses. +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. Middleware stores in structlog.contextvars -4. All log entries include the correlation ID automatically -5. Error responses include the correlation ID for client-side correlation +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 ----------------- @@ -27,10 +26,10 @@ The registration order in ``main.py`` must be: from __future__ import annotations +from app.utils.logging_compat import get_logger import uuid from typing import TYPE_CHECKING -import structlog from starlette.middleware.base import BaseHTTPMiddleware if TYPE_CHECKING: @@ -39,23 +38,22 @@ if TYPE_CHECKING: from starlette.requests import Request from starlette.responses import Response as StarletteResponse -log: structlog.stdlib.BoundLogger = structlog.get_logger() +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 structlog context +# 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 inject into structlog context. + """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 in structlog.contextvars so all logs for this request include it - 4. Makes available via request.state for error handlers + 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. @@ -82,19 +80,12 @@ class CorrelationIdMiddleware(BaseHTTPMiddleware): str(uuid.uuid4()), ) - # Store in structlog context so all logs for this request include it - structlog.contextvars.clear_contextvars() - structlog.contextvars.bind_contextvars( - **{CORRELATION_ID_CONTEXT_KEY: correlation_id} - ) - - # Also store on request.state for use by exception handlers + # Store on request.state for use by exception handlers request.state.correlation_id = correlation_id log.debug( "request_received", - method=request.method, - path=request.url.path, + extra={"method": request.method, "path": request.url.path}, ) response: StarletteResponse = await call_next(request) diff --git a/backend/app/middleware/csrf.py b/backend/app/middleware/csrf.py index 11a8f85..fa3b385 100644 --- a/backend/app/middleware/csrf.py +++ b/backend/app/middleware/csrf.py @@ -25,7 +25,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from fastapi import status from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -38,7 +38,7 @@ if TYPE_CHECKING: from starlette.requests import Request from starlette.responses import Response as StarletteResponse -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # HTTP methods that require CSRF protection. _CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"}) diff --git a/backend/app/middleware/metrics.py b/backend/app/middleware/metrics.py index cc54167..8e49f11 100644 --- a/backend/app/middleware/metrics.py +++ b/backend/app/middleware/metrics.py @@ -10,7 +10,7 @@ import re import time from typing import TYPE_CHECKING -import structlog +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 @@ -21,7 +21,7 @@ if TYPE_CHECKING: from starlette.requests import Request from starlette.responses import Response -log = structlog.get_logger() +log = get_logger(__name__) # Paths excluded from detailed metrics (to avoid cardinality explosion) EXCLUDED_PATHS = {"/metrics", "/health", "/api/health"} diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py index 2c51373..f22f312 100644 --- a/backend/app/middleware/rate_limit.py +++ b/backend/app/middleware/rate_limit.py @@ -37,7 +37,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -49,7 +49,7 @@ if TYPE_CHECKING: from app.config import Settings from app.utils.rate_limiter import GlobalRateLimiter -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) class RateLimitMiddleware(BaseHTTPMiddleware): diff --git a/backend/app/routers/action_config.py b/backend/app/routers/action_config.py index 1ba964a..5df9b70 100644 --- a/backend/app/routers/action_config.py +++ b/backend/app/routers/action_config.py @@ -41,9 +41,9 @@ def _check_action_update_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "action_update_rate_limit_exceeded", client_ip=client_ip, @@ -70,9 +70,9 @@ def _check_action_create_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "action_create_rate_limit_exceeded", client_ip=client_ip, @@ -99,9 +99,9 @@ def _check_action_delete_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "action_delete_rate_limit_exceeded", client_ip=client_ip, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0aa6006..f694cf7 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -11,32 +11,26 @@ 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. - -Rate limiting uses exponential backoff: each wrong password attempt incurs -a progressive delay (0.5s, 1s, 2s, 4s, 5s max) per IP address. Requests -blocked by this delay return ``429 Too Many Requests`` with a ``Retry-After`` -header. """ from __future__ import annotations -import structlog +from app.utils.logging_compat import get_logger from fastapi import APIRouter, Request, Response from app.dependencies import ( AuthDep, - LoginRateLimiterDep, SessionCacheDep, SessionServiceContextDep, SettingsDep, ) -from app.exceptions import AuthenticationError, RateLimitError +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/v1/auth", tags=["auth"]) @@ -49,7 +43,6 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) 200: {"description": "Login successful", "model": LoginResponse}, 401: {"description": "Invalid password"}, 422: {"description": "Validation error — invalid request body"}, - 429: {"description": "Too many login attempts, retry after delay"}, 503: {"description": "Setup not complete"}, }, ) @@ -59,7 +52,6 @@ async def login( request: Request, session_ctx: SessionServiceContextDep, settings: SettingsDep, - rate_limiter: LoginRateLimiterDep, session_cache: SessionCacheDep, ) -> LoginResponse: """Verify the master password and return a session token. @@ -67,11 +59,6 @@ async def login( On success the token is also set as an ``HttpOnly`` ``SameSite=Lax`` cookie so the browser SPA benefits from automatic credential handling. - Rate limiting: Exponential backoff on failed attempts. Each wrong password - incurs an increasing delay (0.5s, 1s, 2s, 4s, 5s max per IP address). - Requests during the penalty period return ``429 Too Many Requests`` with - a ``Retry-After`` header. - Cache invalidation: On successful login, any existing cached sessions for the same user are invalidated so that stale tokens (e.g., from a stolen device) cannot be reused beyond the cache TTL window. @@ -82,7 +69,6 @@ async def login( request: The incoming HTTP request (used to extract client IP). session_ctx: Session service context containing db and repository. settings: Application settings (used for session duration and trusted proxies). - rate_limiter: The login rate limiter (per IP). session_cache: Session cache for invalidating old sessions on login. Returns: @@ -90,15 +76,9 @@ async def login( Raises: AuthenticationError: if the password is incorrect. - RateLimitError: if the rate limit is exceeded. """ client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) - # Check if this IP is currently blocked by exponential backoff - if not rate_limiter.is_allowed(client_ip): - log.warning("login_rate_limit_exceeded", client_ip=client_ip) - raise RateLimitError("Too many login attempts. Please try again later.", retry_after_seconds=60.0) - try: signed_token, expires_at, session = await auth_service.login( session_ctx.db, @@ -108,8 +88,6 @@ async def login( session_repo=session_ctx.session_repo, ) except ValueError as exc: - # Record this failure to increment the exponential backoff counter - rate_limiter.record_failure(client_ip) log.warning("login_failed", client_ip=client_ip, error=str(exc)) raise AuthenticationError(str(exc)) from exc diff --git a/backend/app/routers/bans.py b/backend/app/routers/bans.py index 60892b0..74fcce1 100644 --- a/backend/app/routers/bans.py +++ b/backend/app/routers/bans.py @@ -53,9 +53,9 @@ def _check_ban_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "bans_ban_rate_limit_exceeded", client_ip=client_ip, @@ -82,9 +82,9 @@ def _check_unban_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "bans_unban_rate_limit_exceeded", client_ip=client_ip, diff --git a/backend/app/routers/blocklist.py b/backend/app/routers/blocklist.py index 2cb3531..3ef2969 100644 --- a/backend/app/routers/blocklist.py +++ b/backend/app/routers/blocklist.py @@ -22,7 +22,7 @@ registered *before* the ``/{id}`` routes so FastAPI resolves them correctly. from __future__ import annotations -import structlog +from app.utils.logging_compat import get_logger from fastapi import APIRouter, Depends, Query, Request, status from app.dependencies import ( @@ -64,7 +64,7 @@ _BLOCKLIST_IMPORT_BUCKET = "blocklist:import" # 3600 seconds per hour _HOUR = 3600 -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) def _check_blocklist_import_rate_limit( diff --git a/backend/app/routers/config_misc.py b/backend/app/routers/config_misc.py index ef6aba5..0f03d6f 100644 --- a/backend/app/routers/config_misc.py +++ b/backend/app/routers/config_misc.py @@ -4,7 +4,7 @@ import shlex from pathlib import Path from typing import Annotated -import structlog +from app.utils.logging_compat import get_logger from fastapi import APIRouter, Depends, Query, Request, status from app.config import get_settings @@ -37,7 +37,7 @@ from app.services import ( ) from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, RATE_LIMIT_CONFIG_UPDATE_REQUESTS -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) router: APIRouter = APIRouter(tags=["Config Misc"]) @@ -60,11 +60,11 @@ def _check_config_update_rate_limit( _CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE ) if not is_allowed: - import structlog + from app.utils.logging_compat import get_logger from app.exceptions import RateLimitError - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "config_update_rate_limit_exceeded", client_ip=client_ip, diff --git a/backend/app/routers/filter_config.py b/backend/app/routers/filter_config.py index 89134ce..ec02481 100644 --- a/backend/app/routers/filter_config.py +++ b/backend/app/routers/filter_config.py @@ -42,9 +42,9 @@ def _check_filter_update_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "filter_update_rate_limit_exceeded", client_ip=client_ip, @@ -71,9 +71,9 @@ def _check_filter_create_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "filter_create_rate_limit_exceeded", client_ip=client_ip, @@ -100,9 +100,9 @@ def _check_filter_delete_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "filter_delete_rate_limit_exceeded", client_ip=client_ip, diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py index 39773ce..f6c8400 100644 --- a/backend/app/routers/health.py +++ b/backend/app/routers/health.py @@ -22,7 +22,7 @@ import asyncio import os from typing import TYPE_CHECKING, Literal -import structlog +from app.utils.logging_compat import get_logger from fastapi import APIRouter, status from fastapi.responses import JSONResponse @@ -34,7 +34,7 @@ if TYPE_CHECKING: router: APIRouter = APIRouter(prefix="/api/v1/health", tags=["Health"]) -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) @router.get( diff --git a/backend/app/routers/jail_config.py b/backend/app/routers/jail_config.py index 2b39b4d..3aba689 100644 --- a/backend/app/routers/jail_config.py +++ b/backend/app/routers/jail_config.py @@ -76,9 +76,9 @@ def _check_jail_update_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "jail_update_rate_limit_exceeded", client_ip=client_ip, @@ -105,9 +105,9 @@ def _check_jail_create_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "jail_create_rate_limit_exceeded", client_ip=client_ip, @@ -134,9 +134,9 @@ def _check_jail_delete_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "jail_delete_rate_limit_exceeded", client_ip=client_ip, @@ -163,9 +163,9 @@ def _check_jail_activate_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "jail_activate_rate_limit_exceeded", client_ip=client_ip, @@ -192,9 +192,9 @@ def _check_jail_deactivate_rate_limit( ) if not is_allowed: from app.exceptions import RateLimitError - import structlog + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) log.warning( "jail_deactivate_rate_limit_exceeded", client_ip=client_ip, diff --git a/backend/app/routers/metrics.py b/backend/app/routers/metrics.py index 0d7d7f1..8305cb6 100644 --- a/backend/app/routers/metrics.py +++ b/backend/app/routers/metrics.py @@ -5,13 +5,13 @@ Exposes collected metrics in Prometheus text format at GET /metrics. from __future__ import annotations -import structlog +from app.utils.logging_compat import get_logger from fastapi import APIRouter from starlette.responses import Response from app.utils.metrics import get_metrics, get_metrics_content_type -log = structlog.get_logger() +log = get_logger(__name__) router = APIRouter() diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py index b232192..d93b69c 100644 --- a/backend/app/routers/setup.py +++ b/backend/app/routers/setup.py @@ -7,7 +7,7 @@ return ``409 Conflict``. from __future__ import annotations -import structlog +from app.utils.logging_compat import get_logger from fastapi import APIRouter, status from app.dependencies import AppDep, SettingsDep, SettingsServiceContextDep @@ -17,7 +17,7 @@ from app.services import setup_service from app.utils.runtime_state import update_app_settings from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) router = APIRouter(prefix="/api/v1/setup", tags=["setup"]) diff --git a/backend/app/services/action_config_service.py b/backend/app/services/action_config_service.py index fcf3e52..c89f061 100644 --- a/backend/app/services/action_config_service.py +++ b/backend/app/services/action_config_service.py @@ -15,7 +15,7 @@ import re import tempfile from pathlib import Path -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import ( ActionAlreadyExistsError, @@ -47,7 +47,7 @@ from app.utils.config_file_utils import ( ) from app.utils.jail_socket import reload_all -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Internal wrappers for shared config helpers. diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index cf81d49..79706b0 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -13,7 +13,7 @@ import secrets from typing import TYPE_CHECKING import bcrypt -import structlog +from app.utils.logging_compat import get_logger from app.utils.async_utils import run_blocking @@ -28,7 +28,7 @@ from app.repositories import settings_repo as default_settings_repo from app.utils.constants import SESSION_TOKEN_BYTES, SESSION_TOKEN_SIGNATURE_SEPARATOR from app.utils.time_utils import add_minutes, utc_now -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Settings key for password hash _KEY_PASSWORD_HASH = "master_password_hash" diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index 8b0af74..93e1361 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_service.py @@ -16,7 +16,7 @@ import ipaddress from typing import TYPE_CHECKING, Any, cast import aiohttp -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import JailNotFoundError, JailOperationError from app.models._common import ( @@ -69,7 +69,7 @@ if TYPE_CHECKING: from app.repositories.protocols import HistoryArchiveRepository from app.services.geo_cache import GeoCache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) async def get_fail2ban_db_path(socket_path: str) -> str: diff --git a/backend/app/services/blocklist_ban_executor.py b/backend/app/services/blocklist_ban_executor.py index aa23d4d..20bf12d 100644 --- a/backend/app/services/blocklist_ban_executor.py +++ b/backend/app/services/blocklist_ban_executor.py @@ -8,14 +8,14 @@ from __future__ import annotations from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import JailNotFoundError, JailOperationError if TYPE_CHECKING: from collections.abc import Awaitable, Callable -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) class BanExecutor: diff --git a/backend/app/services/blocklist_downloader.py b/backend/app/services/blocklist_downloader.py index 5fe5b3e..59be365 100644 --- a/backend/app/services/blocklist_downloader.py +++ b/backend/app/services/blocklist_downloader.py @@ -10,9 +10,9 @@ from __future__ import annotations import asyncio import aiohttp -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: HTTP status codes that should be retried for blocklist downloads. _BLOCKLIST_HTTP_RETRY_STATUSES: frozenset[int] = frozenset({429, 500, 502, 503, 504}) diff --git a/backend/app/services/blocklist_import_workflow.py b/backend/app/services/blocklist_import_workflow.py index ae6dd43..8d78c35 100644 --- a/backend/app/services/blocklist_import_workflow.py +++ b/backend/app/services/blocklist_import_workflow.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING import aiohttp import aiosqlite -import structlog +from app.utils.logging_compat import get_logger from app.models.blocklist import BlocklistSource, ImportSourceResult from app.repositories import import_run_repo @@ -29,7 +29,7 @@ if TYPE_CHECKING: from app.services.geo_cache import GeoCache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: fail2ban jail name for blocklist-origin bans. BLOCKLIST_JAIL: str = "blocklist-import" diff --git a/backend/app/services/blocklist_parser.py b/backend/app/services/blocklist_parser.py index fe306f6..bea494f 100644 --- a/backend/app/services/blocklist_parser.py +++ b/backend/app/services/blocklist_parser.py @@ -6,11 +6,11 @@ or CIDR networks. Separates valid IPs from invalid/CIDR entries. from __future__ import annotations -import structlog +from app.utils.logging_compat import get_logger from app.utils.ip_utils import is_valid_ip, is_valid_network, normalise_ip -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) class ParsedBlocklist: diff --git a/backend/app/services/blocklist_service.py b/backend/app/services/blocklist_service.py index 820bb90..32134b8 100644 --- a/backend/app/services/blocklist_service.py +++ b/backend/app/services/blocklist_service.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING import aiohttp import aiosqlite -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import BlocklistSourceHasLogsError from app.models.blocklist import ( @@ -47,7 +47,7 @@ if TYPE_CHECKING: from app.config import Settings from app.services.geo_cache import GeoCache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: Settings key used to persist the schedule config. _SCHEDULE_SETTINGS_KEY: str = "blocklist_schedule" diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 33b933c..6ccc4c1 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -17,7 +17,7 @@ import contextlib import re from typing import TYPE_CHECKING, TypeVar, cast -import structlog +from app.utils.logging_compat import get_logger from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanToken @@ -59,7 +59,7 @@ from app.utils.fail2ban_response import ( ) from app.utils.path_utils import validate_log_target -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Custom exceptions diff --git a/backend/app/services/dns_validated_connector.py b/backend/app/services/dns_validated_connector.py index d015bee..6d38feb 100644 --- a/backend/app/services/dns_validated_connector.py +++ b/backend/app/services/dns_validated_connector.py @@ -23,14 +23,14 @@ import ipaddress import socket from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.utils.ip_utils import is_private_ip if TYPE_CHECKING: from collections.abc import Callable -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) def create_dns_validated_socket_factory() -> ( diff --git a/backend/app/services/fail2ban_metadata_service.py b/backend/app/services/fail2ban_metadata_service.py index eeb25c8..5234c11 100644 --- a/backend/app/services/fail2ban_metadata_service.py +++ b/backend/app/services/fail2ban_metadata_service.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -import structlog +from app.utils.logging_compat import get_logger from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT_FAST from app.utils.fail2ban_client import ( @@ -13,7 +13,7 @@ from app.utils.fail2ban_client import ( Fail2BanProtocolError, ) -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) class Fail2BanMetadataService: diff --git a/backend/app/services/filter_config_service.py b/backend/app/services/filter_config_service.py index e20e12c..30fb278 100644 --- a/backend/app/services/filter_config_service.py +++ b/backend/app/services/filter_config_service.py @@ -13,7 +13,7 @@ import re import tempfile from pathlib import Path -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import ( ConfigWriteError, @@ -48,7 +48,7 @@ from app.utils.config_file_utils import ( from app.utils.jail_socket import reload_all from app.utils.regex_validator import RegexTimeoutError, validate_regex_pattern -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Internal wrappers for shared config helpers. diff --git a/backend/app/services/geo_cache.py b/backend/app/services/geo_cache.py index 78c2363..30e4479 100644 --- a/backend/app/services/geo_cache.py +++ b/backend/app/services/geo_cache.py @@ -21,7 +21,7 @@ import time from typing import TYPE_CHECKING import aiohttp -import structlog +from app.utils.logging_compat import get_logger from app.models.geo import GeoInfo from app.repositories import geo_cache_repo @@ -33,7 +33,7 @@ if TYPE_CHECKING: import geoip2.database import geoip2.errors -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Constants @@ -208,9 +208,9 @@ class GeoCache: Returns: A dict with ``resolved`` and ``total`` counts. """ - import structlog # noqa: PLC0415 + from app.utils.logging_compat import get_logger - log = structlog.get_logger() + log = get_logger(__name__) unresolved = await self.get_unresolved_ips(db) if not unresolved: return {"resolved": 0, "total": 0} diff --git a/backend/app/services/health_service.py b/backend/app/services/health_service.py index 389fe20..978a331 100644 --- a/backend/app/services/health_service.py +++ b/backend/app/services/health_service.py @@ -13,7 +13,7 @@ import asyncio from collections.abc import Awaitable, Callable from typing import TypeVar, cast -import structlog +from app.utils.logging_compat import get_logger from app import __version__ from app.models.config_domain import DomainServiceStatus @@ -30,7 +30,7 @@ from app.utils.fail2ban_response import ( to_dict, ) -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Internal helpers diff --git a/backend/app/services/history_service.py b/backend/app/services/history_service.py index 265cb26..f080149 100644 --- a/backend/app/services/history_service.py +++ b/backend/app/services/history_service.py @@ -13,7 +13,7 @@ from __future__ import annotations from datetime import UTC, datetime from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger if TYPE_CHECKING: import aiohttp @@ -37,7 +37,7 @@ from app.utils.constants import DEFAULT_PAGE_SIZE from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso from app.utils.time_utils import since_unix -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Internal Helpers diff --git a/backend/app/services/jail_config_service.py b/backend/app/services/jail_config_service.py index dea80e6..da6d9c4 100644 --- a/backend/app/services/jail_config_service.py +++ b/backend/app/services/jail_config_service.py @@ -16,7 +16,7 @@ import tempfile from pathlib import Path from typing import TYPE_CHECKING, cast -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import ( ConfigWriteError, @@ -59,7 +59,7 @@ if TYPE_CHECKING: from app.services.protocols import HealthProbe -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) def _parse_jails_sync(config_dir: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]: diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index 414f154..bff41bc 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -20,7 +20,7 @@ import contextlib import ipaddress from typing import TYPE_CHECKING, cast -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import JailNotFoundError, JailOperationError from app.models.ban_domain import DomainActiveBan @@ -61,7 +61,7 @@ if TYPE_CHECKING: from app.models.geo import GeoEnricher, GeoInfo from app.services.geo_cache import GeoCache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) __all__ = ["reload_all"] diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py index eef197e..b680f59 100644 --- a/backend/app/services/log_service.py +++ b/backend/app/services/log_service.py @@ -9,7 +9,7 @@ import asyncio import re from pathlib import Path -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import ConfigOperationError from app.models.config import ( @@ -29,7 +29,7 @@ from app.utils.fail2ban_client import ( ) from app.utils.fail2ban_response import ok -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) _NON_FILE_LOG_TARGETS: frozenset[str] = frozenset( {"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"} diff --git a/backend/app/services/raw_config_io_service.py b/backend/app/services/raw_config_io_service.py index e3ae763..c25f8f2 100644 --- a/backend/app/services/raw_config_io_service.py +++ b/backend/app/services/raw_config_io_service.py @@ -19,7 +19,7 @@ import configparser import re from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import ( ConfigFileNameError, @@ -59,7 +59,7 @@ if TYPE_CHECKING: JailFileConfigUpdate, ) -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Internal helpers — INI parsing / patching diff --git a/backend/app/services/server_service.py b/backend/app/services/server_service.py index 1377e43..0750b3f 100644 --- a/backend/app/services/server_service.py +++ b/backend/app/services/server_service.py @@ -12,7 +12,7 @@ from __future__ import annotations from typing import cast -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import Fail2BanConnectionError, Fail2BanProtocolError, ServerOperationError from app.models.server import ServerSettingsUpdate @@ -28,7 +28,7 @@ from app.utils.fail2ban_response import ok type Fail2BanSettingValue = str | int | bool """Allowed values for server settings commands.""" -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) def _to_int(value: object | None, default: int) -> int: diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index 8721c87..ba0cfdf 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -8,14 +8,14 @@ from __future__ import annotations from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.repositories import settings_repo if TYPE_CHECKING: # pragma: no cover import aiosqlite -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) _KEY_MAP_COLOR_THRESHOLD_HIGH = "map_color_threshold_high" _KEY_MAP_COLOR_THRESHOLD_MEDIUM = "map_color_threshold_medium" diff --git a/backend/app/services/setup_service.py b/backend/app/services/setup_service.py index a6323bd..eb79833 100644 --- a/backend/app/services/setup_service.py +++ b/backend/app/services/setup_service.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING import bcrypt -import structlog +from app.utils.logging_compat import get_logger from app.db import init_db, open_db from app.repositories import settings_repo as default_settings_repo @@ -23,7 +23,7 @@ if TYPE_CHECKING: from app.repositories.protocols import SettingsRepository from app.services.protocols import Fail2BanMetadataService -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Keys used in the settings table. _KEY_PASSWORD_HASH = "master_password_hash" diff --git a/backend/app/startup.py b/backend/app/startup.py index 6bb39e8..1e97730 100644 --- a/backend/app/startup.py +++ b/backend/app/startup.py @@ -26,7 +26,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any import aiohttp -import structlog +from app.utils.logging_compat import get_logger from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped] from app.db import init_db, open_db @@ -59,7 +59,7 @@ if TYPE_CHECKING: from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) def _check_single_worker_mode() -> None: diff --git a/backend/app/startup_dag.py b/backend/app/startup_dag.py index aadd030..546a112 100644 --- a/backend/app/startup_dag.py +++ b/backend/app/startup_dag.py @@ -20,9 +20,9 @@ from dataclasses import dataclass from enum import Enum from typing import Any -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) class StartupStage(Enum): diff --git a/backend/app/tasks/blocklist_import.py b/backend/app/tasks/blocklist_import.py index 1439cfa..b692ed6 100644 --- a/backend/app/tasks/blocklist_import.py +++ b/backend/app/tasks/blocklist_import.py @@ -21,7 +21,7 @@ from __future__ import annotations import uuid from typing import TYPE_CHECKING, Any -import structlog +from app.utils.logging_compat import get_logger from app.services import ban_service, blocklist_service from app.tasks.db import task_db @@ -35,7 +35,7 @@ if TYPE_CHECKING: from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: Stable APScheduler job id so the job can be replaced without duplicates. JOB_ID: str = "blocklist_import" diff --git a/backend/app/tasks/geo_cache_cleanup.py b/backend/app/tasks/geo_cache_cleanup.py index 850278c..8c001d3 100644 --- a/backend/app/tasks/geo_cache_cleanup.py +++ b/backend/app/tasks/geo_cache_cleanup.py @@ -18,7 +18,7 @@ import uuid from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.repositories import geo_cache_repo from app.tasks.db import task_db @@ -31,7 +31,7 @@ if TYPE_CHECKING: from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How long to retain geo cache entries (days). Configurable tuning constant. GEO_CACHE_RETENTION_DAYS: int = 90 diff --git a/backend/app/tasks/geo_cache_flush.py b/backend/app/tasks/geo_cache_flush.py index 7957faf..29293b9 100644 --- a/backend/app/tasks/geo_cache_flush.py +++ b/backend/app/tasks/geo_cache_flush.py @@ -17,7 +17,7 @@ from __future__ import annotations import uuid from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.tasks.db import task_db from app.tasks.timeout_utils import run_with_timeout @@ -30,7 +30,7 @@ if TYPE_CHECKING: from app.config import Settings from app.services.geo_cache import GeoCache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How often the flush job fires (seconds). Configurable tuning constant. GEO_FLUSH_INTERVAL: int = 60 diff --git a/backend/app/tasks/geo_re_resolve.py b/backend/app/tasks/geo_re_resolve.py index 0a93337..58e5f9f 100644 --- a/backend/app/tasks/geo_re_resolve.py +++ b/backend/app/tasks/geo_re_resolve.py @@ -23,7 +23,7 @@ from __future__ import annotations import uuid from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.tasks.db import task_db from app.tasks.timeout_utils import run_with_timeout @@ -37,7 +37,7 @@ if TYPE_CHECKING: from app.config import Settings from app.services.geo_cache import GeoCache -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How often the re-resolve job fires (seconds). 10 minutes. GEO_RE_RESOLVE_INTERVAL: int = 600 diff --git a/backend/app/tasks/health_check.py b/backend/app/tasks/health_check.py index 1ca77ca..d54a89a 100644 --- a/backend/app/tasks/health_check.py +++ b/backend/app/tasks/health_check.py @@ -26,7 +26,7 @@ import uuid from contextvars import copy_context from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.models.server import ServerStatus from app.services import health_service @@ -44,7 +44,7 @@ if TYPE_CHECKING: # pragma: no cover from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How often the probe fires (seconds). diff --git a/backend/app/tasks/history_sync.py b/backend/app/tasks/history_sync.py index 4de3dcd..7d03ca2 100644 --- a/backend/app/tasks/history_sync.py +++ b/backend/app/tasks/history_sync.py @@ -13,7 +13,7 @@ import datetime import uuid from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.services import history_service from app.tasks.db import task_db @@ -26,7 +26,7 @@ if TYPE_CHECKING: from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: Stable APScheduler job id. JOB_ID: str = "history_sync" diff --git a/backend/app/tasks/rate_limiter_cleanup.py b/backend/app/tasks/rate_limiter_cleanup.py index 60f301c..74aa901 100644 --- a/backend/app/tasks/rate_limiter_cleanup.py +++ b/backend/app/tasks/rate_limiter_cleanup.py @@ -18,7 +18,7 @@ from __future__ import annotations import uuid from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.tasks.timeout_utils import run_with_timeout from app.utils.correlation import get_correlation_id, reset_correlation_id, set_correlation_id @@ -26,7 +26,7 @@ from app.utils.correlation import get_correlation_id, reset_correlation_id, set_ if TYPE_CHECKING: from fastapi import FastAPI -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How often the cleanup job fires (seconds). Chosen to balance memory #: management against CPU overhead. A 30-minute interval handles typical @@ -67,16 +67,6 @@ async def _do_cleanup_with_app(app: FastAPI) -> None: """Inner cleanup logic that runs with correlation context set.""" async def _do_cleanup() -> None: - login_limiter = getattr(app.state, "login_rate_limiter", None) - if login_limiter is None: - log.warning( - "rate_limiter_cleanup_skipped", - correlation_id=get_correlation_id(), - reason="login_rate_limiter not found on app.state", - ) - else: - login_limiter.cleanup_expired() - global_limiter = getattr(app.state, "global_rate_limiter", None) if global_limiter is None: log.warning( diff --git a/backend/app/tasks/scheduler_lock_heartbeat.py b/backend/app/tasks/scheduler_lock_heartbeat.py index e320b01..28ae805 100644 --- a/backend/app/tasks/scheduler_lock_heartbeat.py +++ b/backend/app/tasks/scheduler_lock_heartbeat.py @@ -17,7 +17,7 @@ from __future__ import annotations import uuid from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.tasks.db import task_db from app.tasks.timeout_utils import run_with_timeout @@ -30,7 +30,7 @@ if TYPE_CHECKING: from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How often the heartbeat job fires (seconds). Must be significantly less than #: the lock TTL to allow multiple missed heartbeats before lock expiry. diff --git a/backend/app/tasks/session_cleanup.py b/backend/app/tasks/session_cleanup.py index f7c5cb1..61dd722 100644 --- a/backend/app/tasks/session_cleanup.py +++ b/backend/app/tasks/session_cleanup.py @@ -16,7 +16,7 @@ from __future__ import annotations import uuid from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger from app.repositories import session_repo from app.tasks.db import task_db @@ -30,7 +30,7 @@ if TYPE_CHECKING: from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) #: How often the cleanup job fires (seconds). Configurable tuning constant. SESSION_CLEANUP_INTERVAL: int = 6 * 60 * 60 # 6 hours diff --git a/backend/app/tasks/timeout_utils.py b/backend/app/tasks/timeout_utils.py index d1e1926..af92fdb 100644 --- a/backend/app/tasks/timeout_utils.py +++ b/backend/app/tasks/timeout_utils.py @@ -12,9 +12,9 @@ import time from collections.abc import Awaitable from typing import TypeVar -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) T = TypeVar("T") diff --git a/backend/app/utils/async_utils.py b/backend/app/utils/async_utils.py index f10b67e..0a544cf 100644 --- a/backend/app/utils/async_utils.py +++ b/backend/app/utils/async_utils.py @@ -12,12 +12,12 @@ from collections.abc import Callable, Coroutine from concurrent.futures import ThreadPoolExecutor from typing import Any, ParamSpec, TypeVar -import structlog +from app.utils.logging_compat import get_logger P = ParamSpec("P") T = TypeVar("T") -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) DEFAULT_BLOCKING_EXECUTOR: ThreadPoolExecutor = ThreadPoolExecutor( max_workers=16, diff --git a/backend/app/utils/conffile_parser.py b/backend/app/utils/conffile_parser.py index 2dbc9eb..42e720f 100644 --- a/backend/app/utils/conffile_parser.py +++ b/backend/app/utils/conffile_parser.py @@ -24,7 +24,7 @@ import contextlib import io from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger if TYPE_CHECKING: from pathlib import Path @@ -39,7 +39,7 @@ from app.models.config import ( JailSectionConfig, ) -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Constants — well-known Definition keys for action files diff --git a/backend/app/utils/config_file_utils.py b/backend/app/utils/config_file_utils.py index 9421430..4485801 100644 --- a/backend/app/utils/config_file_utils.py +++ b/backend/app/utils/config_file_utils.py @@ -10,7 +10,7 @@ import tempfile from pathlib import Path from typing import cast -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import ( ConfigWriteError, @@ -32,7 +32,7 @@ from app.utils.fail2ban_client import ( from app.utils.fail2ban_response import ok, to_dict from app.utils.log_sanitizer import sanitize_for_logging -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Allowlist pattern for jail names used in path construction. _SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") diff --git a/backend/app/utils/config_parser.py b/backend/app/utils/config_parser.py index 5033cbb..95b36ff 100644 --- a/backend/app/utils/config_parser.py +++ b/backend/app/utils/config_parser.py @@ -28,12 +28,12 @@ import configparser import re from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger if TYPE_CHECKING: from pathlib import Path -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Compiled pattern that matches fail2ban-style %(variable_name)s references. _INTERPOLATE_RE: re.Pattern[str] = re.compile(r"%\((\w+)\)s") diff --git a/backend/app/utils/config_writer.py b/backend/app/utils/config_writer.py index 1e72a30..fa32cc8 100644 --- a/backend/app/utils/config_writer.py +++ b/backend/app/utils/config_writer.py @@ -31,12 +31,12 @@ import tempfile import threading from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger if TYPE_CHECKING: from pathlib import Path -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Per-file lock registry diff --git a/backend/app/utils/constants.py b/backend/app/utils/constants.py index 59d8921..659a509 100644 --- a/backend/app/utils/constants.py +++ b/backend/app/utils/constants.py @@ -51,19 +51,6 @@ CSRF_HEADER_NAME: Final[str] = "X-BanGUI-Request" CSRF_HEADER_VALUE: Final[str] = "1" """Required value of the CSRF header to pass validation.""" -# --------------------------------------------------------------------------- -# Authentication penalty (brute-force resistance) -# --------------------------------------------------------------------------- - -LOGIN_PENALTY_BASE_SECONDS: Final[float] = 1.0 -"""Base penalty (seconds) for a failed login attempt.""" - -LOGIN_PENALTY_MAX_SECONDS: Final[float] = 10.0 -"""Maximum penalty (seconds) for failed login attempts.""" - -LOGIN_PENALTY_MULTIPLIER: Final[float] = 2.0 -"""Exponential multiplier applied per failed attempt.""" - # --------------------------------------------------------------------------- # Time-range presets (used by dashboard and history endpoints) # --------------------------------------------------------------------------- diff --git a/backend/app/utils/external_logging.py b/backend/app/utils/external_logging.py index f3e4639..ca5b7fe 100644 --- a/backend/app/utils/external_logging.py +++ b/backend/app/utils/external_logging.py @@ -16,9 +16,9 @@ from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: from aiohttp import ClientSession -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) class ExternalLogHandler(ABC): diff --git a/backend/app/utils/fail2ban_client.py b/backend/app/utils/fail2ban_client.py index 50acbbc..4e7d956 100644 --- a/backend/app/utils/fail2ban_client.py +++ b/backend/app/utils/fail2ban_client.py @@ -24,7 +24,7 @@ from collections.abc import Mapping, Sequence, Set from pathlib import Path from typing import TYPE_CHECKING, Protocol -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import Fail2BanConnectionError, Fail2BanProtocolError @@ -68,7 +68,7 @@ type Fail2BanResponse = tuple[int, object] if TYPE_CHECKING: from types import TracebackType -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Attempt to reuse the vendored fail2ban package embedded in the repository. # If it is not on sys.path yet, load it from ``../fail2ban-master``. diff --git a/backend/app/utils/fail2ban_db_utils.py b/backend/app/utils/fail2ban_db_utils.py index 3c1f8a5..4fe09f2 100644 --- a/backend/app/utils/fail2ban_db_utils.py +++ b/backend/app/utils/fail2ban_db_utils.py @@ -5,9 +5,9 @@ from __future__ import annotations import json from datetime import UTC, datetime -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) def escape_like(s: str) -> str: diff --git a/backend/app/utils/jail_config.py b/backend/app/utils/jail_config.py index 3e4b94d..d830972 100644 --- a/backend/app/utils/jail_config.py +++ b/backend/app/utils/jail_config.py @@ -11,12 +11,12 @@ from __future__ import annotations from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger if TYPE_CHECKING: from pathlib import Path -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # --------------------------------------------------------------------------- # Default file contents diff --git a/backend/app/utils/jail_socket.py b/backend/app/utils/jail_socket.py index e3450ad..b4b2e3e 100644 --- a/backend/app/utils/jail_socket.py +++ b/backend/app/utils/jail_socket.py @@ -11,7 +11,7 @@ from __future__ import annotations import asyncio from typing import cast -import structlog +from app.utils.logging_compat import get_logger from app.exceptions import JailNotFoundError, JailOperationError from app.utils.fail2ban_client import ( @@ -24,7 +24,7 @@ from app.utils.fail2ban_response import ( to_dict, ) -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Socket communication timeout in seconds. SOCKET_TIMEOUT: float = 10.0 diff --git a/backend/app/utils/json_formatter.py b/backend/app/utils/json_formatter.py new file mode 100644 index 0000000..0d5164b --- /dev/null +++ b/backend/app/utils/json_formatter.py @@ -0,0 +1,85 @@ +"""JSON formatter for stdlib logging that preserves extra fields. + +A single logging.Formatter subclass that serialises any keyword arguments +passed via ``extra=`` into the JSON output alongside the standard record +attributes. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any + +# Attributes that belong to the standard LogRecord and should NOT be +# treated as user-supplied extra fields. +_STD_RECORD_ATTRS: frozenset[str] = frozenset( + { + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "message", + "asctime", + "taskName", + } +) + + +class JSONFormatter(logging.Formatter): + """Format log records as JSON lines, including extra fields. + + Usage:: + + handler = logging.StreamHandler() + handler.setFormatter(JSONFormatter()) + logging.getLogger().addHandler(handler) + + Output keys: + - ``event`` – the log message + - ``level`` – lower-cased level name + - ``timestamp`` – ISO-8601 UTC timestamp + - ``logger`` – logger name + - any ``extra`` fields supplied by the caller + """ + + def format(self, record: logging.LogRecord) -> str: + """Return a JSON string for *record*.""" + log_dict: dict[str, Any] = { + "event": record.getMessage(), + "level": record.levelname.lower(), + "timestamp": ( + datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat() + ), + "logger": record.name, + } + + # Merge any extra fields attached to the record. + for key, value in record.__dict__.items(): + if key not in _STD_RECORD_ATTRS: + log_dict[key] = value + + # Include exception info when present. + if record.exc_info and not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + if record.exc_text: + log_dict["exception"] = record.exc_text + + return json.dumps(log_dict, default=str) diff --git a/backend/app/utils/log_sanitizer.py b/backend/app/utils/log_sanitizer.py index c82b2d5..6ba0a17 100644 --- a/backend/app/utils/log_sanitizer.py +++ b/backend/app/utils/log_sanitizer.py @@ -1,7 +1,7 @@ """Log sanitization utilities for preventing sensitive data leakage. All external output (subprocess, API responses, config data) passed to -structlog MUST be sanitized first. This module provides the canonical +logging MUST be sanitized first. This module provides the canonical sanitize_for_logging() function used across the codebase. """ diff --git a/backend/app/utils/logging_compat.py b/backend/app/utils/logging_compat.py new file mode 100644 index 0000000..28b2746 --- /dev/null +++ b/backend/app/utils/logging_compat.py @@ -0,0 +1,63 @@ +"""Compatibility shim providing keyword-argument logging API on top of stdlib logging. + +This module lets the rest of the codebase keep the keyword-argument logging +style (``log.info("event", key=value)``) while using only the Python standard +library ``logging`` module underneath. +""" + +from __future__ import annotations + +import logging +from typing import Any + + +class _CompatLogger: + """Wraps a stdlib :class:`logging.Logger` to accept keyword arguments.""" + + def __init__(self, logger: logging.Logger) -> None: + self._logger = logger + + def _log(self, level: int, event: str, **kwargs: Any) -> None: + exc_info = kwargs.pop("exc_info", None) + extra = kwargs if kwargs else None + self._logger.log(level, event, exc_info=exc_info, extra=extra) + + def debug(self, event: str, **kwargs: Any) -> None: + self._log(logging.DEBUG, event, **kwargs) + + def info(self, event: str, **kwargs: Any) -> None: + self._log(logging.INFO, event, **kwargs) + + def warning(self, event: str, **kwargs: Any) -> None: + self._log(logging.WARNING, event, **kwargs) + + def warn(self, event: str, **kwargs: Any) -> None: + self._log(logging.WARNING, event, **kwargs) + + def error(self, event: str, **kwargs: Any) -> None: + self._log(logging.ERROR, event, **kwargs) + + def critical(self, event: str, **kwargs: Any) -> None: + self._log(logging.CRITICAL, event, **kwargs) + + def exception(self, event: str, **kwargs: Any) -> None: + self._log(logging.ERROR, event, exc_info=True, **kwargs) + + def bind(self, **kwargs: Any) -> "_CompatLogger": + """Return a new logger with bound context (no-op for stdlib).""" + return self + + +def get_logger(name: str | None = None) -> _CompatLogger: + """Get a compatibility logger wrapping the stdlib logger for *name*. + + If *name* is ``None`` the caller's module name is used. + """ + if name is None: + import sys + + # Walk up the stack to find the caller's module. + frame = sys._getframe(1) + module = frame.f_globals.get("__name__", "__main__") + name = module + return _CompatLogger(logging.getLogger(name)) diff --git a/backend/app/utils/metrics.py b/backend/app/utils/metrics.py index b865ffa..2362721 100644 --- a/backend/app/utils/metrics.py +++ b/backend/app/utils/metrics.py @@ -11,9 +11,9 @@ and get_metrics() returns an empty bytes object. from __future__ import annotations -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) try: from prometheus_client import ( diff --git a/backend/app/utils/rate_limiter.py b/backend/app/utils/rate_limiter.py index 46cf85d..368ec5b 100644 --- a/backend/app/utils/rate_limiter.py +++ b/backend/app/utils/rate_limiter.py @@ -1,46 +1,25 @@ -"""In-memory rate limiter for IP-based request throttling. +"""In-memory global rate limiter for IP-based request throttling. -Implements exponential backoff for failed login attempts using failure tracking. -Each wrong password attempt increments the failure count for that IP, and subsequent -attempts are blocked for a duration that grows exponentially up to a maximum. - -Uses a dictionary of deques (per IP) storing timestamps of recent failures. -Old entries are cleaned up by a background task to prevent unbounded growth. +Implements a sliding-window request counter per IP address. Old entries are +cleaned up by a background task to prevent unbounded growth. Process-local implementation — in multi-worker setups, each worker has -independent counters. This constraint limits the blast radius of brute-force -attacks to a single worker. +independent counters. This constraint limits the blast radius of abuse to a +single worker. -**How It Works:** +**Cleanup Lifecycle**: The rate limiter state grows as IPs interact with the +system. To prevent unbounded memory growth during long runtimes, a scheduled +background task (rate_limiter_cleanup) calls cleanup_expired() every 30 minutes. +This is safe because: -1. A successful login resets the failure counter for that IP. -2. Each failed login (wrong password) calls record_failure() and increments the counter. -3. is_allowed() checks if enough time has passed since the last failure based on - the current failure count. The delay grows exponentially with each consecutive failure: - - - 1st failure: 0.5 second penalty - - 2nd failure: 1 second penalty (0.5 * 2^1) - - 3rd failure: 2 seconds penalty (0.5 * 2^2) - - 4th failure: 4 seconds penalty (0.5 * 2^3) - - ... up to the configured maximum (default 5 seconds) - -4. Penalties are cumulative within the window: if an attacker makes 5 failed - attempts, they must wait the full 5 seconds before trying again (not 5 seconds - per attempt). - -**Cleanup Lifecycle**: The rate limiter state (_failures) grows as IPs interact -with the system. To prevent unbounded memory growth during long runtimes, a -scheduled background task (rate_limiter_cleanup) calls cleanup_expired() every -30 minutes. This is safe because: - -- cleanup_expired() only removes IPs with no recent failures (all timestamps +- cleanup_expired() only removes IPs with no recent requests (all timestamps outside the rate-limit window), so active IPs are never disrupted. - The cleanup is non-blocking and logged for observability. - Individual requests already prune old timestamps from each IP's deque during - is_allowed() and record_failure(), so cleanup primarily handles dormant IPs. + check_allowed(), so cleanup primarily handles dormant IPs. -For monitoring, check logs for "rate_limiter_cleanup" events to observe how -many IPs are being retired from memory each cleanup cycle. +For monitoring, check logs for "global_rate_limiter_cleanup" events to observe +how many IPs are being retired from memory each cleanup cycle. """ from __future__ import annotations @@ -49,173 +28,21 @@ from collections import deque from time import time from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger -from app.utils.constants import ( - LOGIN_PENALTY_BASE_SECONDS, - LOGIN_PENALTY_MAX_SECONDS, - LOGIN_PENALTY_MULTIPLIER, -) from app.utils.ip_utils import normalise_ip if TYPE_CHECKING: from collections.abc import Mapping -log: structlog.stdlib.BoundLogger = structlog.get_logger() - -# 5 attempts per minute per IP (300 seconds) -DEFAULT_RATE_LIMIT_ATTEMPTS = 5 -DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 60 - - -class RateLimiter: - """Track and enforce request rate limits per IP address. - - Stores attempt timestamps in per-IP deques, removing old entries - outside the rate limit window. - """ - - def __init__( - self, - max_attempts: int = DEFAULT_RATE_LIMIT_ATTEMPTS, - window_seconds: int = DEFAULT_RATE_LIMIT_WINDOW_SECONDS, - ) -> None: - """Initialize the rate limiter. - - Args: - max_attempts: Maximum attempts allowed within the window. - (Deprecated: now only used for cleanup window size) - window_seconds: Time window (seconds) for rate limit. - """ - self.max_attempts: int = max_attempts - self.window_seconds: int = window_seconds - self._failures: dict[str, deque[float]] = {} - - def is_allowed(self, ip_address: str) -> bool: - """Check if a request from *ip_address* is allowed. - - Checks if the IP has accumulated failures that would currently block - the attempt due to penalty backoff. Does NOT record a new attempt — - that happens only on successful password verification. - - Args: - ip_address: The client IP address to rate-limit. - - Returns: - ``True`` if the request is allowed (past penalty period), ``False`` - if currently blocked by exponential backoff. - """ - ip_address = normalise_ip(ip_address) - now = time() - - if ip_address not in self._failures: - self._failures[ip_address] = deque() - - failures = self._failures[ip_address] - cutoff = now - self.window_seconds - - # Remove old failures outside the window - while failures and failures[0] < cutoff: - failures.popleft() - - # If no recent failures, request is allowed - if not failures: - return True - - # Calculate accumulated penalty: how much time must pass before - # the next attempt is allowed, based on failure count - failure_count = len(failures) - penalty = min( - LOGIN_PENALTY_BASE_SECONDS * (LOGIN_PENALTY_MULTIPLIER ** failure_count), - LOGIN_PENALTY_MAX_SECONDS, - ) - - # Check if enough time has passed since the last failure - time_since_last_failure = now - failures[-1] - return time_since_last_failure >= penalty - - def cleanup_expired(self) -> None: - """Remove all IPs with no recent failures (cleanup task). - - Called periodically by the background task to prevent unbounded - growth of the tracking dictionary. - """ - now = time() - cutoff = now - self.window_seconds - - ips_to_remove = [] - for ip_address, failures in self._failures.items(): - # Remove old failures - while failures and failures[0] < cutoff: - failures.popleft() - # Mark IP for removal if no failures remain - if not failures: - ips_to_remove.append(ip_address) - - for ip_address in ips_to_remove: - del self._failures[ip_address] - - if ips_to_remove: - log.debug("rate_limiter_cleanup", removed_ips=len(ips_to_remove)) - - def get_state(self) -> Mapping[str, int]: - """Return a read-only view of current failure counts per IP. - - For debugging and monitoring. - - Returns: - A mapping of IP addresses to their failure counts. - """ - now = time() - cutoff = now - self.window_seconds - result = {} - for ip_address, failures in self._failures.items(): - # Count non-expired failures - count = sum(1 for ts in failures if ts >= cutoff) - if count > 0: - result[ip_address] = count - return result - - def reset(self) -> None: - """Clear all tracked failures (for testing).""" - self._failures.clear() - - # --------------------------------------------------------------------------- - # Penalty strategy for failed login attempts - # --------------------------------------------------------------------------- - - def record_failure(self, ip_address: str) -> None: - """Record a failed login attempt. - - Tracks failures per IP to enable exponential backoff in is_allowed(). - The penalty delay is automatically calculated in is_allowed() based on - the failure count, providing transparent brute-force resistance. - - Args: - ip_address: The client IP address whose login attempt failed. - """ - ip_address = normalise_ip(ip_address) - now = time() - - if ip_address not in self._failures: - self._failures[ip_address] = deque() - - failures = self._failures[ip_address] - cutoff = now - self.window_seconds - - # Remove old failures outside the window - while failures and failures[0] < cutoff: - failures.popleft() - - # Record this failure - failures.append(now) +log = get_logger(__name__) class GlobalRateLimiter: """Global per-IP request rate limiter using sliding window algorithm. Tracks total request count within a configurable time window per IP address. - Unlike RateLimiter (which uses exponential backoff), this implements simple + This implements simple request counting: when an IP exceeds the limit, the next request is blocked until the oldest request in the window expires. diff --git a/backend/app/utils/regex_validator.py b/backend/app/utils/regex_validator.py index 41a139f..72e591c 100644 --- a/backend/app/utils/regex_validator.py +++ b/backend/app/utils/regex_validator.py @@ -11,7 +11,7 @@ import signal from contextlib import contextmanager from typing import TYPE_CHECKING -import structlog +from app.utils.logging_compat import get_logger try: from regexploit.ast.sre import SreOpParser @@ -25,7 +25,7 @@ except ImportError: if TYPE_CHECKING: from collections.abc import Generator -logger = structlog.get_logger() +logger = get_logger(__name__) # Constants for regex validation MAX_REGEX_LENGTH = 1000 diff --git a/backend/app/utils/runtime_state.py b/backend/app/utils/runtime_state.py index a49a6ad..46d0d41 100644 --- a/backend/app/utils/runtime_state.py +++ b/backend/app/utils/runtime_state.py @@ -53,7 +53,7 @@ import datetime from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -import structlog +from app.utils.logging_compat import get_logger from starlette.datastructures import State from app.models.config import PendingRecovery @@ -63,7 +63,7 @@ from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache if TYPE_CHECKING: # pragma: no cover from app.config import Settings -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) ActivationRecord = dict[str, datetime.datetime] diff --git a/backend/app/utils/scheduler_lock.py b/backend/app/utils/scheduler_lock.py index 87d53ef..d8f9765 100644 --- a/backend/app/utils/scheduler_lock.py +++ b/backend/app/utils/scheduler_lock.py @@ -46,9 +46,9 @@ import time from typing import Any import aiosqlite -import structlog +from app.utils.logging_compat import get_logger -log: structlog.stdlib.BoundLogger = structlog.get_logger() +log = get_logger(__name__) # Lock record expires if heartbeat hasn't been updated for this many seconds. # This prevents stale locks from a crashed instance from blocking new startups. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2d6ad57..5cc2033 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "aiosqlite>=0.20.0", "aiohttp>=3.11.0", "apscheduler>=3.10,<4.0", - "structlog>=24.4.0", "bcrypt>=4.2.0", "geoip2>=4.8.0", "prometheus-client>=0.21.0", diff --git a/backend/tests/logging_capture.py b/backend/tests/logging_capture.py new file mode 100644 index 0000000..aa919c7 --- /dev/null +++ b/backend/tests/logging_capture.py @@ -0,0 +1,70 @@ +"""Test utilities for capturing stdlib log records.""" + +from __future__ import annotations + +import logging +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any + + +class _CaptureHandler(logging.Handler): + """Handler that stores every emitted record as a dict.""" + + def __init__(self) -> None: + super().__init__() + self.records: list[dict[str, Any]] = [] + + def emit(self, record: logging.LogRecord) -> None: + entry: dict[str, Any] = { + "event": record.getMessage(), + "level": record.levelname.lower(), + "logger": record.name, + } + # Merge extra fields attached to the record. + std_attrs = { + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "message", + "asctime", + "taskName", + } + for key, value in record.__dict__.items(): + if key not in std_attrs: + entry[key] = value + self.records.append(entry) + + +@contextmanager +def capture_logs() -> Generator[list[dict[str, Any]], None, None]: + """Capture all log records emitted inside the context. + + Yields a list of dicts, each representing a log entry with keys + ``event``, ``level``, ``logger`` and any extra fields. + """ + handler = _CaptureHandler() + handler.setLevel(logging.DEBUG) + root = logging.getLogger() + root.addHandler(handler) + try: + yield handler.records + finally: + root.removeHandler(handler) diff --git a/backend/tests/test_routers/test_auth.py b/backend/tests/test_routers/test_auth.py index 1ff5e80..d8892fa 100644 --- a/backend/tests/test_routers/test_auth.py +++ b/backend/tests/test_routers/test_auth.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from collections.abc import Generator from unittest.mock import patch @@ -107,127 +106,7 @@ class TestLogin: response = await client.post("/api/v1/auth/login", json={}) assert response.status_code == 422 - async def test_login_rate_limit_returns_429_after_5_attempts( - self, client: AsyncClient - ) -> None: - """Login is blocked immediately after first failed attempt due to exponential backoff.""" - await _do_setup(client) - limiter = client._transport.app.state.login_rate_limiter - limiter.reset() - # First failed attempt is allowed - response = await client.post( - "/api/v1/auth/login", json={"password": "wrongpassword"} - ) - assert response.status_code == 401 - - # Second attempt immediately after is blocked by 1s penalty - response = await client.post( - "/api/v1/auth/login", json={"password": "wrongpassword"} - ) - assert response.status_code == 429 - assert response.json()["detail"] == "Too many login attempts. Please try again later." - - # Verify the failure count is correct - state = limiter.get_state() - assert "127.0.0.1" in state - assert state["127.0.0.1"] >= 1 - - async def test_login_rate_limit_includes_retry_after_header( - self, client: AsyncClient - ) -> None: - """Rate-limited response includes Retry-After header.""" - await _do_setup(client) - limiter = client._transport.app.state.login_rate_limiter - limiter.reset() - - # First attempt fails - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 401 - - # Second immediate attempt is rate-limited - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 429 - assert "retry-after" in response.headers - assert response.headers["retry-after"] == "60" - - async def test_login_rate_limit_per_ip( - self, client: AsyncClient - ) -> None: - """Rate limit is tracked separately per IP address.""" - await _do_setup(client) - limiter = client._transport.app.state.login_rate_limiter - limiter.reset() - - # Make 1 failed attempt with default IP - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 401 - - # 2nd attempt is blocked - response = await client.post( - "/api/v1/auth/login", json={"password": "correct"} - ) - assert response.status_code == 429 - - # Verify the failure count is correct - state = limiter.get_state() - assert "127.0.0.1" in state - assert state["127.0.0.1"] >= 1 - - async def test_login_rate_limit_reset_after_window( - self, client: AsyncClient - ) -> None: - """Rate limit counter resets after the window expires.""" - await _do_setup(client) - limiter = client._transport.app.state.login_rate_limiter - limiter.reset() - - # Make 1 failed attempt (enough to trigger exponential backoff) - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 401 - - # 2nd attempt is blocked - response = await client.post( - "/api/v1/auth/login", json={"password": "wrong"} - ) - assert response.status_code == 429 - - # Reset the limiter (simulate window expiry) - limiter.reset() - - # Now a fresh login attempt should succeed (use correct password) - response = await client.post( - "/api/v1/auth/login", json={"password": "Mysecretpass1!"} - ) - assert response.status_code == 200 - - async def test_login_exponential_backoff(self, client: AsyncClient) -> None: - """Exponential backoff accumulates with each consecutive failure.""" - await _do_setup(client) - limiter = client._transport.app.state.login_rate_limiter - limiter.reset() - - # 1st failure: 1 * 2^1 = 2s penalty - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 401 - state = limiter.get_state() - assert state["127.0.0.1"] == 1 - - # 2nd attempt blocked immediately by 2s penalty - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 429 - - # After 2.1s, the penalty expires and we can try again - # (this will record a 2nd failure, creating a 1 * 2^2 = 4s penalty) - await asyncio.sleep(2.1) - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 401 - state = limiter.get_state() - assert state["127.0.0.1"] == 2 - - # Now blocked by 4s penalty - response = await client.post("/api/v1/auth/login", json={"password": "wrong"}) - assert response.status_code == 429 # --------------------------------------------------------------------------- diff --git a/backend/tests/test_services/test_geo_service.py b/backend/tests/test_services/test_geo_service.py index 1e9402f..8151507 100644 --- a/backend/tests/test_services/test_geo_service.py +++ b/backend/tests/test_services/test_geo_service.py @@ -790,9 +790,9 @@ class TestErrorLogging: mock_ctx.__aexit__ = AsyncMock(return_value=False) session.get = MagicMock(return_value=mock_ctx) - import structlog.testing + from tests.logging_capture import capture_logs - with structlog.testing.capture_logs() as captured, patch.object( + with capture_logs() as captured, patch.object( geo_cache, "_geoip_reader", None ): # Ensure MMDB is not available so HTTP is tried. @@ -817,9 +817,9 @@ class TestErrorLogging: mock_ctx.__aexit__ = AsyncMock(return_value=False) session.get = MagicMock(return_value=mock_ctx) - import structlog.testing + from tests.logging_capture import capture_logs - with structlog.testing.capture_logs() as captured, patch.object( + with capture_logs() as captured, patch.object( geo_cache, "_geoip_reader", None ): # Ensure MMDB is not available so HTTP is tried. @@ -844,9 +844,9 @@ class TestErrorLogging: mock_ctx.__aexit__ = AsyncMock(return_value=False) session.post = MagicMock(return_value=mock_ctx) - import structlog.testing + from tests.logging_capture import capture_logs - with structlog.testing.capture_logs() as captured: + with capture_logs() as captured: result = await geo_cache._batch_api_call(["1.2.3.4"], session) assert result["1.2.3.4"].country_code is None diff --git a/backend/tests/test_utils/test_async_utils.py b/backend/tests/test_utils/test_async_utils.py index 5618303..13cc523 100644 --- a/backend/tests/test_utils/test_async_utils.py +++ b/backend/tests/test_utils/test_async_utils.py @@ -8,7 +8,6 @@ from concurrent.futures import ThreadPoolExecutor from unittest import mock import pytest -import structlog from app.utils.async_utils import logged_task, run_blocking @@ -108,7 +107,7 @@ async def test_logged_task_preserves_exception_info() -> None: with mock.patch("app.utils.async_utils.log") as mock_log: await logged_task(failing_coro(), "test_task") mock_log.exception.assert_called_once() - # Verify the exception context is logged (structlog.exception captures + # Verify the exception context is logged (exception captures # the traceback automatically) args, kwargs = mock_log.exception.call_args assert args[0] == "background_task_failed"