refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
11 changed files with 832 additions and 444 deletions
Showing only changes of commit b634ce876a - Show all commits

View File

@@ -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

View File

@@ -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.

View File

@@ -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 67 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 ~4060) and `ApplicationContext` dataclass (lines ~6275) 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`

View File

@@ -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",

View File

@@ -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

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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()

View 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",
)
)