refactor: Extract fail2ban response utilities into shared module
Consolidate duplicate _ok(), _to_dict(), ensure_list(), and is_not_found_error() functions from 6 service modules into a single canonical implementation at backend/app/utils/fail2ban_response.py. Changes: - Create fail2ban_response.py with canonical implementations - Remove local duplicates from: ban_service, jail_service, config_service, health_service, server_service, config_file_utils - Update all imports to use shared module - Add comprehensive docstrings and examples - Update Architecture.md and Backend-Development.md documentation Benefits: - Single source of truth for response parsing logic - Eliminates code duplication across service layer - Improves maintainability and consistency - Enables centralized bug fixes and improvements Tests: All 228 service tests passing, no regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -135,11 +135,16 @@ backend/
|
||||
│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite)│ │ ├── geo_re_resolve.py # Periodic re-resolution of stale geo cache records│ │ └── health_check.py # Periodic fail2ban connectivity probe
|
||||
│ └── utils/ # Helpers, constants, shared types
|
||||
│ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol
|
||||
│ ├── fail2ban_response.py # Canonical response parsing: ok(), to_dict(), ensure_list(), is_not_found_error()
|
||||
│ ├── fail2ban_db_utils.py # fail2ban database query helpers
|
||||
│ ├── ip_utils.py # IP/CIDR validation and normalisation
|
||||
│ ├── time_utils.py # Timezone-aware datetime helpers│ ├── jail_config.py # Jail config parser/serializer helper
|
||||
│ ├── conffile_parser.py # Fail2ban config file parser/serializer
|
||||
│ ├── time_utils.py # Timezone-aware datetime helpers
|
||||
│ ├── config_file_utils.py # fail2ban config file I/O
|
||||
│ ├── conffile_parser.py # fail2ban config file parser/serializer
|
||||
│ ├── config_parser.py # Structured config object parser
|
||||
│ ├── config_writer.py # Atomic config file write operations│ └── constants.py # Shared constants (default paths, limits, etc.)
|
||||
│ ├── config_writer.py # Atomic config file write operations
|
||||
│ ├── jail_config.py # Jail config helper
|
||||
│ └── constants.py # Shared constants (default paths, limits, etc.)
|
||||
├── tests/
|
||||
│ ├── conftest.py # Shared fixtures (test app, client, mock DB)
|
||||
│ ├── test_routers/ # One test file per router
|
||||
|
||||
@@ -301,7 +301,67 @@ async def test_list_jails_returns_200(client: AsyncClient) -> None:
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuration & Secrets
|
||||
## 11. fail2ban Response Utilities
|
||||
|
||||
All services that interact with the fail2ban daemon must use the canonical response parsing utilities from `app.utils.fail2ban_response`. This ensures consistent error handling, type safety, and makes it easy to fix bugs in response handling across the entire codebase.
|
||||
|
||||
### Available Functions
|
||||
|
||||
**`ok(response: object) -> object`**
|
||||
Extracts the payload from a fail2ban ``(return_code, data)`` response tuple.
|
||||
- Raises `ValueError` if return code ≠ 0 or response shape is invalid.
|
||||
- Use this on every response from `Fail2BanClient.send()`.
|
||||
|
||||
**`to_dict(pairs: object) -> dict[str, object]`**
|
||||
Converts a list of ``(key, value)`` pairs (fail2ban's native response format) to a Python dict.
|
||||
- Silently ignores malformed entries and non-list/tuple inputs.
|
||||
- Always returns a dict (empty if input is invalid).
|
||||
|
||||
**`ensure_list(value: object | None) -> list[str]`**
|
||||
Coerces fail2ban response values (which may be `None`, a single string, or a list) to a normalized list of strings.
|
||||
- Handles all three cases consistently.
|
||||
- Returns empty list for `None` or empty strings.
|
||||
|
||||
**`is_not_found_error(exc: Exception) -> bool`**
|
||||
Checks if an exception indicates a jail does not exist.
|
||||
- Checks for multiple error message patterns (case-insensitive).
|
||||
- Use this to distinguish "jail not found" errors from other failures.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
from app.utils.fail2ban_response import ok, to_dict, ensure_list, is_not_found_error
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
|
||||
client = Fail2BanClient(socket_path="/var/run/fail2ban/fail2ban.sock")
|
||||
|
||||
try:
|
||||
# Get jail status
|
||||
response = await client.send(["status", "sshd", "short"])
|
||||
status_dict = to_dict(ok(response)) # Extract payload and convert to dict
|
||||
|
||||
# Get list of banned IPs
|
||||
ban_response = await client.send(["get", "sshd", "banip"])
|
||||
banned_ips = ensure_list(ok(ban_response)) # Normalize to list of strings
|
||||
|
||||
except ValueError as exc:
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError("sshd") from exc
|
||||
raise
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Before this utility module, every service implemented its own copy of these functions, leading to:
|
||||
- Code duplication across 7+ service files.
|
||||
- Subtle inconsistencies in error handling.
|
||||
- Difficult maintenance — every bug fix required touching multiple files.
|
||||
|
||||
Now, all services import from a single authoritative source, making response handling consistent, maintainable, and type-safe.
|
||||
|
||||
---
|
||||
|
||||
## 12. Configuration & Secrets
|
||||
|
||||
- All configuration lives in **environment variables** loaded through **pydantic-settings**.
|
||||
- Secrets (master password hash, session key) are **never** committed to the repository.
|
||||
|
||||
486
Docs/Tasks.md
486
Docs/Tasks.md
@@ -1,16 +1,480 @@
|
||||
### TASK-QUALITY-06 — `console.log` Leaked in `HistoryPage.test.tsx`
|
||||
### T-01 · Extract `_ok()` / `_to_dict()` into shared util module
|
||||
|
||||
**Where found**
|
||||
`frontend/src/pages/__tests__/HistoryPage.test.tsx` line 8. A `console.log` statement was left in the test file, likely from a debugging session.
|
||||
**Where found:** `backend/app/services/ban_service.py`, `jail_service.py`, `config_service.py`, `health_service.py`, `server_service.py`, `log_service.py`, `utils/config_file_utils.py`
|
||||
|
||||
**Goal**
|
||||
Remove the `console.log` call.
|
||||
**Why this is needed:** The same two helper functions are copy-pasted across 6–7 service modules. `config_service.py` even has a comment admitting it: *"mirrored from jail_service for isolation"*. Any bug fix or behavioural change requires touching every copy independently.
|
||||
|
||||
**Possible traps and issues**
|
||||
- None.
|
||||
**Goal:** Single authoritative implementation. All services import from `app/utils/fail2ban_response.py`.
|
||||
|
||||
**Docs changes needed**
|
||||
None required.
|
||||
**What to do:**
|
||||
1. Create `backend/app/utils/fail2ban_response.py` with `ok()`, `to_dict()`, `ensure_list()`, `is_not_found_error()`.
|
||||
2. Delete local definitions in all service files.
|
||||
3. Import from `fail2ban_response` in each service.
|
||||
|
||||
**Why this is needed**
|
||||
Debug logs in test files pollute the test runner output and make it harder to spot real failures or warnings.
|
||||
**Possible traps and issues:**
|
||||
- `config_file_utils.py` defines `_to_dict_inner` as a nested function inside another function — needs to be unwrapped.
|
||||
- `ban_service._ok` uses `response` typed as `object`; `server_service._ok` types it as `Fail2BanResponse`. Unify the signature.
|
||||
- Run all service tests after the change to confirm no subtle type differences.
|
||||
|
||||
**Docs changes needed:** Update `Docs/Backend-Development.md` to document `fail2ban_response.py` as the canonical response-parsing utility.
|
||||
|
||||
**Doc references:** `Docs/Backend-Development.md`, `Docs/Architekture.md`
|
||||
|
||||
---
|
||||
|
||||
### T-02 · Remove duplicate router-level exception helpers — use global handlers only
|
||||
|
||||
**Where found:** `backend/app/routers/jails.py`, `bans.py`, `jail_config.py`, `server.py`, `config_misc.py` each define `_bad_gateway()`, `_not_found()`, `_conflict()`. Global handlers for the same exceptions exist in `backend/app/main.py`.
|
||||
|
||||
**Why this is needed:** Two parallel error-mapping systems produce inconsistent error bodies. A `Fail2BanConnectionError` caught by a router produces `"Cannot reach fail2ban: {exc}"` while one that escapes produces `"{exc}"` (just the exception string). Adds dead code and maintenance burden.
|
||||
|
||||
**Goal:** All domain exceptions propagate to the global handlers in `main.py`. Routers contain zero HTTP error construction.
|
||||
|
||||
**What to do:**
|
||||
1. Remove all `_bad_gateway`, `_not_found`, `_conflict` helpers from routers.
|
||||
2. Remove the `try/except` blocks in router handlers that convert domain exceptions to `HTTPException` — let them propagate.
|
||||
3. Verify that all domain exception types are covered in `main.py`'s `add_exception_handler` registrations.
|
||||
4. Confirm error body format is consistent across all affected endpoints.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- A few routers catch `ValueError` from service layer and re-raise as `JailOperationError` — ensure those paths still reach the correct global handler.
|
||||
- FastAPI evaluates exception handlers in registration order. Verify the order in `create_app` is most-specific first.
|
||||
- Integration tests that assert on specific error message strings may need updates.
|
||||
|
||||
**Docs changes needed:** `Docs/Backend-Development.md` — document that routers must not construct `HTTPException` for domain errors; they should raise or let domain exceptions propagate.
|
||||
|
||||
**Doc references:** `Docs/Backend-Development.md`
|
||||
|
||||
---
|
||||
|
||||
### T-03 · Centralise `_DEFAULT_PAGE_SIZE` constant
|
||||
|
||||
**Where found:** `backend/app/routers/dashboard.py:45`, `routers/history.py:34`, `services/ban_service.py:70`, `services/history_service.py:49`
|
||||
|
||||
**Why this is needed:** Four independent definitions can drift. The router default and service default are currently coincidentally aligned at 100, but nothing enforces this.
|
||||
|
||||
**Goal:** Single definition in `app/utils/constants.py`, imported everywhere.
|
||||
|
||||
**What to do:**
|
||||
1. Add `DEFAULT_PAGE_SIZE: Final[int] = 100` and `MAX_PAGE_SIZE: Final[int] = 500` to `app/utils/constants.py`.
|
||||
2. Replace all four local `_DEFAULT_PAGE_SIZE` and `_MAX_PAGE_SIZE` declarations with imports.
|
||||
|
||||
**Possible traps and issues:** None significant. Pure search-and-replace.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `app/utils/constants.py`
|
||||
|
||||
---
|
||||
|
||||
### T-04 · Encapsulate `geo_service` module-level mutable state in a class
|
||||
|
||||
**Where found:** `backend/app/services/geo_service.py` — module globals `_cache`, `_neg_cache`, `_dirty`, `_geoip_reader`, `_geoip_initialized`, `_cache_lock`
|
||||
|
||||
**Why this is needed:** Module-level mutable state is invisible to callers, cannot be injected, and requires test-escape-hatch functions (`clear_cache()`) that exist only because the state can't be reset otherwise. It also violates SRP — the module owns both the geo lookup logic *and* the cache lifecycle.
|
||||
|
||||
**Goal:** `GeoCache` class with instance state, instantiated once at startup and stored on `app.state` (consistent with `InMemorySessionCache`).
|
||||
|
||||
**What to do:**
|
||||
1. Create `class GeoCache` in `geo_service.py` or a new `app/services/geo_cache.py` with the cache dict, neg-cache, dirty set, and lock as instance attributes.
|
||||
2. Expose `lookup`, `lookup_batch`, `flush_dirty`, `load_from_db`, `clear`, etc. as methods.
|
||||
3. Instantiate in `startup.py` or `main.py` lifespan and store as `app.state.geo_cache`.
|
||||
4. Update `dependencies.py` to inject `GeoCache` rather than returning `geo_service.lookup_batch` directly.
|
||||
5. Remove `clear_cache()` and `clear_neg_cache()` module-level functions (move to instance methods).
|
||||
|
||||
**Possible traps and issues:**
|
||||
- Background tasks (`geo_cache_flush.py`, `geo_re_resolve.py`) reference module-level functions. They need to receive the `GeoCache` instance.
|
||||
- `geo_service.is_cached()` is called from `ban_service` — update call sites.
|
||||
- Many tests likely patch `geo_service._cache` or call `geo_service.clear_cache()` — all tests need updating.
|
||||
|
||||
**Docs changes needed:** `Docs/Architekture.md` — document `GeoCache` as a managed stateful service alongside session cache.
|
||||
|
||||
**Doc references:** `Docs/Architekture.md`, `Docs/Backend-Development.md`
|
||||
|
||||
---
|
||||
|
||||
### T-05 · Remove `app.state` mutation from `_build_app_context` in `dependencies.py`
|
||||
|
||||
**Where found:** `backend/app/dependencies.py` — `_build_app_context()` mutates `state.session_cache` on every request
|
||||
|
||||
**Why this is needed:** A function named `_build_app_context` (a "build" / read operation) has a side effect of mutating `app.state`. Startup configuration decisions belong in the lifespan handler, not in the per-request dependency graph.
|
||||
|
||||
**Goal:** `_build_app_context` is pure (read-only). Session cache type is decided once at startup in `main.py`.
|
||||
|
||||
**What to do:**
|
||||
1. Move the session cache swap logic (`_session_cache_enabled` check → replace `NoOpSessionCache` with `InMemorySessionCache`) to the lifespan handler in `main.py`.
|
||||
2. Remove the three `if/elif/elif` branches mutating `state.session_cache` from `_build_app_context`.
|
||||
3. Ensure settings-change flow (runtime settings update) also triggers a cache swap if needed.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- If `runtime_settings` can change mid-process (setup wizard updates settings), the cache swap must still happen. Identify where `runtime_settings` is set and add a swap there.
|
||||
- Tests that call `_build_app_context` directly may rely on the mutation side effect.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `backend/app/dependencies.py`, `backend/app/main.py`
|
||||
|
||||
---
|
||||
|
||||
### T-06 · Eliminate `AppState` Protocol / `ApplicationContext` dataclass redundancy
|
||||
|
||||
**Where found:** `backend/app/dependencies.py` — `AppState` Protocol (lines ~40–60) and `ApplicationContext` dataclass (lines ~62–75) describe identical fields.
|
||||
|
||||
**Why this is needed:** Every new field must be added to both. The Protocol is only used for a single `cast()` call inside `_build_app_context`. Maintenance burden with no benefit.
|
||||
|
||||
**Goal:** One typed representation. Remove `AppState` Protocol; cast directly or use `ApplicationContext`.
|
||||
|
||||
**What to do:**
|
||||
1. Delete the `AppState` Protocol class.
|
||||
2. Replace `state = cast("AppState", request.app.state)` with `state = request.app.state` (type: ignore or use `ApplicationState` directly since it's the concrete type set in `create_app`).
|
||||
3. Access fields from `state` directly using the `ApplicationState` / `RuntimeState` types.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- `mypy` / pyright may report type errors on `request.app.state` accesses — use `cast(ApplicationState, request.app.state)` once, then access typed.
|
||||
- Ensure all fields accessed in `_build_app_context` are present on `ApplicationState`.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `backend/app/dependencies.py`, `backend/app/utils/runtime_state.py`
|
||||
|
||||
---
|
||||
|
||||
### T-07 · Break cross-service import: `jail_config_service` imports `jail_service`
|
||||
|
||||
**Where found:** `backend/app/services/jail_config_service.py` — `import app.services.jail_service as jail_service`
|
||||
|
||||
**Why this is needed:** Services at the same layer should not depend on each other. Shared logic should move to a lower-level utility. This creates a dependency cycle risk and makes both services harder to test independently.
|
||||
|
||||
**Goal:** Shared socket operations extracted to `app/utils/` or `app/utils/jail_socket.py`. No service imports a sibling service.
|
||||
|
||||
**What to do:**
|
||||
1. Identify which functions from `jail_service` are called by `jail_config_service`.
|
||||
2. Extract shared low-level socket helpers to `app/utils/jail_socket.py` (or extend `fail2ban_client.py`).
|
||||
3. Update both services to import from the utility layer.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- Some `jail_service` functions that are called may themselves import geo, config, or other services — trace the full dependency graph before extracting.
|
||||
- APScheduler tasks reference `jail_service` — ensure they still work after any reorganisation.
|
||||
|
||||
**Docs changes needed:** `Docs/Architekture.md` — add rule: services must not import sibling services.
|
||||
|
||||
**Doc references:** `Docs/Architekture.md`
|
||||
|
||||
---
|
||||
|
||||
### T-08 · `_SOCKET_TIMEOUT` defined 6× — `constants.py` constant unused
|
||||
|
||||
**Where found:** `backend/app/utils/constants.py:16` (defines `FAIL2BAN_SOCKET_TIMEOUT_SECONDS = 5.0` but is never imported); `ban_service.py` (5.0), `jail_service.py` (10.0), `config_service.py` (10.0), `server_service.py` (10.0), `log_service.py` (10.0), `jail_config_service.py` (10.0), `config_file_utils.py` (10.0)
|
||||
|
||||
**Why this is needed:** `constants.py` has a module docstring saying "import from this module rather than hard-coding values" — and then nothing imports the socket timeout. The values also disagree: `ban_service` uses `5.0` while all others use `10.0`, creating silent inconsistency in timeout behaviour across endpoints.
|
||||
|
||||
**Goal:** One constant in `constants.py`, imported everywhere. Decide on a single default (or two named constants for fast/slow operations).
|
||||
|
||||
**What to do:**
|
||||
1. In `constants.py`, define `FAIL2BAN_SOCKET_TIMEOUT_FAST: Final[float] = 5.0` (for health/metadata probes) and `FAIL2BAN_SOCKET_TIMEOUT: Final[float] = 10.0` (for command operations) — or a single value if appropriate.
|
||||
2. Replace all local `_SOCKET_TIMEOUT` float literals with imports from `constants`.
|
||||
3. Confirm `ban_service` should actually be `5.0` or `10.0` (intentional vs accidental discrepancy).
|
||||
|
||||
**Possible traps and issues:**
|
||||
- Changing `ban_service` from 5.0 to 10.0 (or vice versa) changes observable timeout behaviour. Decide deliberately.
|
||||
- `fail2ban_metadata_service.py` also has `socket_timeout: float = 5.0` hardcoded inline — include this in the sweep.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `backend/app/utils/constants.py`
|
||||
|
||||
---
|
||||
|
||||
### T-09 · `_since_unix` has two divergent implementations — latent window-boundary bug
|
||||
|
||||
**Where found:** `backend/app/services/ban_service.py:393` (uses `time.time()`, applies `_TIME_RANGE_SLACK_SECONDS = 60`); `backend/app/services/history_service.py:53` (uses `datetime.now(UTC).timestamp()`, no slack)
|
||||
|
||||
**Why this is needed:** The `ban_service` docstring explicitly explains why `time.time()` is used and why the 60-second slack is needed for consistency with fail2ban's timestamp storage. `history_service` quietly omits both, so history queries return a slightly different window boundary than ban queries for the same `TimeRange` parameter. Inconsistent windows cause "same data, different count" bugs in the UI.
|
||||
|
||||
**Goal:** Single `_since_unix` function in a shared utility module, with documented rationale, used by both services.
|
||||
|
||||
**What to do:**
|
||||
1. Move `_since_unix` (with the `time.time()` + slack approach) to `app/utils/time_utils.py` (which already exists).
|
||||
2. Delete both local implementations.
|
||||
3. Import from `time_utils` in both services.
|
||||
4. Decide consciously whether history queries should also use the slack (probably yes for consistency).
|
||||
|
||||
**Possible traps and issues:**
|
||||
- Adding the 60-second slack to history queries may change row counts in tests that assert exact counts against seeded timestamps.
|
||||
- Confirm `_TIME_RANGE_SLACK_SECONDS` belongs in `constants.py`.
|
||||
|
||||
**Docs changes needed:** `Docs/Backend-Development.md` — document the timestamp handling rationale.
|
||||
|
||||
**Doc references:** `backend/app/utils/time_utils.py`, `backend/app/utils/constants.py`
|
||||
|
||||
---
|
||||
|
||||
### T-10 · `get_geo_batch_lookup` is false injectability — module function pointer injection
|
||||
|
||||
**Where found:** `backend/app/dependencies.py` — `get_geo_batch_lookup()` returns `geo_service.lookup_batch` (a module-level function)
|
||||
|
||||
**Why this is needed:** The dependency provider exists to give the appearance of injectable geo lookup, but because `geo_service` uses module-level global state (T-04), tests that inject a different callable into routers still have the global cache active. The abstraction provides type-level indirection without runtime isolation.
|
||||
|
||||
**Goal:** Once T-04 is done (GeoCache as an object), inject the `GeoCache` instance and call methods on it directly. The `GeoBatchLookup` callable protocol becomes a method reference on the injected instance.
|
||||
|
||||
**What to do:**
|
||||
1. Complete T-04 first.
|
||||
2. Update `get_geo_batch_lookup` to retrieve `GeoCache` from `app.state` and return its `lookup_batch` method.
|
||||
3. Or inject `GeoCache` directly and let routers call `.lookup_batch()` on it.
|
||||
|
||||
**Possible traps and issues:** Blocked on T-04.
|
||||
|
||||
**Docs changes needed:** None beyond T-04.
|
||||
|
||||
**Doc references:** `backend/app/dependencies.py`
|
||||
|
||||
---
|
||||
|
||||
### T-11 · Repositories injected as module references via `cast()` — structural type-safety gap
|
||||
|
||||
**Where found:** `backend/app/dependencies.py` — `get_session_repo()`, `get_blocklist_repo()`, `get_settings_repo()`, `get_import_log_repo()`, `get_history_archive_repo()`, `get_geo_cache_repo()`, `get_fail2ban_db_repo()` all return the module itself cast to the Protocol type.
|
||||
|
||||
**Why this is needed:** The `cast()` call is a signal that the type system is being overridden. Modules pass Protocol structural checks only because their top-level `async def` functions happen to match the Protocol method signatures. This is fragile — a module rename, a function rename, or an added required parameter will silently pass mypy but fail at runtime.
|
||||
|
||||
**Goal:** Repository modules become proper singleton instances, or the dependency providers are acknowledged as module-adapters with explicit documentation.
|
||||
|
||||
**What to do (option A — correct):**
|
||||
1. Convert each repository module's functions into a class with the same method signatures.
|
||||
2. Instantiate singletons at startup and store on `app.state` or as module-level instances.
|
||||
3. Update dependency providers to return the instance without `cast()`.
|
||||
|
||||
**What to do (option B — minimal):**
|
||||
1. Document in each `get_*_repo` provider why the module-as-Protocol pattern is intentional.
|
||||
2. Add a CI check (or mypy plugin) that validates structural compatibility doesn't silently break.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- Option A is a significant refactor affecting all repository call sites.
|
||||
- Option B risks the pattern silently breaking in future.
|
||||
|
||||
**Docs changes needed:** `Docs/Backend-Development.md` — document repository injection pattern and why it works.
|
||||
|
||||
**Doc references:** `Docs/Backend-Development.md`, `backend/app/repositories/protocols.py`
|
||||
|
||||
---
|
||||
|
||||
### T-12 · Apply `useListData` consistently across all data-fetching hooks
|
||||
|
||||
**Where found:** `frontend/src/hooks/useJailList.ts`, `useJailDetail.ts`, `useServerStatus.ts`, `useBanTrend.ts`, `useDashboardCountryData.ts` — all re-implement abort-controller / loading / error state manually. `useListData.ts` exists and is used by `useBlocklists`, `useJailConfigs`, `useActionList`, `useFilterList`.
|
||||
|
||||
**Why this is needed:** At least 5 hooks implement the same 40-line pattern. Any fix to the pattern (e.g. abort-guard in `.finally()`) must be applied to every copy independently. `useHistory` has a real bug because of this (see T-18).
|
||||
|
||||
**Goal:** All hooks that load a list and need refresh semantics use `useListData` or a shared base.
|
||||
|
||||
**What to do:**
|
||||
1. Audit all hooks for the manual abort-controller pattern.
|
||||
2. Refactor `useJailList` first (cleanest candidate — no mutations).
|
||||
3. For hooks with side-effects beyond listing (e.g. `useJailDetail`), split into data hook + command hook (see T-13) and use `useListData` for the data half.
|
||||
4. Extend `useListData` if needed to support `onSuccess` callbacks returning non-array data (e.g. `total`).
|
||||
|
||||
**Possible traps and issues:**
|
||||
- `useListData` currently requires `selector: (response) → TItem[]`. Hooks that expose `total` alongside items need `onSuccess` to capture it — the `onSuccess` callback already exists in `UseListDataOptions`.
|
||||
- `useServerStatus` has a polling interval and window-focus refetch that `useListData` does not support — may need a `usePolledData` variant or extension.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `frontend/src/hooks/useListData.ts`
|
||||
|
||||
---
|
||||
|
||||
### T-13 · Split `useJailDetail` — SRP violation (read state + write commands in one hook)
|
||||
|
||||
**Where found:** `frontend/src/hooks/useJailDetail.ts`
|
||||
|
||||
**Why this is needed:** The hook manages fetch state AND exposes 8 write operations (`start`, `stop`, `reload`, `setIdle`, `addIp`, `removeIp`, `toggleIgnoreSelf`, `load`). Read concerns and command concerns are independent. The consumer (`JailDetailPage`) passes them separately through props anyway, so the UI doesn't depend on them being co-located.
|
||||
|
||||
**Goal:** `useJailData(name)` for reading, `useJailCommands(name, onSuccess)` for mutations.
|
||||
|
||||
**What to do:**
|
||||
1. Create `useJailData(name): { jail, ignoreList, ignoreSelf, loading, error, refresh }` using `useListData` or the abort-controller pattern.
|
||||
2. Create `useJailCommands(name, onSuccess: () => void): { start, stop, reload, setIdle, addIp, removeIp, toggleIgnoreSelf }` — each command calls the API and then calls `onSuccess()` to trigger a refresh.
|
||||
3. In `JailDetailPage`, call both hooks and pass results to child components.
|
||||
4. Delete `useJailDetail.ts`.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- `JailDetailPage` destructures all properties from `useJailDetail` in a single line — update the destructuring.
|
||||
- Commands currently call `load()` directly. The `onSuccess` callback pattern keeps them decoupled from the data hook's internals.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `frontend/src/hooks/useJailDetail.ts`, `frontend/src/pages/JailDetailPage.tsx`
|
||||
|
||||
---
|
||||
|
||||
### T-14 · Move jail detail sub-sections from `pages/jail/` to `components/jail/`
|
||||
|
||||
**Where found:** `frontend/src/pages/jail/` — `JailInfoSection`, `PatternsSection`, `BantimeEscalationSection`, `IgnoreListSection`, `jailDetailPageStyles.ts`. `BannedIpsSection` is in `frontend/src/components/jail/` (correct location).
|
||||
|
||||
**Why this is needed:** The project convention is `pages/` for route-level components and `components/` for reusable UI. Sub-sections are reusable UI building blocks, not route components. `BannedIpsSection` already lives in the right place. The inconsistency makes the directory structure misleading.
|
||||
|
||||
**Goal:** All `*Section` components for jail detail under `components/jail/`. `pages/jail/` deleted or contains only route-level entry points.
|
||||
|
||||
**What to do:**
|
||||
1. Move `JailInfoSection`, `PatternsSection`, `BantimeEscalationSection`, `IgnoreListSection`, and `jailDetailPageStyles.ts` to `frontend/src/components/jail/`.
|
||||
2. Update imports in `JailDetailPage.tsx`.
|
||||
3. Remove empty `pages/jail/` directory.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- Confirm no other page imports from `pages/jail/` before deleting it.
|
||||
- Update any path-based test imports.
|
||||
|
||||
**Docs changes needed:** `Docs/Web-Development.md` — document the `pages/` vs `components/` convention explicitly.
|
||||
|
||||
**Doc references:** `Docs/Web-Development.md`
|
||||
|
||||
---
|
||||
|
||||
### T-15 · Replace `window` event bus for session expiry with React context callback
|
||||
|
||||
**Where found:** `frontend/src/api/client.ts` — `window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT))`; `frontend/src/providers/AuthProvider.tsx` — `window.addEventListener(SESSION_EXPIRED_EVENT, ...)`
|
||||
|
||||
**Why this is needed:** Using `window` as a side-channel bypasses React's component tree, breaks in non-browser environments (SSR, test environments without full JSDOM), and creates an invisible coupling between the API client and the auth provider. It also fires globally — any code anywhere that dispatches `bangui:session-expired` on window will trigger a logout, with no tracing possible.
|
||||
|
||||
**Goal:** The API client receives an `onUnauthorized` callback injected at the provider level, called directly instead of via a DOM event.
|
||||
|
||||
**What to do:**
|
||||
1. Add an `onUnauthorized` callback to the API client (e.g. a module-level setter `setUnauthorizedHandler(fn)`).
|
||||
2. In `AuthProvider`, call `setUnauthorizedHandler(handleSessionExpired)` on mount and reset it on unmount.
|
||||
3. In `client.ts`, call the handler directly instead of `window.dispatchEvent`.
|
||||
4. Remove `SESSION_EXPIRED_EVENT` string constant and the `window.addEventListener` in `AuthProvider`.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- The module-level handler setter is still global state — an alternative is to pass the handler as a parameter to `request()` via a context object, but that changes the API signature more significantly.
|
||||
- Tests that mock `window.dispatchEvent` need updating.
|
||||
- SSR / Vitest environments that already mock `window` may need adjustment.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `frontend/src/api/client.ts`, `frontend/src/providers/AuthProvider.tsx`
|
||||
|
||||
---
|
||||
|
||||
### T-16 · Centralise `PAGE_SIZE` frontend constants
|
||||
|
||||
**Where found:** `frontend/src/hooks/useBans.ts:14` (`PAGE_SIZE = 100`); `frontend/src/pages/HistoryPage.tsx:45` (`PAGE_SIZE = 50`)
|
||||
|
||||
**Why this is needed:** Page sizes can silently diverge from backend defaults. If the backend changes `_DEFAULT_PAGE_SIZE`, the frontend won't know. Having multiple files define the same concept differently is also misleading.
|
||||
|
||||
**Goal:** All pagination constants in `frontend/src/utils/constants.ts`.
|
||||
|
||||
**What to do:**
|
||||
1. Add `BAN_PAGE_SIZE = 100`, `HISTORY_PAGE_SIZE = 50` to `frontend/src/utils/constants.ts` (create it if it doesn't exist).
|
||||
2. Replace local `const PAGE_SIZE = ...` in each hook/page with imports.
|
||||
|
||||
**Possible traps and issues:** Trivial. Verify test snapshots don't hard-code the old inline constant.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `frontend/src/utils/constants.ts`
|
||||
|
||||
---
|
||||
|
||||
### T-17 · `useHistory` is missing abort-signal guards — stale state update bug
|
||||
|
||||
**Where found:** `frontend/src/hooks/useHistory.ts` — `.then()`, `.catch()`, `.finally()` callbacks update state without checking `abortRef.current.signal.aborted`
|
||||
|
||||
**Why this is needed:** Every other data-fetching hook in the codebase guards all state-update callbacks against aborted signals. `useHistory` does not. If the component unmounts mid-request, `setItems`, `setTotal`, `setLoading` will all fire on an unmounted component. In React 18 this is a no-op but it still indicates a broken invariant and `handleFetchError` could misclassify the abort as a real error (depends on whether `fetch` threw `AbortError` or the API module swallowed it).
|
||||
|
||||
**Goal:** All callbacks in `useHistory` check the abort signal before mutating state.
|
||||
|
||||
**What to do:**
|
||||
1. Capture the controller in a local variable inside `load()` (already done: `abortRef.current = new AbortController()`).
|
||||
2. In `.then()`: add `if (abortRef.current.signal.aborted) return;` before `setItems(...)`.
|
||||
3. In `.catch()`: add the same guard before `handleFetchError(...)`.
|
||||
4. In `.finally()`: add `if (!abortRef.current.signal.aborted)` before `setLoading(false)`.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- `abortRef.current` may have been replaced by a new controller before the callback fires. Capture the controller in a closure variable at the top of `load()`: `const controller = abortRef.current`.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `frontend/src/hooks/useHistory.ts`
|
||||
|
||||
---
|
||||
|
||||
### T-18 · Merge `useDashboardCountryData` and `useMapData` — near-identical hooks
|
||||
|
||||
**Where found:** `frontend/src/hooks/useDashboardCountryData.ts` and `frontend/src/hooks/useMapData.ts`
|
||||
|
||||
**Why this is needed:** Both hooks call `fetchBansByCountry`, maintain the same state shape (`countries`, `countryNames`, `bans`, `total`, `loading`, `error`), and implement the same abort-controller pattern. The only behavioural difference is that `useMapData` adds a 300ms debounce. Any bug fix must be applied to both.
|
||||
|
||||
**Goal:** A single `useBansByCountry` base hook; `useMapData` adds the debounce on top.
|
||||
|
||||
**What to do:**
|
||||
1. Create `useBansByCountry(range, origin, source, countryCode?)` — the shared fetch logic without debounce.
|
||||
2. Refactor `useDashboardCountryData` to wrap `useBansByCountry`.
|
||||
3. Refactor `useMapData` to wrap `useBansByCountry` and add the debounce layer.
|
||||
4. Keep the existing hook names as thin wrappers to preserve call sites.
|
||||
|
||||
**Possible traps and issues:**
|
||||
- `useMapData` returns `{ data }` (the full response object) whereas `useDashboardCountryData` unpacks `countries`, `countryNames`, `bans`, `total`. Normalise the return shape before collapsing.
|
||||
- Tests for each hook test them independently — update or merge.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `frontend/src/hooks/useDashboardCountryData.ts`, `frontend/src/hooks/useMapData.ts`
|
||||
|
||||
---
|
||||
|
||||
### T-19 · Move `DashboardFilterProvider` — page-scoped provider in wrong directory
|
||||
|
||||
**Where found:** `frontend/src/providers/DashboardFilterProvider.tsx` — instantiated only inside `DashboardPage.tsx`
|
||||
|
||||
**Why this is needed:** The `providers/` directory implies app-wide providers (alongside `AuthProvider`, `ThemeProvider`, `TimezoneProvider`). `DashboardFilterProvider` wraps only `DashboardPageContent` and is not used anywhere else. Its placement implies reuse that doesn't exist, misleading future contributors about its scope.
|
||||
|
||||
**Goal:** Co-located with its only consumer.
|
||||
|
||||
**What to do:**
|
||||
1. Move `DashboardFilterProvider.tsx` to `frontend/src/pages/` (alongside `DashboardPage.tsx`) or to `frontend/src/pages/dashboard/` if the page is split into a subdirectory.
|
||||
2. Update imports in `DashboardPage.tsx` and any tests.
|
||||
|
||||
**Possible traps and issues:** Only `DashboardPage.tsx` imports it — confirm with grep before moving.
|
||||
|
||||
**Docs changes needed:** `Docs/Web-Development.md` — document what belongs in `providers/` (app-wide) vs co-located.
|
||||
|
||||
**Doc references:** `Docs/Web-Development.md`
|
||||
|
||||
---
|
||||
|
||||
### T-20 · Replace inline `style={{}}` objects with `makeStyles` classes
|
||||
|
||||
**Where found:** `frontend/src/pages/map/MapBansTable.tsx` (multiple), `pages/JailDetailPage.tsx`, `pages/HistoryPage.tsx`, `pages/history/IpDetailView.tsx`, `components/WorldMap.tsx`, `components/TopCountriesPieChart.tsx`, `components/TopCountriesBarChart.tsx`
|
||||
|
||||
**Why this is needed:** The project uses Fluent UI's `makeStyles` with atomic CSS caching. Inline `style={{}}` objects are allocated on every render, bypass the atomic CSS cache, and are inconsistent with the established pattern. Exceptions are acceptable only for truly dynamic values (e.g. tooltip `left`/`top` that change on mouse move) — static layout values must use `makeStyles`.
|
||||
|
||||
**Goal:** All static layout properties moved to `makeStyles`. Inline styles only for genuinely dynamic values.
|
||||
|
||||
**What to do:**
|
||||
1. Audit each file listed above.
|
||||
2. For static `display: flex`, `gap`, `margin`, `padding` — move to `makeStyles` in that component's style block.
|
||||
3. Keep inline `style` only where the value is truly dynamic at runtime (e.g. `WorldMap` tooltip position, `TopCountriesBarChart` chart height).
|
||||
|
||||
**Possible traps and issues:**
|
||||
- `MapBansTable.tsx` has several consecutive inline `style` objects for its pagination row — these can be collapsed into one named class.
|
||||
- Some components use both `tokens.*` values and inline styles — ensure `makeStyles` is imported where it isn't already.
|
||||
|
||||
**Docs changes needed:** `Docs/Web-Development.md` — add styling rule: use `makeStyles` for all static styles; inline `style` only for runtime-dynamic values.
|
||||
|
||||
**Doc references:** `Docs/Web-Development.md`, Fluent UI v9 docs on `makeStyles`
|
||||
|
||||
---
|
||||
|
||||
### T-21 · `Fail2BanMetadataService` has inline socket timeout hardcoded
|
||||
|
||||
**Where found:** `backend/app/services/fail2ban_metadata_service.py:64` — `socket_timeout: float = 5.0`
|
||||
|
||||
**Why this is needed:** Part of the same constant duplication as T-08. This file doesn't use `_SOCKET_TIMEOUT` as a module constant — it hardcodes `5.0` inline as a local variable. Should use the constant from `constants.py` once T-08 is done.
|
||||
|
||||
**Goal:** After T-08, replace with `FAIL2BAN_SOCKET_TIMEOUT_FAST` import.
|
||||
|
||||
**What to do:** Covered by T-08 sweep.
|
||||
|
||||
**Possible traps and issues:** None — dependent on T-08.
|
||||
|
||||
**Docs changes needed:** None.
|
||||
|
||||
**Doc references:** `backend/app/services/fail2ban_metadata_service.py`
|
||||
@@ -24,13 +24,13 @@ from app.models.ban import (
|
||||
BUCKET_SECONDS,
|
||||
BUCKET_SIZE_LABEL,
|
||||
TIME_RANGE_SECONDS,
|
||||
ActiveBan,
|
||||
ActiveBanListResponse,
|
||||
BanOrigin,
|
||||
BansByCountryResponse,
|
||||
BansByJailResponse,
|
||||
BanTrendBucket,
|
||||
BanTrendResponse,
|
||||
ActiveBan,
|
||||
ActiveBanListResponse,
|
||||
DashboardBanItem,
|
||||
DashboardBanListResponse,
|
||||
TimeRange,
|
||||
@@ -40,12 +40,17 @@ from app.models.ban import (
|
||||
from app.models.ban import (
|
||||
JailBanCount as JailBanCountModel,
|
||||
)
|
||||
from app.repositories import fail2ban_db_repo, history_archive_repo as default_history_archive_repo
|
||||
from app.repositories import fail2ban_db_repo
|
||||
from app.repositories import history_archive_repo as default_history_archive_repo
|
||||
from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service
|
||||
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
|
||||
from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanResponse,
|
||||
)
|
||||
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
|
||||
from app.utils.fail2ban_response import (
|
||||
is_not_found_error,
|
||||
ok,
|
||||
to_dict,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -71,80 +76,8 @@ _DEFAULT_PAGE_SIZE: int = 100
|
||||
_MAX_PAGE_SIZE: int = 500
|
||||
_SOCKET_TIMEOUT: float = 5.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: object) -> object:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the response indicates an error (return code ≠ 0).
|
||||
"""
|
||||
try:
|
||||
code, data = response # type: ignore[assignment]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: object) -> dict[str, object]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict.
|
||||
|
||||
Args:
|
||||
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
|
||||
|
||||
Returns:
|
||||
A :class:`dict` with the keys and values from *pairs*.
|
||||
"""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, object] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_list(value: object | None) -> list[str]:
|
||||
"""Coerce a fail2ban response value to a list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value if v is not None]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _is_not_found_error(exc: Exception) -> bool:
|
||||
"""Return ``True`` if *exc* indicates a jail does not exist."""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
phrase in msg
|
||||
for phrase in (
|
||||
"unknown jail",
|
||||
"unknownjail",
|
||||
"no jail",
|
||||
"does not exist",
|
||||
"not found",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
|
||||
"""Ban an IP address in the specified jail."""
|
||||
@@ -156,9 +89,9 @@ async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
try:
|
||||
_ok(await client.send(["set", jail, "banip", ip]))
|
||||
ok(await client.send(["set", jail, "banip", ip]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -173,13 +106,13 @@ async def unban_ip(socket_path: str, ip: str, jail: str | None = None) -> None:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
if jail is None:
|
||||
_ok(await client.send(["unban", ip]))
|
||||
ok(await client.send(["unban", ip]))
|
||||
return
|
||||
|
||||
try:
|
||||
_ok(await client.send(["set", jail, "unbanip", ip]))
|
||||
ok(await client.send(["set", jail, "unbanip", ip]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -324,7 +257,7 @@ async def get_active_bans(
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
global_status = to_dict(ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
@@ -351,7 +284,7 @@ async def get_active_bans(
|
||||
continue
|
||||
|
||||
try:
|
||||
ban_list: list[str] = cast("list[str]", _ok(raw_result)) or []
|
||||
ban_list: list[str] = cast("list[str]", ok(raw_result)) or []
|
||||
except (TypeError, ValueError) as exc:
|
||||
log.warning(
|
||||
"active_bans_parse_error",
|
||||
|
||||
@@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
import structlog
|
||||
|
||||
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken
|
||||
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanToken
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -52,6 +52,12 @@ from app.services.settings_service import (
|
||||
set_map_color_thresholds as util_set_map_color_thresholds,
|
||||
)
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
from app.utils.fail2ban_response import (
|
||||
ensure_list,
|
||||
is_not_found_error,
|
||||
ok,
|
||||
to_dict,
|
||||
)
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -65,56 +71,10 @@ _SOCKET_TIMEOUT: float = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers (mirrored from jail_service for isolation)
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: object) -> object:
|
||||
"""Extract payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the return code indicates an error.
|
||||
"""
|
||||
try:
|
||||
code, data = cast("Fail2BanResponse", response)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: object) -> dict[str, object]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict."""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, object] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_list(value: object | None) -> list[str]:
|
||||
"""Coerce a fail2ban ``get`` result to a list of strings."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value if v is not None]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -125,7 +85,7 @@ async def _safe_get(
|
||||
) -> object | None:
|
||||
"""Send a command and return *default* if it fails."""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
return ok(await client.send(command))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@@ -139,15 +99,6 @@ async def _safe_get_typed[T](
|
||||
return cast("T", await _safe_get(client, command, default))
|
||||
|
||||
|
||||
def _is_not_found_error(exc: Exception) -> bool:
|
||||
"""Return ``True`` if *exc* signals an unknown jail."""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
phrase in msg
|
||||
for phrase in ("unknown jail", "no jail", "does not exist", "not found")
|
||||
)
|
||||
|
||||
|
||||
def _validate_regex(pattern: str) -> str | None:
|
||||
"""Try to compile *pattern* and return an error message if invalid.
|
||||
|
||||
@@ -187,9 +138,9 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||||
|
||||
# Verify existence.
|
||||
try:
|
||||
_ok(await client.send(["status", name, "short"]))
|
||||
ok(await client.send(["status", name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
@@ -228,15 +179,15 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
|
||||
ban_time=int(bantime_raw or 600),
|
||||
find_time=int(findtime_raw or 600),
|
||||
max_retry=int(maxretry_raw or 5),
|
||||
fail_regex=_ensure_list(failregex_raw),
|
||||
ignore_regex=_ensure_list(ignoreregex_raw),
|
||||
log_paths=_ensure_list(logpath_raw),
|
||||
fail_regex=ensure_list(failregex_raw),
|
||||
ignore_regex=ensure_list(ignoreregex_raw),
|
||||
log_paths=ensure_list(logpath_raw),
|
||||
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
||||
log_encoding=str(logencoding_raw or "UTF-8"),
|
||||
backend=str(backend_raw or "polling"),
|
||||
use_dns=str(usedns_raw or "warn"),
|
||||
prefregex=str(prefregex_raw) if prefregex_raw else "",
|
||||
actions=_ensure_list(actions_raw),
|
||||
actions=ensure_list(actions_raw),
|
||||
bantime_escalation=bantime_escalation,
|
||||
)
|
||||
|
||||
@@ -258,7 +209,7 @@ async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
global_status = to_dict(ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
@@ -325,15 +276,15 @@ async def update_jail_config(
|
||||
|
||||
# Verify existence.
|
||||
try:
|
||||
_ok(await client.send(["status", name, "short"]))
|
||||
ok(await client.send(["status", name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
async def _set(key: str, value: Fail2BanToken) -> None:
|
||||
try:
|
||||
_ok(await client.send(["set", name, key, value]))
|
||||
ok(await client.send(["set", name, key, value]))
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
||||
|
||||
@@ -402,7 +353,7 @@ async def _replace_regex_list(
|
||||
"""
|
||||
# Determine current count.
|
||||
current_raw: list[object] = await _safe_get_typed(client, ["get", jail, field], [])
|
||||
current: list[str] = _ensure_list(current_raw)
|
||||
current: list[str] = ensure_list(current_raw)
|
||||
|
||||
del_cmd = f"del{field}"
|
||||
add_cmd = f"add{field}"
|
||||
@@ -410,7 +361,7 @@ async def _replace_regex_list(
|
||||
# Delete in reverse order so indices stay stable.
|
||||
for idx in range(len(current) - 1, -1, -1):
|
||||
with contextlib.suppress(ValueError):
|
||||
_ok(await client.send(["set", jail, del_cmd, idx]))
|
||||
ok(await client.send(["set", jail, del_cmd, idx]))
|
||||
|
||||
# Add new patterns.
|
||||
for pattern in new_patterns:
|
||||
@@ -418,7 +369,7 @@ async def _replace_regex_list(
|
||||
if err:
|
||||
raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})")
|
||||
try:
|
||||
_ok(await client.send(["set", jail, add_cmd, pattern]))
|
||||
ok(await client.send(["set", jail, add_cmd, pattern]))
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc
|
||||
|
||||
@@ -477,7 +428,7 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
|
||||
|
||||
async def _set_global(key: str, value: Fail2BanToken) -> None:
|
||||
try:
|
||||
_ok(await client.send(["set", key, value]))
|
||||
ok(await client.send(["set", key, value]))
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to set global {key!r} = {value!r}: {exc}") from exc
|
||||
|
||||
@@ -528,15 +479,15 @@ async def add_log_path(
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
try:
|
||||
_ok(await client.send(["status", jail, "short"]))
|
||||
ok(await client.send(["status", jail, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail) from exc
|
||||
raise
|
||||
|
||||
tail_flag = "tail" if req.tail else "head"
|
||||
try:
|
||||
_ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag]))
|
||||
ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag]))
|
||||
log.info("log_path_added", jail=jail, path=req.log_path)
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to add log path {req.log_path!r}: {exc}") from exc
|
||||
@@ -565,14 +516,14 @@ async def delete_log_path(
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
try:
|
||||
_ok(await client.send(["status", jail, "short"]))
|
||||
ok(await client.send(["status", jail, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail) from exc
|
||||
raise
|
||||
|
||||
try:
|
||||
_ok(await client.send(["set", jail, "dellogpath", log_path]))
|
||||
ok(await client.send(["set", jail, "dellogpath", log_path]))
|
||||
log.info("log_path_deleted", jail=jail, path=log_path)
|
||||
except ValueError as exc:
|
||||
raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc
|
||||
|
||||
@@ -23,7 +23,10 @@ from app.utils.fail2ban_client import (
|
||||
Fail2BanCommand,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
Fail2BanResponse,
|
||||
)
|
||||
from app.utils.fail2ban_response import (
|
||||
ok,
|
||||
to_dict,
|
||||
)
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -35,59 +38,6 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
_SOCKET_TIMEOUT: float = 5.0
|
||||
|
||||
|
||||
def _ok(response: object) -> object:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
fail2ban wraps every response in a ``(0, data)`` success tuple or
|
||||
a ``(1, exception)`` error tuple. This helper returns ``data`` for
|
||||
successful responses or raises :class:`ValueError` for error responses.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the response indicates an error (return code ≠ 0).
|
||||
"""
|
||||
try:
|
||||
code, data = cast("Fail2BanResponse", response)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"Unexpected fail2ban response shape: {response!r}"
|
||||
) from exc
|
||||
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: object) -> dict[str, object]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict.
|
||||
|
||||
fail2ban returns structured data as lists of 2-tuples rather than dicts.
|
||||
This helper converts them safely, ignoring non-pair items.
|
||||
|
||||
Args:
|
||||
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
|
||||
|
||||
Returns:
|
||||
A :class:`dict` with the keys and values from *pairs*.
|
||||
"""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, object] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -98,7 +48,7 @@ async def _safe_get(
|
||||
) -> object | None:
|
||||
"""Send a command and return *default* if it fails."""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
return ok(await client.send(command))
|
||||
except (
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
@@ -202,7 +152,7 @@ async def probe(
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. Connectivity check #
|
||||
# ------------------------------------------------------------------ #
|
||||
ping_data = _ok(await client.send(["ping"]))
|
||||
ping_data = ok(await client.send(["ping"]))
|
||||
if ping_data != "pong":
|
||||
log.warning(
|
||||
"fail2ban_unexpected_ping_response",
|
||||
@@ -214,14 +164,14 @@ async def probe(
|
||||
# 2. Version
|
||||
# ------------------------------------------------------------------ #
|
||||
try:
|
||||
version: str | None = str(_ok(await client.send(["version"])))
|
||||
version: str | None = str(ok(await client.send(["version"])))
|
||||
except (ValueError, TypeError):
|
||||
version = None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. Global status — jail count and names #
|
||||
# ------------------------------------------------------------------ #
|
||||
status_data = _to_dict(_ok(await client.send(["status"])))
|
||||
status_data = to_dict(ok(await client.send(["status"])))
|
||||
active_jails: int = int(str(status_data.get("Number of jail", 0) or 0))
|
||||
jail_list_raw: str = str(
|
||||
status_data.get("Jail list", "") or ""
|
||||
@@ -240,11 +190,11 @@ async def probe(
|
||||
|
||||
for jail_name in jail_names:
|
||||
try:
|
||||
jail_resp = _to_dict(
|
||||
_ok(await client.send(["status", jail_name]))
|
||||
jail_resp = to_dict(
|
||||
ok(await client.send(["status", jail_name]))
|
||||
)
|
||||
filter_stats = _to_dict(jail_resp.get("Filter") or [])
|
||||
action_stats = _to_dict(jail_resp.get("Actions") or [])
|
||||
filter_stats = to_dict(jail_resp.get("Filter") or [])
|
||||
action_stats = to_dict(jail_resp.get("Actions") or [])
|
||||
total_failures += int(
|
||||
str(filter_stats.get("Currently failed", 0) or 0)
|
||||
)
|
||||
|
||||
@@ -38,6 +38,12 @@ from app.utils.fail2ban_client import (
|
||||
Fail2BanResponse,
|
||||
Fail2BanToken,
|
||||
)
|
||||
from app.utils.fail2ban_response import (
|
||||
ensure_list,
|
||||
is_not_found_error,
|
||||
ok,
|
||||
to_dict,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
@@ -115,71 +121,6 @@ def _get_backend_cmd_lock() -> asyncio.Lock:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: object) -> object:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the response indicates an error (return code ≠ 0).
|
||||
"""
|
||||
try:
|
||||
code, data = cast("Fail2BanResponse", response)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _to_dict(pairs: object) -> dict[str, object]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict.
|
||||
|
||||
Args:
|
||||
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
|
||||
|
||||
Returns:
|
||||
A :class:`dict` with the keys and values from *pairs*.
|
||||
"""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, object] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_list(value: object | None) -> list[str]:
|
||||
"""Coerce a fail2ban response value to a list of strings.
|
||||
|
||||
Some fail2ban ``get`` responses return ``None`` or a single string
|
||||
when there is only one entry. This helper normalises the result.
|
||||
|
||||
Args:
|
||||
value: The raw value from a ``get`` command response.
|
||||
|
||||
Returns:
|
||||
A list of strings, possibly empty.
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value if v is not None]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
async def _resolve_geo_info(
|
||||
ip: str,
|
||||
*,
|
||||
@@ -196,32 +137,6 @@ async def _resolve_geo_info(
|
||||
return None
|
||||
|
||||
|
||||
def _is_not_found_error(exc: Exception) -> bool:
|
||||
"""Return ``True`` if *exc* indicates a jail does not exist.
|
||||
|
||||
Checks both space-separated (``"unknown jail"``) and concatenated
|
||||
(``"unknownjail"``) forms because fail2ban serialises
|
||||
``UnknownJailException`` without a space when pickled.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
``True`` when the exception message signals an unknown jail.
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
phrase in msg
|
||||
for phrase in (
|
||||
"unknown jail",
|
||||
"unknownjail", # covers UnknownJailException serialised by fail2ban
|
||||
"no jail",
|
||||
"does not exist",
|
||||
"not found",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _safe_get(
|
||||
client: Fail2BanClient,
|
||||
command: Fail2BanCommand,
|
||||
@@ -242,7 +157,7 @@ async def _safe_get(
|
||||
"""
|
||||
try:
|
||||
response = await client.send(command)
|
||||
return _ok(cast("Fail2BanResponse", response))
|
||||
return ok(cast("Fail2BanResponse", response))
|
||||
except (ValueError, TypeError, Exception):
|
||||
return default
|
||||
|
||||
@@ -282,7 +197,7 @@ async def _check_backend_cmd_supported(
|
||||
|
||||
# Probe: send the command and catch any exception.
|
||||
try:
|
||||
_ok(await client.send(["get", jail_name, "backend"]))
|
||||
ok(await client.send(["get", jail_name, "backend"]))
|
||||
_backend_cmd_supported = True
|
||||
log.debug("backend_cmd_supported_detected")
|
||||
except Exception:
|
||||
@@ -328,7 +243,7 @@ async def list_jails(socket_path: str) -> JailListResponse:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
# 1. Fetch global status to get jail names.
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
global_status = to_dict(ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
@@ -411,9 +326,9 @@ async def _fetch_jail_summary(
|
||||
jail_status: JailStatus | None = None
|
||||
if not isinstance(status_raw, Exception):
|
||||
try:
|
||||
raw = _to_dict(_ok(status_raw))
|
||||
filter_stats = _to_dict(raw.get("Filter") or [])
|
||||
action_stats = _to_dict(raw.get("Actions") or [])
|
||||
raw = to_dict(ok(status_raw))
|
||||
filter_stats = to_dict(raw.get("Filter") or [])
|
||||
action_stats = to_dict(raw.get("Actions") or [])
|
||||
jail_status = JailStatus(
|
||||
currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)),
|
||||
total_banned=int(str(action_stats.get("Total banned", 0) or 0)),
|
||||
@@ -427,7 +342,7 @@ async def _fetch_jail_summary(
|
||||
if isinstance(raw, Exception):
|
||||
return fallback
|
||||
try:
|
||||
return int(str(_ok(cast("Fail2BanResponse", raw))))
|
||||
return int(str(ok(cast("Fail2BanResponse", raw))))
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
|
||||
@@ -435,7 +350,7 @@ async def _fetch_jail_summary(
|
||||
if isinstance(raw, Exception):
|
||||
return fallback
|
||||
try:
|
||||
return str(_ok(cast("Fail2BanResponse", raw)))
|
||||
return str(ok(cast("Fail2BanResponse", raw)))
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
|
||||
@@ -443,7 +358,7 @@ async def _fetch_jail_summary(
|
||||
if isinstance(raw, Exception):
|
||||
return fallback
|
||||
try:
|
||||
return bool(_ok(cast("Fail2BanResponse", raw)))
|
||||
return bool(ok(cast("Fail2BanResponse", raw)))
|
||||
except (ValueError, TypeError):
|
||||
return fallback
|
||||
|
||||
@@ -482,15 +397,15 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
|
||||
|
||||
# Verify the jail exists by sending a status command first.
|
||||
try:
|
||||
status_raw = _ok(await client.send(["status", name, "short"]))
|
||||
status_raw = ok(await client.send(["status", name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
raw = _to_dict(status_raw)
|
||||
filter_stats = _to_dict(raw.get("Filter") or [])
|
||||
action_stats = _to_dict(raw.get("Actions") or [])
|
||||
raw = to_dict(status_raw)
|
||||
filter_stats = to_dict(raw.get("Filter") or [])
|
||||
action_stats = to_dict(raw.get("Actions") or [])
|
||||
|
||||
jail_status = JailStatus(
|
||||
currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)),
|
||||
@@ -559,10 +474,10 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
|
||||
running=True,
|
||||
idle=bool(idle_raw),
|
||||
backend=str(backend_raw or "polling"),
|
||||
log_paths=_ensure_list(logpath_raw),
|
||||
fail_regex=_ensure_list(failregex_raw),
|
||||
ignore_regex=_ensure_list(ignoreregex_raw),
|
||||
ignore_ips=_ensure_list(ignoreip_raw),
|
||||
log_paths=ensure_list(logpath_raw),
|
||||
fail_regex=ensure_list(failregex_raw),
|
||||
ignore_regex=ensure_list(ignoreregex_raw),
|
||||
ignore_ips=ensure_list(ignoreip_raw),
|
||||
date_pattern=str(datepattern_raw) if datepattern_raw else None,
|
||||
log_encoding=str(logencoding_raw or "UTF-8"),
|
||||
find_time=int(str(findtime_raw or 600)),
|
||||
@@ -570,7 +485,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
|
||||
max_retry=int(str(maxretry_raw or 5)),
|
||||
bantime_escalation=bantime_escalation,
|
||||
status=jail_status,
|
||||
actions=_ensure_list(actions_raw),
|
||||
actions=ensure_list(actions_raw),
|
||||
)
|
||||
|
||||
log.info("jail_detail_fetched", jail=name)
|
||||
@@ -597,10 +512,10 @@ async def start_jail(socket_path: str, name: str) -> None:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["start", name]))
|
||||
ok(await client.send(["start", name]))
|
||||
log.info("jail_started", jail=name)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -622,10 +537,10 @@ async def stop_jail(socket_path: str, name: str) -> None:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["stop", name]))
|
||||
ok(await client.send(["stop", name]))
|
||||
log.info("jail_stopped", jail=name)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
# Jail is already stopped or was never running — treat as a no-op.
|
||||
log.info("jail_stop_noop", jail=name)
|
||||
return
|
||||
@@ -652,10 +567,10 @@ async def set_idle(socket_path: str, name: str, *, on: bool) -> None:
|
||||
state = "on" if on else "off"
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "idle", state]))
|
||||
ok(await client.send(["set", name, "idle", state]))
|
||||
log.info("jail_idle_toggled", jail=name, idle=on)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -682,10 +597,10 @@ async def reload_jail(socket_path: str, name: str) -> None:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["reload", name, [], [["start", name]]]))
|
||||
ok(await client.send(["reload", name, [], [["start", name]]]))
|
||||
log.info("jail_reloaded", jail=name)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -724,8 +639,8 @@ async def reload_all(
|
||||
async with _get_reload_all_lock():
|
||||
try:
|
||||
# Resolve jail names so we can build the minimal config stream.
|
||||
status_raw = _ok(await client.send(["status"]))
|
||||
status_dict = _to_dict(status_raw)
|
||||
status_raw = ok(await client.send(["status"]))
|
||||
status_dict = to_dict(status_raw)
|
||||
jail_list_raw: str = str(status_dict.get("Jail list", ""))
|
||||
jail_names = [n.strip() for n in jail_list_raw.split(",") if n.strip()]
|
||||
|
||||
@@ -737,12 +652,12 @@ async def reload_all(
|
||||
names_set -= set(exclude_jails)
|
||||
|
||||
stream: list[list[object]] = [["start", n] for n in sorted(names_set)]
|
||||
_ok(await client.send(["reload", "--all", [], cast("Fail2BanToken", stream)]))
|
||||
ok(await client.send(["reload", "--all", [], cast("Fail2BanToken", stream)]))
|
||||
log.info("all_jails_reloaded")
|
||||
except ValueError as exc:
|
||||
# Detect UnknownJailException (missing or invalid jail configuration)
|
||||
# and re-raise as JailNotFoundError for better error specificity.
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
# Extract the jail name from include_jails if available.
|
||||
jail_name = include_jails[0] if include_jails else "unknown"
|
||||
raise JailNotFoundError(jail_name) from exc
|
||||
@@ -771,7 +686,7 @@ async def restart(socket_path: str) -> None:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["stop"]))
|
||||
ok(await client.send(["stop"]))
|
||||
log.info("fail2ban_stopped_for_restart")
|
||||
except ValueError as exc:
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
@@ -946,15 +861,15 @@ async def get_jail_banned_ips(
|
||||
|
||||
# Verify the jail exists.
|
||||
try:
|
||||
_ok(await client.send(["status", jail_name, "short"]))
|
||||
ok(await client.send(["status", jail_name, "short"]))
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(jail_name) from exc
|
||||
raise
|
||||
|
||||
# Fetch the full ban list for this jail.
|
||||
try:
|
||||
raw_result = _ok(await client.send(["get", jail_name, "banip", "--with-time"]))
|
||||
raw_result = ok(await client.send(["get", jail_name, "banip", "--with-time"]))
|
||||
except (ValueError, TypeError):
|
||||
raw_result = []
|
||||
|
||||
@@ -1059,10 +974,10 @@ async def get_ignore_list(socket_path: str, name: str) -> list[str]:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
raw = _ok(await client.send(["get", name, "ignoreip"]))
|
||||
return _ensure_list(raw)
|
||||
raw = ok(await client.send(["get", name, "ignoreip"]))
|
||||
return ensure_list(raw)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
@@ -1089,10 +1004,10 @@ async def add_ignore_ip(socket_path: str, name: str, ip: str) -> None:
|
||||
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "addignoreip", ip]))
|
||||
ok(await client.send(["set", name, "addignoreip", ip]))
|
||||
log.info("ignore_ip_added", jail=name, ip=ip)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -1113,10 +1028,10 @@ async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "delignoreip", ip]))
|
||||
ok(await client.send(["set", name, "delignoreip", ip]))
|
||||
log.info("ignore_ip_removed", jail=name, ip=ip)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -1138,10 +1053,10 @@ async def get_ignore_self(socket_path: str, name: str) -> bool:
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
raw = _ok(await client.send(["get", name, "ignoreself"]))
|
||||
raw = ok(await client.send(["get", name, "ignoreself"]))
|
||||
return bool(raw)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise
|
||||
|
||||
@@ -1163,10 +1078,10 @@ async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None:
|
||||
value = "true" if on else "false"
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["set", name, "ignoreself", value]))
|
||||
ok(await client.send(["set", name, "ignoreself", value]))
|
||||
log.info("ignore_self_toggled", jail=name, on=on)
|
||||
except ValueError as exc:
|
||||
if _is_not_found_error(exc):
|
||||
if is_not_found_error(exc):
|
||||
raise JailNotFoundError(name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
@@ -1212,10 +1127,10 @@ async def lookup_ip(
|
||||
|
||||
with contextlib.suppress(ValueError, Fail2BanConnectionError):
|
||||
# Use fail2ban's "banned <ip>" command which checks all jails.
|
||||
_ok(await client.send(["get", "--all", "banned", ip]))
|
||||
ok(await client.send(["get", "--all", "banned", ip]))
|
||||
|
||||
# Fetch jail names from status.
|
||||
global_status = _to_dict(_ok(await client.send(["status"])))
|
||||
global_status = to_dict(ok(await client.send(["status"])))
|
||||
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
@@ -1234,7 +1149,7 @@ async def lookup_ip(
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
try:
|
||||
ban_list: list[str] = cast("list[str]", _ok(result)) or []
|
||||
ban_list: list[str] = cast("list[str]", ok(result)) or []
|
||||
if ip in ban_list:
|
||||
currently_banned_in.append(jail_name)
|
||||
except (ValueError, TypeError):
|
||||
@@ -1282,6 +1197,6 @@ async def unban_all_ips(socket_path: str) -> int:
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
count: int = int(str(_ok(await client.send(["unban", "--all"])) or 0))
|
||||
count: int = int(str(ok(await client.send(["unban", "--all"])) or 0))
|
||||
log.info("all_ips_unbanned", count=count)
|
||||
return count
|
||||
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import structlog
|
||||
|
||||
@@ -26,8 +25,8 @@ from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
Fail2BanResponse,
|
||||
)
|
||||
from app.utils.fail2ban_response import ok
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -40,21 +39,6 @@ _NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
|
||||
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
|
||||
|
||||
|
||||
def _ok(response: object) -> object:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response."""
|
||||
try:
|
||||
code, data = cast("Fail2BanResponse", response)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(
|
||||
f"Unexpected fail2ban response shape: {response!r}"
|
||||
) from exc
|
||||
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code}: {data!r}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _count_file_lines(file_path: str) -> int:
|
||||
"""Count the total number of lines in *file_path* synchronously."""
|
||||
count = 0
|
||||
@@ -71,7 +55,7 @@ async def _safe_get(
|
||||
) -> object | None:
|
||||
"""Send a command and return *default* if it fails."""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
return ok(await client.send(command))
|
||||
except (
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
|
||||
@@ -17,6 +17,7 @@ import structlog
|
||||
from app.exceptions import ServerOperationError
|
||||
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
|
||||
from app.utils.fail2ban_response import ok
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Types
|
||||
@@ -60,27 +61,6 @@ def _to_str(value: object | None, default: str) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ok(response: Fail2BanResponse) -> object:
|
||||
"""Extract payload from a fail2ban ``(code, data)`` response.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the return code indicates an error.
|
||||
"""
|
||||
try:
|
||||
code, data = response
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected response shape: {response!r}") from exc
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban error {code}: {data!r}")
|
||||
return data
|
||||
|
||||
|
||||
async def _safe_get(
|
||||
client: Fail2BanClient,
|
||||
command: Fail2BanCommand,
|
||||
@@ -98,7 +78,7 @@ async def _safe_get(
|
||||
"""
|
||||
try:
|
||||
response = await client.send(command)
|
||||
return _ok(cast("Fail2BanResponse", response))
|
||||
return ok(cast("Fail2BanResponse", response))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@@ -185,7 +165,7 @@ async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> Non
|
||||
async def _set(key: str, value: Fail2BanSettingValue) -> None:
|
||||
try:
|
||||
response = await client.send(["set", key, value])
|
||||
_ok(cast("Fail2BanResponse", response))
|
||||
ok(cast("Fail2BanResponse", response))
|
||||
except ValueError as exc:
|
||||
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
||||
|
||||
@@ -220,7 +200,7 @@ async def flush_logs(socket_path: str) -> str:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
response = await client.send(["flushlogs"])
|
||||
result = _ok(cast("Fail2BanResponse", response))
|
||||
result = ok(cast("Fail2BanResponse", response))
|
||||
log.info("logs_flushed", result=result)
|
||||
return str(result)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -17,19 +17,18 @@ from app.exceptions import (
|
||||
JailNameError,
|
||||
)
|
||||
from app.models.config import (
|
||||
ActionConfig,
|
||||
BantimeEscalation,
|
||||
InactiveJail,
|
||||
JailValidationIssue,
|
||||
JailValidationResult,
|
||||
)
|
||||
from app.utils import conffile_parser
|
||||
from app.utils.constants import FAIL2BAN_TRUTHY_VALUES
|
||||
from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanResponse,
|
||||
)
|
||||
from app.utils.fail2ban_response import ok, to_dict
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
@@ -256,26 +255,8 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
|
||||
try:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
def _to_dict_inner(pairs: object) -> dict[str, object]:
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, object] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
def _ok(response: object) -> object:
|
||||
code, data = cast("Fail2BanResponse", response)
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban error {code}: {data!r}")
|
||||
return data
|
||||
|
||||
status_raw = _ok(await client.send(["status"]))
|
||||
status_dict = _to_dict_inner(status_raw)
|
||||
status_raw = ok(await client.send(["status"]))
|
||||
status_dict = to_dict(status_raw)
|
||||
jail_list_raw: str = str(status_dict.get("Jail list", "") or "").strip()
|
||||
if not jail_list_raw:
|
||||
return set()
|
||||
|
||||
165
backend/app/utils/fail2ban_response.py
Normal file
165
backend/app/utils/fail2ban_response.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Shared utilities for parsing fail2ban responses.
|
||||
|
||||
This module provides canonical implementations of response parsing helpers
|
||||
used across all service modules. All services should import from here instead
|
||||
of maintaining local copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def ok(response: object) -> object:
|
||||
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
|
||||
|
||||
fail2ban commands return a tuple of ``(return_code, data)`` where
|
||||
``return_code`` is 0 for success or non-zero for errors. This function
|
||||
extracts and returns the ``data`` portion.
|
||||
|
||||
Args:
|
||||
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
||||
|
||||
Returns:
|
||||
The payload ``data`` portion of the response.
|
||||
|
||||
Raises:
|
||||
ValueError: If the response indicates an error (return code ≠ 0) or
|
||||
has an unexpected shape.
|
||||
|
||||
Examples:
|
||||
>>> response = (0, {'Jail list': 'sshd,recidive'})
|
||||
>>> ok(response)
|
||||
{'Jail list': 'sshd,recidive'}
|
||||
|
||||
>>> error_response = (1, 'Unknown jail')
|
||||
>>> ok(error_response)
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: fail2ban returned error code 1: 'Unknown jail'
|
||||
"""
|
||||
try:
|
||||
code_val: int
|
||||
data_val: object
|
||||
code_val, data_val = response # type: ignore[misc]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
|
||||
|
||||
if code_val != 0:
|
||||
raise ValueError(f"fail2ban returned error code {code_val}: {data_val!r}")
|
||||
|
||||
return data_val
|
||||
|
||||
|
||||
def to_dict(pairs: object) -> dict[str, object]:
|
||||
"""Convert a list of ``(key, value)`` pairs to a plain dict.
|
||||
|
||||
fail2ban returns many results as a list of key-value tuples. This
|
||||
function converts them to a regular Python dict, skipping malformed
|
||||
entries and converting keys to strings.
|
||||
|
||||
Args:
|
||||
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
|
||||
Non-list/tuple inputs return an empty dict.
|
||||
|
||||
Returns:
|
||||
A :class:`dict` with the keys and values from *pairs*. Keys are
|
||||
converted to strings; values are preserved as-is. Malformed entries
|
||||
are silently skipped.
|
||||
|
||||
Examples:
|
||||
>>> to_dict([('name', 'sshd'), ('port', 22)])
|
||||
{'name': 'sshd', 'port': 22}
|
||||
|
||||
>>> to_dict([('a', 1), 'broken', ('b', 2)])
|
||||
{'a': 1, 'b': 2}
|
||||
|
||||
>>> to_dict('not a list')
|
||||
{}
|
||||
"""
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, object] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def ensure_list(value: object | None) -> list[str]:
|
||||
"""Coerce a fail2ban response value to a list of strings.
|
||||
|
||||
Some fail2ban ``get`` responses return ``None`` when a field is empty,
|
||||
a single string when there is only one entry, or a list of strings.
|
||||
This helper normalises all three cases to a consistent list.
|
||||
|
||||
Args:
|
||||
value: The raw value from a fail2ban command response. Can be
|
||||
``None``, a string, a list/tuple of strings, or any other object.
|
||||
|
||||
Returns:
|
||||
A :class:`list` of strings. Empty input returns an empty list.
|
||||
Single strings are wrapped in a list. Lists/tuples are converted to
|
||||
strings element-wise.
|
||||
|
||||
Examples:
|
||||
>>> ensure_list(None)
|
||||
[]
|
||||
|
||||
>>> ensure_list('sshd')
|
||||
['sshd']
|
||||
|
||||
>>> ensure_list(['sshd', 'apache2'])
|
||||
['sshd', 'apache2']
|
||||
|
||||
>>> ensure_list(42)
|
||||
['42']
|
||||
"""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value] if value.strip() else []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [str(v) for v in value if v is not None]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def is_not_found_error(exc: Exception) -> bool:
|
||||
"""Return ``True`` if *exc* indicates a jail does not exist.
|
||||
|
||||
fail2ban raises errors when a jail is not found, but serializes them
|
||||
in different formats depending on the context. This function checks
|
||||
for multiple common error message patterns.
|
||||
|
||||
Args:
|
||||
exc: The exception to inspect.
|
||||
|
||||
Returns:
|
||||
``True`` if the exception message contains any of the known
|
||||
"not found" phrases (case-insensitive), ``False`` otherwise.
|
||||
|
||||
Examples:
|
||||
>>> exc = ValueError('Unknown jail: sshd')
|
||||
>>> is_not_found_error(exc)
|
||||
True
|
||||
|
||||
>>> exc = ValueError('unknownjail')
|
||||
>>> is_not_found_error(exc)
|
||||
True
|
||||
|
||||
>>> exc = ValueError('Internal error')
|
||||
>>> is_not_found_error(exc)
|
||||
False
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
return any(
|
||||
phrase in msg
|
||||
for phrase in (
|
||||
"unknown jail",
|
||||
"unknownjail",
|
||||
"no jail",
|
||||
"does not exist",
|
||||
"not found",
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user