From b634ce876a149d5b61f6b8b9d0ff5c8216b354ca Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 23 Apr 2026 15:11:21 +0200 Subject: [PATCH] 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> --- Docs/Architekture.md | 11 +- Docs/Backend-Development.md | 62 +++- Docs/Tasks.md | 486 ++++++++++++++++++++++++- backend/app/services/ban_service.py | 101 +---- backend/app/services/config_service.py | 107 ++---- backend/app/services/health_service.py | 74 +--- backend/app/services/jail_service.py | 197 +++------- backend/app/services/log_service.py | 20 +- backend/app/services/server_service.py | 28 +- backend/app/utils/config_file_utils.py | 25 +- backend/app/utils/fail2ban_response.py | 165 +++++++++ 11 files changed, 832 insertions(+), 444 deletions(-) create mode 100644 backend/app/utils/fail2ban_response.py diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 47ff61b..d7be7ca 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -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 diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 65c7507..74c4aae 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -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. diff --git a/Docs/Tasks.md b/Docs/Tasks.md index c9db6d8..5ee3a47 100644 --- a/Docs/Tasks.md +++ b/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. \ No newline at end of file +**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` \ No newline at end of file diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py index af313f0..486a848 100644 --- a/backend/app/services/ban_service.py +++ b/backend/app/services/ban_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", diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 9b4ddd7..7a41365 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -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 diff --git a/backend/app/services/health_service.py b/backend/app/services/health_service.py index 5f46527..8a30854 100644 --- a/backend/app/services/health_service.py +++ b/backend/app/services/health_service.py @@ -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) ) diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index 2f1db5b..259bf4a 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -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 " 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 diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py index df80964..76978ca 100644 --- a/backend/app/services/log_service.py +++ b/backend/app/services/log_service.py @@ -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, diff --git a/backend/app/services/server_service.py b/backend/app/services/server_service.py index d9f91c6..a4b99b5 100644 --- a/backend/app/services/server_service.py +++ b/backend/app/services/server_service.py @@ -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: diff --git a/backend/app/utils/config_file_utils.py b/backend/app/utils/config_file_utils.py index 26d339f..74ba1e1 100644 --- a/backend/app/utils/config_file_utils.py +++ b/backend/app/utils/config_file_utils.py @@ -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() diff --git a/backend/app/utils/fail2ban_response.py b/backend/app/utils/fail2ban_response.py new file mode 100644 index 0000000..070b176 --- /dev/null +++ b/backend/app/utils/fail2ban_response.py @@ -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", + ) + )