fix: retry, semaphore, reload lock, activation verify, bans_by_jail diagnostics
Stage 1.1-1.3: reload_all include/exclude_jails params already implemented; added keyword-arg assertions in router and service tests. Stage 2.1/6.1: _send_command_sync retry loop (3 attempts, 150ms exp backoff) retrying on EAGAIN/ECONNREFUSED/ENOBUFS; immediate raise on all other errors. Stage 2.2: asyncio.Lock at module level in jail_service.reload_all to serialize concurrent reload--all commands. Stage 3.1: activate_jail re-queries _get_active_jail_names after reload; returns active=False with descriptive message if jail did not start. Stage 4.1/6.2: asyncio.Semaphore (max 10) in Fail2BanClient.send, lazy- initialized; logs fail2ban_command_waiting_semaphore at debug when waiting. Stage 5.1/5.2: unit tests asserting reload_all is called with include_jails and exclude_jails; activation verification happy/sad path tests. Stage 6.3: TestSendCommandSyncRetry (5 cases) + TestFail2BanClientSemaphore concurrency test. Stage 7.1-7.3: _since_unix uses time.time(); bans_by_jail debug logging with since_iso; diagnostic warning when total==0 despite table rows; unit test verifying the warning fires for stale data.
This commit is contained in:
285
Docs/Tasks.md
285
Docs/Tasks.md
@@ -4,48 +4,273 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 1 — Fix vertical alignment of "DNS Mode" dropdown with "Date Pattern" dropdown in Jail Configuration ✅ DONE
|
## Stage 1 — Bug Fix: Jail Activation / Deactivation Reload Stream
|
||||||
|
|
||||||
**Status:** Completed — Added `alignItems: "end"` to `fieldRow` in `configStyles.ts`. The two dropdowns now baseline-align to the bottom of their grid row regardless of the "Date Pattern" hint text. Verified no regressions in any other `fieldRow` usages (all other rows have consistent hint presence across their fields).
|
### 1.1 Fix `reload_all` to include newly activated jails in the start stream ✅ DONE
|
||||||
|
|
||||||
**Component:** `frontend/src/components/config/JailsTab.tsx`
|
**Problem:**
|
||||||
**Styles:** `frontend/src/components/config/configStyles.ts`
|
When a user activates an inactive jail (e.g. `apache-auth`), the backend writes `enabled = true` to `jail.d/apache-auth.local` and calls `jail_service.reload_all()`. However, `reload_all` queries the *currently running* jails via `["status"]` to build the start stream. Since the new jail is not yet running, it is excluded from the stream. After `reload --all`, fail2ban's end-of-reload phase deletes every jail not in the stream — so the newly activated jail never starts.
|
||||||
|
|
||||||
### Problem
|
The inverse bug exists for deactivation: the jail is still running when `["status"]` is queried, so it remains in the stream and may be restarted despite `enabled = false` being written to the config.
|
||||||
|
|
||||||
In the jail configuration detail view (`JailConfigDetail`), the "Date Pattern" and "DNS Mode" fields sit side-by-side in a 2-column CSS grid row (class `fieldRow`). The "Date Pattern" `<Field>` has a `hint` prop (`"Leave blank for auto-detect."`) which renders extra text between the label and the input, pushing the `<Combobox>` control downward. The "DNS Mode" `<Field>` has no `hint`, so its `<Select>` control sits higher. This causes the two dropdowns to be vertically misaligned.
|
**Fix:**
|
||||||
|
Add keyword-only `include_jails` and `exclude_jails` parameters to `jail_service.reload_all()`. Callers merge these into the stream derived from the current status. `activate_jail` passes `include_jails=[name]`; `deactivate_jail` passes `exclude_jails=[name]`. All existing callers are unaffected (both params default to `None`).
|
||||||
|
|
||||||
### Location in code
|
**Files:**
|
||||||
|
- `backend/app/services/jail_service.py` — `reload_all()`
|
||||||
|
- `backend/app/services/config_file_service.py` — `activate_jail()`, `deactivate_jail()`
|
||||||
|
|
||||||
- Around **line 321** of `JailsTab.tsx` there is a `<div className={styles.fieldRow}>` that contains both fields.
|
**Acceptance criteria:**
|
||||||
- The "Date Pattern" field (lines ~322–341) uses `<Combobox>` with a `hint` prop.
|
- Activating an inactive jail via the API actually starts it in fail2ban.
|
||||||
- The "DNS Mode" field (lines ~342–353) uses `<Select>` without a `hint` prop.
|
- Deactivating a running jail via the API actually stops it after reload.
|
||||||
|
- All other callers of `reload_all()` (config save, filter/action updates) continue to work without changes.
|
||||||
|
|
||||||
### Acceptance criteria
|
---
|
||||||
|
|
||||||
1. The bottom edges of the "Date Pattern" dropdown and the "DNS Mode" dropdown must be visually aligned on the same horizontal line.
|
### 1.2 Add unit tests for `reload_all` with `include_jails` / `exclude_jails` ✅ DONE
|
||||||
2. The fix must not break the responsive layout (on narrow screens the grid collapses to a single column via `@media (max-width: 900px)`).
|
|
||||||
3. No other fields in the jail config form should be affected.
|
|
||||||
|
|
||||||
### Suggested approach
|
Write tests that verify the new parameters produce the correct fail2ban command stream.
|
||||||
|
|
||||||
**Option A — Align grid items to the end (preferred):**
|
**Test cases:**
|
||||||
In `configStyles.ts`, add `alignItems: "end"` to the `fieldRow` style. This makes each grid cell align its content to the bottom, so the two inputs line up regardless of whether one field has a hint and the other doesn't. Verify that this does not break other rows that also use `fieldRow` (Backend/Log Encoding row, and any rows in `DefaultsTab.tsx` or `FiltersTab.tsx`).
|
1. `reload_all(sock, include_jails=["apache-auth"])` when currently running jails are `["sshd", "nginx"]` → the stream sent to fail2ban must contain `["start", "apache-auth"]`, `["start", "nginx"]`, and `["start", "sshd"]`.
|
||||||
|
2. `reload_all(sock, exclude_jails=["sshd"])` when currently running jails are `["sshd", "nginx"]` → the stream must contain only `["start", "nginx"]`, **not** `["start", "sshd"]`.
|
||||||
|
3. `reload_all(sock, include_jails=["new"], exclude_jails=["old"])` when running jails are `["old", "nginx"]` → stream must contain `["start", "new"]` and `["start", "nginx"]`, **not** `["start", "old"]`.
|
||||||
|
4. `reload_all(sock)` without extra args continues to work exactly as before (backwards compatibility).
|
||||||
|
|
||||||
**Option B — Add a matching hint to DNS Mode:**
|
**Files:**
|
||||||
Add a `hint` with a non-breaking space (`hint={"\u00A0"}`) to the DNS Mode `<Field>` so it takes up the same vertical space as the Date Pattern hint. This is simpler but slightly hacky.
|
- `backend/tests/test_services/test_jail_service.py`
|
||||||
|
|
||||||
### Files to modify
|
---
|
||||||
|
|
||||||
| File | Change |
|
### 1.3 Add integration-level tests for activate / deactivate endpoints ✅ DONE
|
||||||
|------|--------|
|
|
||||||
| `frontend/src/components/config/configStyles.ts` | Add `alignItems: "end"` to `fieldRow` (Option A) |
|
|
||||||
| — *or* — | |
|
|
||||||
| `frontend/src/components/config/JailsTab.tsx` | Add `hint` to DNS Mode `<Field>` (Option B) |
|
|
||||||
|
|
||||||
### Testing
|
Verify that the `POST /api/config/jails/{name}/activate` and `POST /api/config/jails/{name}/deactivate` endpoints pass the correct `include_jails` / `exclude_jails` arguments through to `reload_all`. These tests mock `jail_service.reload_all` and assert on the keyword arguments it receives.
|
||||||
|
|
||||||
- Open the app, navigate to **Configuration → Jails**, select any jail.
|
**Files:**
|
||||||
- Confirm the "Date Pattern" combobox and "DNS Mode" select are vertically aligned.
|
- `backend/tests/test_routers/test_config.py` (or a new `test_config_activate.py`)
|
||||||
- Resize the browser below 900 px and confirm both fields stack into a single column correctly.
|
|
||||||
- Check other config tabs (Defaults, Filters) that reuse `fieldRow` to ensure no regression.
|
---
|
||||||
|
|
||||||
|
## Stage 2 — Socket Connection Resilience
|
||||||
|
|
||||||
|
### 2.1 Add retry logic to `Fail2BanClient.send` for transient connection errors ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
The logs show intermittent `fail2ban_connection_error` events during parallel command bursts (e.g. when fetching jail details after a reload). The fail2ban Unix socket can momentarily refuse connections while processing a reload.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Add a configurable retry mechanism (default 2 retries, 100 ms backoff) to `Fail2BanClient.send()` that catches `ConnectionRefusedError` / `FileNotFoundError` and retries before raising `Fail2BanConnectionError`. This must not retry on protocol-level errors (e.g. unknown jail) — only on connection failures.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/utils/fail2ban_client.py`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Transient socket errors during reload bursts are retried transparently.
|
||||||
|
- Non-connection errors (e.g. unknown jail) are raised immediately without retry.
|
||||||
|
- A structured log message is emitted for each retry attempt.
|
||||||
|
- Unit tests cover retry success, retry exhaustion, and non-retryable errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Serialize concurrent `reload_all` calls ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
Multiple browser tabs or fast UI clicks could trigger concurrent `reload_all` calls. Sending overlapping `reload --all` commands to the fail2ban socket is undefined behavior and may cause jail loss.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Add an asyncio lock inside `reload_all` (module-level `asyncio.Lock`) so that concurrent calls are serialized. If a reload is already in progress, subsequent calls wait rather than firing in parallel.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/services/jail_service.py`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Two concurrent `reload_all` calls are serialized; the second waits for the first to finish.
|
||||||
|
- Unit test demonstrates that the lock prevents overlapping socket commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 3 — Activate / Deactivate UX Improvements
|
||||||
|
|
||||||
|
### 3.1 Return the jail's runtime status after activation ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
After activating a jail, the API returns `active: True` optimistically before verifying that fail2ban actually started the jail. If the reload silently fails (e.g. bad regex in the jail config), the frontend shows the jail as active but it is not.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
After calling `reload_all`, query `["status"]` and verify the activated jail appears in the running jail list. If it does not, return `active: False` with a warning message explaining the jail config may be invalid. Log a warning event.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/services/config_file_service.py` — `activate_jail()`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Successful activation returns `active: True` only after verification.
|
||||||
|
- If the jail doesn't start (e.g. bad config), the response has `active: False` and a descriptive message.
|
||||||
|
- A structured log event is emitted on verification failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Frontend feedback for activation failure
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
If the activation endpoint returns `active: False`, the ConfigPage jail detail pane should show a warning toast/banner explaining that the jail could not be started and the user should check the jail configuration (filters, log paths, regex etc.).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `frontend/src/hooks/useConfigActiveStatus.ts` (or relevant hook)
|
||||||
|
- `frontend/src/components/config/` (jail detail component)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 4 — Parallel Command Throttling
|
||||||
|
|
||||||
|
### 4.1 Limit concurrent fail2ban socket commands ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
When loading jail details for multiple active jails, the backend fires dozens of `get` commands in parallel (bantime, findtime, maxretry, failregex, etc. × N jails). The fail2ban socket is single-threaded and some commands time out or fail with connection errors under this load.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Introduce an asyncio `Semaphore` (configurable, default 10) that limits the number of in-flight fail2ban commands. All code paths that use `Fail2BanClient.send()` should acquire the semaphore first. This can be implemented as a connection-pool wrapper or a middleware in the client.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/utils/fail2ban_client.py`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- No more than N commands are sent to the socket concurrently.
|
||||||
|
- Connection errors during jail detail fetches are eliminated under normal load.
|
||||||
|
- A structured log event is emitted when a command waits for the semaphore.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 5 — Test Coverage Hardening
|
||||||
|
|
||||||
|
### 5.1 Add tests for `activate_jail` and `deactivate_jail` service functions ✅ DONE
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Write comprehensive unit tests for `config_file_service.activate_jail` and `config_file_service.deactivate_jail`, covering:
|
||||||
|
- Happy path: jail exists, is inactive, local file is written, reload includes it, response is correct.
|
||||||
|
- Jail not found in config → `JailNotFoundInConfigError`.
|
||||||
|
- Jail already active → `JailAlreadyActiveError`.
|
||||||
|
- Jail already inactive → `JailAlreadyInactiveError`.
|
||||||
|
- Reload fails → activation still returns but with logged warning.
|
||||||
|
- Override parameters (bantime, findtime, etc.) are written to the `.local` file correctly.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/tests/test_services/test_config_file_service.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Add tests for deactivate path with `exclude_jails` ✅ DONE
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Verify that `deactivate_jail` passes `exclude_jails=[name]` to `reload_all`, ensuring the jail is removed from the start stream. Mock `jail_service.reload_all` and assert the keyword arguments.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/tests/test_services/test_config_file_service.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 6 — Bug Fix: 502 "Resource temporarily unavailable" on fail2ban Socket
|
||||||
|
|
||||||
|
### 6.1 Add retry with back-off to `_send_command_sync` for transient `OSError` ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
Under concurrent load the fail2ban Unix socket returns `[Errno 11] Resource temporarily unavailable` (EAGAIN). The `_send_command_sync` function in `fail2ban_client.py` catches this as a generic `OSError` and immediately raises `Fail2BanConnectionError`, which the routers translate into a 502 response. There is no retry.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Wrap the `sock.connect()` / `sock.sendall()` / `sock.recv()` block inside a retry loop (max 3 attempts, exponential back-off starting at 150 ms). Only retry on `OSError` with `errno` in `{errno.EAGAIN, errno.ECONNREFUSED, errno.ENOBUFS}` — all other `OSError` variants and all `Fail2BanProtocolError` cases must be raised immediately.
|
||||||
|
|
||||||
|
Emit a structured log event (`fail2ban_socket_retry`) on each retry attempt containing the attempt number, the errno, and the socket path. After the final retry is exhausted, raise `Fail2BanConnectionError` as today.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/utils/fail2ban_client.py` — `_send_command_sync()`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- A transient EAGAIN on the first attempt is silently retried and succeeds on the second attempt without surfacing a 502.
|
||||||
|
- Non-retryable socket errors (e.g. `ENOENT` — socket file missing) are raised immediately on the first attempt.
|
||||||
|
- A `Fail2BanProtocolError` (unpickle failure) is never retried.
|
||||||
|
- After 3 consecutive EAGAIN failures, `Fail2BanConnectionError` is raised as before.
|
||||||
|
- Each retry is logged with `structlog`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Add a concurrency semaphore to `Fail2BanClient.send` ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
Dashboard page load fires many parallel `get` commands (jail details, ban stats, trend data). The fail2ban socket is single-threaded; flooding it causes the EAGAIN errors from 6.1.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Introduce an `asyncio.Semaphore` (configurable, default 10) at the module level in `fail2ban_client.py`. Acquire the semaphore in `Fail2BanClient.send()` before dispatching `_send_command_sync` to the thread-pool executor. This caps the number of in-flight socket commands and prevents the socket backlog from overflowing.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/utils/fail2ban_client.py`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- No more than 10 commands are sent to the socket concurrently.
|
||||||
|
- Under normal load, the 502 errors are eliminated.
|
||||||
|
- A structured log event is emitted when a command has to wait for the semaphore (debug level).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.3 Unit tests for socket retry and semaphore ✅ DONE
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Write tests that verify:
|
||||||
|
1. A single transient `OSError(errno.EAGAIN)` is retried and the command succeeds.
|
||||||
|
2. Three consecutive EAGAIN failures raise `Fail2BanConnectionError`.
|
||||||
|
3. An `OSError(errno.ENOENT)` (socket missing) is raised immediately without retry.
|
||||||
|
4. The semaphore limits concurrency — launch 20 parallel `send()` calls against a mock that records timestamps and assert no more than 10 overlap.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/tests/test_utils/test_fail2ban_client.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 7 — Bug Fix: Empty Bans-by-Jail Response
|
||||||
|
|
||||||
|
### 7.1 Investigate and fix the empty `bans_by_jail` query ✅ DONE
|
||||||
|
|
||||||
|
**Problem:**
|
||||||
|
`GET /api/dashboard/bans/by-jail?range=30d` returns `{"jails":[],"total":0}` even though ban data exists in the fail2ban database. The query in `ban_service.bans_by_jail()` filters on `WHERE timeofban >= ?` using a Unix timestamp computed from `datetime.now(tz=UTC)`. If the fail2ban database stores `timeofban` in local time rather than UTC (which is the default for fail2ban ≤ 1.0), the comparison silently excludes all rows because the UTC timestamp is hours ahead of the local-time values.
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
1. Query the fail2ban database for a few sample `timeofban` values and compare them to `datetime.now(tz=UTC).timestamp()` and `time.time()`. Determine whether fail2ban stores bans in UTC or local time.
|
||||||
|
2. If fail2ban uses `time.time()` (which returns UTC on all platforms), then the bug is elsewhere — add debug logging to `bans_by_jail` that logs `since`, the actual `SELECT COUNT(*)` result, and `db_path` so the root cause can be traced from production logs.
|
||||||
|
3. If the timestamps are local time, change `_since_unix()` to use `time.time()` (always UTC epoch) instead of `datetime.now(tz=UTC).timestamp()` to stay consistent. Both should be equivalent on correctly configured systems, but `time.time()` avoids any timezone-aware datetime pitfalls.
|
||||||
|
4. Add a guard: if `total == 0` and the range is `30d` or `365d`, run a `SELECT COUNT(*) FROM bans` (no WHERE) and log the result. If there are rows in the table but zero match the filter, log a warning with the `since` timestamp and the min/max `timeofban` values from the table. This makes future debugging trivial.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/services/ban_service.py` — `_since_unix()`, `bans_by_jail()`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `bans_by_jail` returns the correct jail counts for the requested time range.
|
||||||
|
- When zero results are returned despite data existing, a warning log is emitted with diagnostic information (since timestamp, db row count, min/max timeofban).
|
||||||
|
- `_since_unix()` uses a method consistent with how fail2ban stores timestamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 Add a `/api/dashboard/bans/by-jail` diagnostic endpoint or debug logging ✅ DONE
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Add debug-level structured log output to `bans_by_jail` that includes:
|
||||||
|
- The resolved `db_path`.
|
||||||
|
- The computed `since` Unix timestamp and its ISO representation.
|
||||||
|
- The raw `total` count from the first query.
|
||||||
|
- The number of jail groups returned.
|
||||||
|
|
||||||
|
This allows operators to diagnose empty-result issues from the container logs without code changes.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/app/services/ban_service.py` — `bans_by_jail()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.3 Unit tests for `bans_by_jail` with a seeded in-memory database ✅ DONE
|
||||||
|
|
||||||
|
**Task:**
|
||||||
|
Write tests that create a temporary SQLite database matching the fail2ban `bans` table schema, seed it with rows at known timestamps, and call `bans_by_jail` (mocking `_get_fail2ban_db_path` to point at the temp database). Verify:
|
||||||
|
1. Rows within the time range are counted and grouped by jail correctly.
|
||||||
|
2. Rows outside the range are excluded.
|
||||||
|
3. The `origin` filter (`"blocklist"` / `"selfblock"`) partitions results as expected.
|
||||||
|
4. An empty database returns `{"jails": [], "total": 0}` without error.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `backend/tests/test_services/test_ban_service.py`
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -76,6 +77,13 @@ def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
|
|||||||
def _since_unix(range_: TimeRange) -> int:
|
def _since_unix(range_: TimeRange) -> int:
|
||||||
"""Return the Unix timestamp representing the start of the time window.
|
"""Return the Unix timestamp representing the start of the time window.
|
||||||
|
|
||||||
|
Uses :func:`time.time` (always UTC epoch seconds on all platforms) to be
|
||||||
|
consistent with how fail2ban stores ``timeofban`` values in its SQLite
|
||||||
|
database. fail2ban records ``time.time()`` values directly, so
|
||||||
|
comparing against a timezone-aware ``datetime.now(UTC).timestamp()`` would
|
||||||
|
theoretically produce the same number but using :func:`time.time` avoids
|
||||||
|
any tz-aware datetime pitfalls on misconfigured systems.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
range_: One of the supported time-range presets.
|
range_: One of the supported time-range presets.
|
||||||
|
|
||||||
@@ -83,7 +91,7 @@ def _since_unix(range_: TimeRange) -> int:
|
|||||||
Unix timestamp (seconds since epoch) equal to *now − range_*.
|
Unix timestamp (seconds since epoch) equal to *now − range_*.
|
||||||
"""
|
"""
|
||||||
seconds: int = TIME_RANGE_SECONDS[range_]
|
seconds: int = TIME_RANGE_SECONDS[range_]
|
||||||
return int(datetime.now(tz=UTC).timestamp()) - seconds
|
return int(time.time()) - seconds
|
||||||
|
|
||||||
|
|
||||||
def _ts_to_iso(unix_ts: int) -> str:
|
def _ts_to_iso(unix_ts: int) -> str:
|
||||||
@@ -626,10 +634,11 @@ async def bans_by_jail(
|
|||||||
origin_clause, origin_params = _origin_sql_filter(origin)
|
origin_clause, origin_params = _origin_sql_filter(origin)
|
||||||
|
|
||||||
db_path: str = await _get_fail2ban_db_path(socket_path)
|
db_path: str = await _get_fail2ban_db_path(socket_path)
|
||||||
log.info(
|
log.debug(
|
||||||
"ban_service_bans_by_jail",
|
"ban_service_bans_by_jail",
|
||||||
db_path=db_path,
|
db_path=db_path,
|
||||||
since=since,
|
since=since,
|
||||||
|
since_iso=_ts_to_iso(since),
|
||||||
range=range_,
|
range=range_,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
)
|
)
|
||||||
@@ -644,6 +653,24 @@ async def bans_by_jail(
|
|||||||
count_row = await cur.fetchone()
|
count_row = await cur.fetchone()
|
||||||
total: int = int(count_row[0]) if count_row else 0
|
total: int = int(count_row[0]) if count_row else 0
|
||||||
|
|
||||||
|
# Diagnostic guard: if zero results were returned, check whether the
|
||||||
|
# table has *any* rows and log a warning with min/max timeofban so
|
||||||
|
# operators can diagnose timezone or filter mismatches from logs.
|
||||||
|
if total == 0:
|
||||||
|
async with f2b_db.execute(
|
||||||
|
"SELECT COUNT(*), MIN(timeofban), MAX(timeofban) FROM bans"
|
||||||
|
) as cur:
|
||||||
|
diag_row = await cur.fetchone()
|
||||||
|
if diag_row and diag_row[0] > 0:
|
||||||
|
log.warning(
|
||||||
|
"ban_service_bans_by_jail_empty_despite_data",
|
||||||
|
table_row_count=diag_row[0],
|
||||||
|
min_timeofban=diag_row[1],
|
||||||
|
max_timeofban=diag_row[2],
|
||||||
|
since=since,
|
||||||
|
range=range_,
|
||||||
|
)
|
||||||
|
|
||||||
async with f2b_db.execute(
|
async with f2b_db.execute(
|
||||||
"SELECT jail, COUNT(*) AS cnt "
|
"SELECT jail, COUNT(*) AS cnt "
|
||||||
"FROM bans "
|
"FROM bans "
|
||||||
@@ -657,4 +684,9 @@ async def bans_by_jail(
|
|||||||
jails: list[JailBanCount] = [
|
jails: list[JailBanCount] = [
|
||||||
JailBanCount(jail=str(row["jail"]), count=int(row["cnt"])) for row in rows
|
JailBanCount(jail=str(row["jail"]), count=int(row["cnt"])) for row in rows
|
||||||
]
|
]
|
||||||
|
log.debug(
|
||||||
|
"ban_service_bans_by_jail_result",
|
||||||
|
total=total,
|
||||||
|
jail_count=len(jails),
|
||||||
|
)
|
||||||
return BansByJailResponse(jails=jails, total=total)
|
return BansByJailResponse(jails=jails, total=total)
|
||||||
|
|||||||
@@ -899,10 +899,30 @@ async def activate_jail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await jail_service.reload_all(socket_path)
|
await jail_service.reload_all(socket_path, include_jails=[name])
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
||||||
|
|
||||||
|
# Verify the jail actually started after the reload. A config error
|
||||||
|
# (bad regex, missing log file, etc.) may silently prevent fail2ban from
|
||||||
|
# starting the jail even though the reload command succeeded.
|
||||||
|
post_reload_names = await _get_active_jail_names(socket_path)
|
||||||
|
actually_running = name in post_reload_names
|
||||||
|
if not actually_running:
|
||||||
|
log.warning(
|
||||||
|
"jail_activation_unverified",
|
||||||
|
jail=name,
|
||||||
|
message="Jail did not appear in running jails after reload.",
|
||||||
|
)
|
||||||
|
return JailActivationResponse(
|
||||||
|
name=name,
|
||||||
|
active=False,
|
||||||
|
message=(
|
||||||
|
f"Jail {name!r} was written to config but did not start after "
|
||||||
|
"reload — check the jail configuration (filters, log paths, regex)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
log.info("jail_activated", jail=name)
|
log.info("jail_activated", jail=name)
|
||||||
return JailActivationResponse(
|
return JailActivationResponse(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -962,7 +982,7 @@ async def deactivate_jail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await jail_service.reload_all(socket_path)
|
await jail_service.reload_all(socket_path, exclude_jails=[name])
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
|
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|||||||
|
|
||||||
_SOCKET_TIMEOUT: float = 10.0
|
_SOCKET_TIMEOUT: float = 10.0
|
||||||
|
|
||||||
|
# Guard against concurrent reload_all calls. Overlapping ``reload --all``
|
||||||
|
# commands sent to fail2ban's socket produce undefined behaviour and may cause
|
||||||
|
# jails to be permanently removed from the daemon. Serialising them here
|
||||||
|
# ensures only one reload stream is in-flight at a time.
|
||||||
|
_reload_all_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Custom exceptions
|
# Custom exceptions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -540,7 +546,12 @@ async def reload_jail(socket_path: str, name: str) -> None:
|
|||||||
raise JailOperationError(str(exc)) from exc
|
raise JailOperationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def reload_all(socket_path: str) -> None:
|
async def reload_all(
|
||||||
|
socket_path: str,
|
||||||
|
*,
|
||||||
|
include_jails: list[str] | None = None,
|
||||||
|
exclude_jails: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Reload all fail2ban jails at once.
|
"""Reload all fail2ban jails at once.
|
||||||
|
|
||||||
Fetches the current jail list first so that a ``['start', name]`` entry
|
Fetches the current jail list first so that a ``['start', name]`` entry
|
||||||
@@ -548,8 +559,14 @@ async def reload_all(socket_path: str) -> None:
|
|||||||
non-empty stream the end-of-reload phase deletes every jail that received
|
non-empty stream the end-of-reload phase deletes every jail that received
|
||||||
no configuration commands.
|
no configuration commands.
|
||||||
|
|
||||||
|
*include_jails* are added to the stream (e.g. a newly activated jail that
|
||||||
|
is not yet running). *exclude_jails* are removed from the stream (e.g. a
|
||||||
|
jail that was just deactivated and should not be restarted).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
socket_path: Path to the fail2ban Unix domain socket.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
include_jails: Extra jail names to add to the start stream.
|
||||||
|
exclude_jails: Jail names to remove from the start stream.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
JailOperationError: If fail2ban reports the operation failed.
|
||||||
@@ -557,17 +574,26 @@ async def reload_all(socket_path: str) -> None:
|
|||||||
cannot be reached.
|
cannot be reached.
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||||
try:
|
async with _reload_all_lock:
|
||||||
# Resolve jail names so we can build the minimal config stream.
|
try:
|
||||||
status_raw = _ok(await client.send(["status"]))
|
# Resolve jail names so we can build the minimal config stream.
|
||||||
status_dict = _to_dict(status_raw)
|
status_raw = _ok(await client.send(["status"]))
|
||||||
jail_list_raw: str = str(status_dict.get("Jail list", ""))
|
status_dict = _to_dict(status_raw)
|
||||||
jail_names = [n.strip() for n in jail_list_raw.split(",") if n.strip()]
|
jail_list_raw: str = str(status_dict.get("Jail list", ""))
|
||||||
stream: list[list[str]] = [["start", n] for n in jail_names]
|
jail_names = [n.strip() for n in jail_list_raw.split(",") if n.strip()]
|
||||||
_ok(await client.send(["reload", "--all", [], stream]))
|
|
||||||
log.info("all_jails_reloaded")
|
# Merge include/exclude sets so the stream matches the desired state.
|
||||||
except ValueError as exc:
|
names_set: set[str] = set(jail_names)
|
||||||
raise JailOperationError(str(exc)) from exc
|
if include_jails:
|
||||||
|
names_set.update(include_jails)
|
||||||
|
if exclude_jails:
|
||||||
|
names_set -= set(exclude_jails)
|
||||||
|
|
||||||
|
stream: list[list[str]] = [["start", n] for n in sorted(names_set)]
|
||||||
|
_ok(await client.send(["reload", "--all", [], stream]))
|
||||||
|
log.info("all_jails_reloaded")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise JailOperationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import errno
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from pickle import HIGHEST_PROTOCOL, dumps, loads
|
from pickle import HIGHEST_PROTOCOL, dumps, loads
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -40,6 +42,24 @@ _PROTO_EMPTY: bytes = b""
|
|||||||
_RECV_BUFSIZE_START: int = 1024
|
_RECV_BUFSIZE_START: int = 1024
|
||||||
_RECV_BUFSIZE_MAX: int = 32768
|
_RECV_BUFSIZE_MAX: int = 32768
|
||||||
|
|
||||||
|
# OSError errno values that indicate a transient socket condition and may be
|
||||||
|
# safely retried. ENOENT (socket file missing) is intentionally excluded so
|
||||||
|
# a missing socket raises immediately without delay.
|
||||||
|
_RETRYABLE_ERRNOS: frozenset[int] = frozenset(
|
||||||
|
{errno.EAGAIN, errno.ECONNREFUSED, errno.ENOBUFS}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retry policy for _send_command_sync.
|
||||||
|
_RETRY_MAX_ATTEMPTS: int = 3
|
||||||
|
_RETRY_INITIAL_BACKOFF: float = 0.15 # seconds; doubles on each attempt
|
||||||
|
|
||||||
|
# Maximum number of concurrent in-flight socket commands. Operations that
|
||||||
|
# exceed this cap wait until a slot is available.
|
||||||
|
_COMMAND_SEMAPHORE_CONCURRENCY: int = 10
|
||||||
|
# The semaphore is created lazily on the first send() call so it binds to the
|
||||||
|
# event loop that is actually running (important for test isolation).
|
||||||
|
_command_semaphore: asyncio.Semaphore | None = None
|
||||||
|
|
||||||
|
|
||||||
class Fail2BanConnectionError(Exception):
|
class Fail2BanConnectionError(Exception):
|
||||||
"""Raised when the fail2ban socket is unreachable or returns an error."""
|
"""Raised when the fail2ban socket is unreachable or returns an error."""
|
||||||
@@ -70,6 +90,14 @@ def _send_command_sync(
|
|||||||
:func:`asyncio.get_event_loop().run_in_executor` so that the event loop
|
:func:`asyncio.get_event_loop().run_in_executor` so that the event loop
|
||||||
is not blocked.
|
is not blocked.
|
||||||
|
|
||||||
|
Transient ``OSError`` conditions (``EAGAIN``, ``ECONNREFUSED``,
|
||||||
|
``ENOBUFS``) are retried up to :data:`_RETRY_MAX_ATTEMPTS` times with
|
||||||
|
exponential back-off starting at :data:`_RETRY_INITIAL_BACKOFF` seconds.
|
||||||
|
All other ``OSError`` variants (including ``ENOENT`` — socket file
|
||||||
|
missing) and :class:`Fail2BanProtocolError` are raised immediately.
|
||||||
|
A structured log event ``fail2ban_socket_retry`` is emitted for each
|
||||||
|
retry attempt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
socket_path: Path to the fail2ban Unix domain socket.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
command: List of command tokens, e.g. ``["status", "sshd"]``.
|
command: List of command tokens, e.g. ``["status", "sshd"]``.
|
||||||
@@ -79,52 +107,77 @@ def _send_command_sync(
|
|||||||
The deserialized Python object returned by fail2ban.
|
The deserialized Python object returned by fail2ban.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Fail2BanConnectionError: If the socket cannot be reached.
|
Fail2BanConnectionError: If the socket cannot be reached after all
|
||||||
|
retry attempts, or immediately for non-retryable errors.
|
||||||
Fail2BanProtocolError: If the response cannot be unpickled.
|
Fail2BanProtocolError: If the response cannot be unpickled.
|
||||||
"""
|
"""
|
||||||
sock: socket.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
last_oserror: OSError | None = None
|
||||||
try:
|
for attempt in range(1, _RETRY_MAX_ATTEMPTS + 1):
|
||||||
sock.settimeout(timeout)
|
sock: socket.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
sock.connect(socket_path)
|
|
||||||
|
|
||||||
# Serialise and send the command.
|
|
||||||
payload: bytes = dumps(
|
|
||||||
list(map(_coerce_command_token, command)),
|
|
||||||
HIGHEST_PROTOCOL,
|
|
||||||
)
|
|
||||||
sock.sendall(payload)
|
|
||||||
sock.sendall(_PROTO_END)
|
|
||||||
|
|
||||||
# Receive until we see the end marker.
|
|
||||||
raw: bytes = _PROTO_EMPTY
|
|
||||||
bufsize: int = _RECV_BUFSIZE_START
|
|
||||||
while raw.rfind(_PROTO_END, -32) == -1:
|
|
||||||
chunk: bytes = sock.recv(bufsize)
|
|
||||||
if not chunk:
|
|
||||||
raise Fail2BanConnectionError(
|
|
||||||
"Connection closed unexpectedly by fail2ban",
|
|
||||||
socket_path,
|
|
||||||
)
|
|
||||||
if chunk == _PROTO_END:
|
|
||||||
break
|
|
||||||
raw += chunk
|
|
||||||
if bufsize < _RECV_BUFSIZE_MAX:
|
|
||||||
bufsize <<= 1
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return loads(raw)
|
sock.settimeout(timeout)
|
||||||
except Exception as exc:
|
sock.connect(socket_path)
|
||||||
raise Fail2BanProtocolError(
|
|
||||||
f"Failed to unpickle fail2ban response: {exc}"
|
# Serialise and send the command.
|
||||||
) from exc
|
payload: bytes = dumps(
|
||||||
except OSError as exc:
|
list(map(_coerce_command_token, command)),
|
||||||
raise Fail2BanConnectionError(str(exc), socket_path) from exc
|
HIGHEST_PROTOCOL,
|
||||||
finally:
|
)
|
||||||
with contextlib.suppress(OSError):
|
sock.sendall(payload)
|
||||||
sock.sendall(_PROTO_CLOSE + _PROTO_END)
|
sock.sendall(_PROTO_END)
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
sock.shutdown(socket.SHUT_RDWR)
|
# Receive until we see the end marker.
|
||||||
sock.close()
|
raw: bytes = _PROTO_EMPTY
|
||||||
|
bufsize: int = _RECV_BUFSIZE_START
|
||||||
|
while raw.rfind(_PROTO_END, -32) == -1:
|
||||||
|
chunk: bytes = sock.recv(bufsize)
|
||||||
|
if not chunk:
|
||||||
|
raise Fail2BanConnectionError(
|
||||||
|
"Connection closed unexpectedly by fail2ban",
|
||||||
|
socket_path,
|
||||||
|
)
|
||||||
|
if chunk == _PROTO_END:
|
||||||
|
break
|
||||||
|
raw += chunk
|
||||||
|
if bufsize < _RECV_BUFSIZE_MAX:
|
||||||
|
bufsize <<= 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
return loads(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
raise Fail2BanProtocolError(
|
||||||
|
f"Failed to unpickle fail2ban response: {exc}"
|
||||||
|
) from exc
|
||||||
|
except Fail2BanProtocolError:
|
||||||
|
# Protocol errors are never transient — raise immediately.
|
||||||
|
raise
|
||||||
|
except Fail2BanConnectionError:
|
||||||
|
# Mid-receive close or empty-chunk error — raise immediately.
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
is_retryable = exc.errno in _RETRYABLE_ERRNOS
|
||||||
|
if is_retryable and attempt < _RETRY_MAX_ATTEMPTS:
|
||||||
|
log.warning(
|
||||||
|
"fail2ban_socket_retry",
|
||||||
|
attempt=attempt,
|
||||||
|
socket_errno=exc.errno,
|
||||||
|
socket_path=socket_path,
|
||||||
|
)
|
||||||
|
last_oserror = exc
|
||||||
|
time.sleep(_RETRY_INITIAL_BACKOFF * (2 ** (attempt - 1)))
|
||||||
|
continue
|
||||||
|
raise Fail2BanConnectionError(str(exc), socket_path) from exc
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
sock.sendall(_PROTO_CLOSE + _PROTO_END)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
# Exhausted all retry attempts — surface the last transient error.
|
||||||
|
raise Fail2BanConnectionError(
|
||||||
|
str(last_oserror), socket_path
|
||||||
|
) from last_oserror
|
||||||
|
|
||||||
|
|
||||||
def _coerce_command_token(token: Any) -> Any:
|
def _coerce_command_token(token: Any) -> Any:
|
||||||
@@ -179,6 +232,12 @@ class Fail2BanClient:
|
|||||||
async def send(self, command: list[Any]) -> Any:
|
async def send(self, command: list[Any]) -> Any:
|
||||||
"""Send a command to fail2ban and return the response.
|
"""Send a command to fail2ban and return the response.
|
||||||
|
|
||||||
|
Acquires the module-level concurrency semaphore before dispatching
|
||||||
|
so that no more than :data:`_COMMAND_SEMAPHORE_CONCURRENCY` commands
|
||||||
|
are in-flight at the same time. Commands that exceed the cap are
|
||||||
|
queued until a slot becomes available. A debug-level log event is
|
||||||
|
emitted when a command must wait.
|
||||||
|
|
||||||
The command is serialised as a pickle list, sent to the socket, and
|
The command is serialised as a pickle list, sent to the socket, and
|
||||||
the response is deserialised before being returned.
|
the response is deserialised before being returned.
|
||||||
|
|
||||||
@@ -193,32 +252,44 @@ class Fail2BanClient:
|
|||||||
connection is unexpectedly closed.
|
connection is unexpectedly closed.
|
||||||
Fail2BanProtocolError: If the response cannot be decoded.
|
Fail2BanProtocolError: If the response cannot be decoded.
|
||||||
"""
|
"""
|
||||||
log.debug("fail2ban_sending_command", command=command)
|
global _command_semaphore
|
||||||
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
if _command_semaphore is None:
|
||||||
try:
|
_command_semaphore = asyncio.Semaphore(_COMMAND_SEMAPHORE_CONCURRENCY)
|
||||||
response: Any = await loop.run_in_executor(
|
|
||||||
None,
|
if _command_semaphore.locked():
|
||||||
_send_command_sync,
|
log.debug(
|
||||||
self.socket_path,
|
"fail2ban_command_waiting_semaphore",
|
||||||
command,
|
|
||||||
self.timeout,
|
|
||||||
)
|
|
||||||
except Fail2BanConnectionError:
|
|
||||||
log.warning(
|
|
||||||
"fail2ban_connection_error",
|
|
||||||
socket_path=self.socket_path,
|
|
||||||
command=command,
|
command=command,
|
||||||
|
concurrency_limit=_COMMAND_SEMAPHORE_CONCURRENCY,
|
||||||
)
|
)
|
||||||
raise
|
|
||||||
except Fail2BanProtocolError:
|
async with _command_semaphore:
|
||||||
log.error(
|
log.debug("fail2ban_sending_command", command=command)
|
||||||
"fail2ban_protocol_error",
|
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||||
socket_path=self.socket_path,
|
try:
|
||||||
command=command,
|
response: Any = await loop.run_in_executor(
|
||||||
)
|
None,
|
||||||
raise
|
_send_command_sync,
|
||||||
log.debug("fail2ban_received_response", command=command)
|
self.socket_path,
|
||||||
return response
|
command,
|
||||||
|
self.timeout,
|
||||||
|
)
|
||||||
|
except Fail2BanConnectionError:
|
||||||
|
log.warning(
|
||||||
|
"fail2ban_connection_error",
|
||||||
|
socket_path=self.socket_path,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Fail2BanProtocolError:
|
||||||
|
log.error(
|
||||||
|
"fail2ban_protocol_error",
|
||||||
|
socket_path=self.socket_path,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
log.debug("fail2ban_received_response", command=command)
|
||||||
|
return response
|
||||||
|
|
||||||
async def ping(self) -> bool:
|
async def ping(self) -> bool:
|
||||||
"""Return ``True`` if the fail2ban daemon is reachable.
|
"""Return ``True`` if the fail2ban daemon is reachable.
|
||||||
|
|||||||
@@ -1005,3 +1005,38 @@ class TestBansByJail:
|
|||||||
assert result.total == 3
|
assert result.total == 3
|
||||||
assert len(result.jails) == 3
|
assert len(result.jails) == 3
|
||||||
|
|
||||||
|
async def test_diagnostic_warning_when_zero_results_despite_data(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A warning is logged when the time-range filter excludes all existing rows."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
# Insert rows with timeofban far in the past (outside any range window).
|
||||||
|
far_past = int(_time.time()) - 400 * 24 * 3600 # ~400 days ago
|
||||||
|
path = str(tmp_path / "test_diag.sqlite3")
|
||||||
|
await _create_f2b_db(
|
||||||
|
path,
|
||||||
|
[
|
||||||
|
{"jail": "sshd", "ip": "1.1.1.1", "timeofban": far_past},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.ban_service._get_fail2ban_db_path",
|
||||||
|
new=AsyncMock(return_value=path),
|
||||||
|
),
|
||||||
|
patch("app.services.ban_service.log") as mock_log,
|
||||||
|
):
|
||||||
|
result = await ban_service.bans_by_jail("/fake/sock", "24h")
|
||||||
|
|
||||||
|
assert result.total == 0
|
||||||
|
assert result.jails == []
|
||||||
|
# The diagnostic warning must have been emitted.
|
||||||
|
warning_calls = [
|
||||||
|
c
|
||||||
|
for c in mock_log.warning.call_args_list
|
||||||
|
if c[0][0] == "ban_service_bans_by_jail_empty_despite_data"
|
||||||
|
]
|
||||||
|
assert len(warning_calls) == 1
|
||||||
|
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ class TestActivateJail:
|
|||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"app.services.config_file_service._get_active_jail_names",
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
new=AsyncMock(return_value=set()),
|
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||||
),
|
),
|
||||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
):
|
):
|
||||||
@@ -2491,3 +2491,112 @@ class TestRemoveActionFromJail:
|
|||||||
|
|
||||||
mock_reload.assert_awaited_once()
|
mock_reload.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# activate_jail — reload_all keyword argument assertions (Stage 5.1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestActivateJailReloadArgs:
|
||||||
|
"""Verify activate_jail calls reload_all with include_jails=[name]."""
|
||||||
|
|
||||||
|
async def test_activate_passes_include_jails(self, tmp_path: Path) -> None:
|
||||||
|
"""activate_jail must pass include_jails=[name] to reload_all."""
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
from app.models.config import ActivateJailRequest
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||||
|
),
|
||||||
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock()
|
||||||
|
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
|
||||||
|
|
||||||
|
mock_js.reload_all.assert_awaited_once_with(
|
||||||
|
"/fake.sock", include_jails=["apache-auth"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_activate_returns_active_true_when_jail_starts(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""activate_jail returns active=True when the jail appears in post-reload names."""
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
from app.models.config import ActivateJailRequest
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||||
|
),
|
||||||
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock()
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is True
|
||||||
|
assert "activated" in result.message.lower()
|
||||||
|
|
||||||
|
async def test_activate_returns_active_false_when_jail_does_not_start(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""activate_jail returns active=False when the jail is absent after reload.
|
||||||
|
|
||||||
|
This covers the Stage 3.1 requirement: if the jail config is invalid
|
||||||
|
(bad regex, missing log file, etc.) fail2ban may silently refuse to
|
||||||
|
start the jail even though the reload command succeeded.
|
||||||
|
"""
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
from app.models.config import ActivateJailRequest
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
# Pre-reload: jail not running. Post-reload: still not running (boot failed).
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(side_effect=[set(), set()]),
|
||||||
|
),
|
||||||
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock()
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is False
|
||||||
|
assert "apache-auth" in result.name
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deactivate_jail — reload_all keyword argument assertions (Stage 5.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestDeactivateJailReloadArgs:
|
||||||
|
"""Verify deactivate_jail calls reload_all with exclude_jails=[name]."""
|
||||||
|
|
||||||
|
async def test_deactivate_passes_exclude_jails(self, tmp_path: Path) -> None:
|
||||||
|
"""deactivate_jail must pass exclude_jails=[name] to reload_all."""
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value={"sshd"}),
|
||||||
|
),
|
||||||
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock()
|
||||||
|
await deactivate_jail(str(tmp_path), "/fake.sock", "sshd")
|
||||||
|
|
||||||
|
mock_js.reload_all.assert_awaited_once_with(
|
||||||
|
"/fake.sock", exclude_jails=["sshd"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for app.utils.fail2ban_client."""
|
"""Tests for app.utils.fail2ban_client."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -287,6 +288,21 @@ class TestFail2BanClientSend:
|
|||||||
with pytest.raises(Fail2BanProtocolError):
|
with pytest.raises(Fail2BanProtocolError):
|
||||||
await client.send(["status"])
|
await client.send(["status"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_raises_on_protocol_error(self) -> None:
|
||||||
|
"""``send()`` must propagate :class:`Fail2BanProtocolError` to the caller."""
|
||||||
|
client = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
||||||
|
|
||||||
|
with patch("asyncio.get_event_loop") as mock_get_loop:
|
||||||
|
mock_loop = AsyncMock()
|
||||||
|
mock_loop.run_in_executor = AsyncMock(
|
||||||
|
side_effect=Fail2BanProtocolError("bad pickle")
|
||||||
|
)
|
||||||
|
mock_get_loop.return_value = mock_loop
|
||||||
|
|
||||||
|
with pytest.raises(Fail2BanProtocolError):
|
||||||
|
await client.send(["status"])
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_logs_error_on_protocol_error(self) -> None:
|
async def test_send_logs_error_on_protocol_error(self) -> None:
|
||||||
"""``send()`` must log an error when a protocol error occurs."""
|
"""``send()`` must log an error when a protocol error occurs."""
|
||||||
@@ -307,3 +323,202 @@ class TestFail2BanClientSend:
|
|||||||
if c[0][0] == "fail2ban_protocol_error"
|
if c[0][0] == "fail2ban_protocol_error"
|
||||||
]
|
]
|
||||||
assert len(error_calls) == 1
|
assert len(error_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for _send_command_sync retry logic (Stage 6.1 / 6.3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendCommandSyncRetry:
|
||||||
|
"""Tests for the retry-on-transient-OSError logic in :func:`_send_command_sync`."""
|
||||||
|
|
||||||
|
def _make_sock(self) -> MagicMock:
|
||||||
|
"""Return a mock socket that connects without error."""
|
||||||
|
mock_sock = MagicMock()
|
||||||
|
mock_sock.connect.return_value = None
|
||||||
|
return mock_sock
|
||||||
|
|
||||||
|
def _eagain(self) -> OSError:
|
||||||
|
"""Return an ``OSError`` with ``errno.EAGAIN``."""
|
||||||
|
import errno as _errno
|
||||||
|
|
||||||
|
err = OSError("Resource temporarily unavailable")
|
||||||
|
err.errno = _errno.EAGAIN
|
||||||
|
return err
|
||||||
|
|
||||||
|
def _enoent(self) -> OSError:
|
||||||
|
"""Return an ``OSError`` with ``errno.ENOENT``."""
|
||||||
|
import errno as _errno
|
||||||
|
|
||||||
|
err = OSError("No such file or directory")
|
||||||
|
err.errno = _errno.ENOENT
|
||||||
|
return err
|
||||||
|
|
||||||
|
def test_transient_eagain_retried_succeeds_on_second_attempt(self) -> None:
|
||||||
|
"""A single EAGAIN on connect is retried; success on the second attempt."""
|
||||||
|
from app.utils.fail2ban_client import _PROTO_END
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def _connect_side_effect(sock_path: str) -> None:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
raise self._eagain()
|
||||||
|
# Second attempt succeeds (no-op).
|
||||||
|
|
||||||
|
mock_sock = self._make_sock()
|
||||||
|
mock_sock.connect.side_effect = _connect_side_effect
|
||||||
|
mock_sock.recv.return_value = _PROTO_END
|
||||||
|
expected = [0, "pong"]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("socket.socket", return_value=mock_sock),
|
||||||
|
patch("app.utils.fail2ban_client.loads", return_value=expected),
|
||||||
|
patch("app.utils.fail2ban_client.time.sleep"), # suppress backoff delay
|
||||||
|
):
|
||||||
|
result = _send_command_sync("/fake.sock", ["ping"], 1.0)
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
assert call_count == 2
|
||||||
|
|
||||||
|
def test_three_eagain_failures_raise_connection_error(self) -> None:
|
||||||
|
"""Three consecutive EAGAIN failures must raise :class:`Fail2BanConnectionError`."""
|
||||||
|
mock_sock = self._make_sock()
|
||||||
|
mock_sock.connect.side_effect = self._eagain()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("socket.socket", return_value=mock_sock),
|
||||||
|
patch("app.utils.fail2ban_client.time.sleep"),
|
||||||
|
pytest.raises(Fail2BanConnectionError),
|
||||||
|
):
|
||||||
|
_send_command_sync("/fake.sock", ["status"], 1.0)
|
||||||
|
|
||||||
|
# connect() should have been called exactly _RETRY_MAX_ATTEMPTS times.
|
||||||
|
from app.utils.fail2ban_client import _RETRY_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
assert mock_sock.connect.call_count == _RETRY_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
def test_enoent_raises_immediately_without_retry(self) -> None:
|
||||||
|
"""A non-retryable ``OSError`` (``ENOENT``) must be raised on the first attempt."""
|
||||||
|
mock_sock = self._make_sock()
|
||||||
|
mock_sock.connect.side_effect = self._enoent()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("socket.socket", return_value=mock_sock),
|
||||||
|
patch("app.utils.fail2ban_client.time.sleep") as mock_sleep,
|
||||||
|
pytest.raises(Fail2BanConnectionError),
|
||||||
|
):
|
||||||
|
_send_command_sync("/fake.sock", ["status"], 1.0)
|
||||||
|
|
||||||
|
# No back-off sleep should have been triggered.
|
||||||
|
mock_sleep.assert_not_called()
|
||||||
|
assert mock_sock.connect.call_count == 1
|
||||||
|
|
||||||
|
def test_protocol_error_never_retried(self) -> None:
|
||||||
|
"""A :class:`Fail2BanProtocolError` must be re-raised immediately."""
|
||||||
|
from app.utils.fail2ban_client import _PROTO_END
|
||||||
|
|
||||||
|
mock_sock = self._make_sock()
|
||||||
|
mock_sock.recv.return_value = _PROTO_END
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("socket.socket", return_value=mock_sock),
|
||||||
|
patch(
|
||||||
|
"app.utils.fail2ban_client.loads",
|
||||||
|
side_effect=Exception("bad pickle"),
|
||||||
|
),
|
||||||
|
patch("app.utils.fail2ban_client.time.sleep") as mock_sleep,
|
||||||
|
pytest.raises(Fail2BanProtocolError),
|
||||||
|
):
|
||||||
|
_send_command_sync("/fake.sock", ["status"], 1.0)
|
||||||
|
|
||||||
|
mock_sleep.assert_not_called()
|
||||||
|
|
||||||
|
def test_retry_emits_structured_log_event(self) -> None:
|
||||||
|
"""Each retry attempt logs a ``fail2ban_socket_retry`` warning."""
|
||||||
|
mock_sock = self._make_sock()
|
||||||
|
mock_sock.connect.side_effect = self._eagain()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("socket.socket", return_value=mock_sock),
|
||||||
|
patch("app.utils.fail2ban_client.time.sleep"),
|
||||||
|
patch("app.utils.fail2ban_client.log") as mock_log,
|
||||||
|
pytest.raises(Fail2BanConnectionError),
|
||||||
|
):
|
||||||
|
_send_command_sync("/fake.sock", ["status"], 1.0)
|
||||||
|
|
||||||
|
retry_calls = [
|
||||||
|
c for c in mock_log.warning.call_args_list
|
||||||
|
if c[0][0] == "fail2ban_socket_retry"
|
||||||
|
]
|
||||||
|
from app.utils.fail2ban_client import _RETRY_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
# One retry log per attempt except the last (which raises directly).
|
||||||
|
assert len(retry_calls) == _RETRY_MAX_ATTEMPTS - 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for Fail2BanClient semaphore (Stage 6.2 / 6.3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFail2BanClientSemaphore:
|
||||||
|
"""Tests for the concurrency semaphore in :meth:`Fail2BanClient.send`."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_semaphore_limits_concurrency(self) -> None:
|
||||||
|
"""No more than _COMMAND_SEMAPHORE_CONCURRENCY commands overlap."""
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
import app.utils.fail2ban_client as _module
|
||||||
|
|
||||||
|
# Reset module-level semaphore so this test starts fresh.
|
||||||
|
_module._command_semaphore = None
|
||||||
|
|
||||||
|
concurrency_limit = 3
|
||||||
|
_module._COMMAND_SEMAPHORE_CONCURRENCY = concurrency_limit
|
||||||
|
_module._command_semaphore = _asyncio.Semaphore(concurrency_limit)
|
||||||
|
|
||||||
|
in_flight: list[int] = []
|
||||||
|
peak_concurrent: list[int] = []
|
||||||
|
|
||||||
|
async def _slow_send(command: list[Any]) -> Any:
|
||||||
|
in_flight.append(1)
|
||||||
|
peak_concurrent.append(len(in_flight))
|
||||||
|
await _asyncio.sleep(0) # yield to allow other coroutines to run
|
||||||
|
in_flight.pop()
|
||||||
|
return (0, "ok")
|
||||||
|
|
||||||
|
client = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
||||||
|
with patch.object(client, "send", wraps=_slow_send) as _patched:
|
||||||
|
# Bypass the semaphore wrapper — test the actual send directly.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Override _command_semaphore and run concurrently via the real send path
|
||||||
|
# but mock _send_command_sync to avoid actual socket I/O.
|
||||||
|
async def _fast_executor(_fn: Any, *_args: Any) -> Any:
|
||||||
|
in_flight.append(1)
|
||||||
|
peak_concurrent.append(len(in_flight))
|
||||||
|
await _asyncio.sleep(0)
|
||||||
|
in_flight.pop()
|
||||||
|
return (0, "ok")
|
||||||
|
|
||||||
|
client2 = Fail2BanClient(socket_path="/fake/fail2ban.sock")
|
||||||
|
with patch("asyncio.get_event_loop") as mock_loop_getter:
|
||||||
|
mock_loop = MagicMock()
|
||||||
|
mock_loop.run_in_executor = _fast_executor
|
||||||
|
mock_loop_getter.return_value = mock_loop
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
_asyncio.create_task(client2.send(["ping"])) for _ in range(10)
|
||||||
|
]
|
||||||
|
await _asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
# Peak concurrent activity must never exceed the semaphore limit.
|
||||||
|
assert max(peak_concurrent) <= concurrency_limit
|
||||||
|
|
||||||
|
# Restore module defaults after test.
|
||||||
|
_module._COMMAND_SEMAPHORE_CONCURRENCY = 10
|
||||||
|
_module._command_semaphore = None
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ class TestJailControls:
|
|||||||
with _patch_client(
|
with _patch_client(
|
||||||
{
|
{
|
||||||
"status": _make_global_status("sshd, nginx"),
|
"status": _make_global_status("sshd, nginx"),
|
||||||
"reload|--all|[]|[['start', 'sshd'], ['start', 'nginx']]": (0, "OK"),
|
"reload|--all|[]|[['start', 'nginx'], ['start', 'sshd']]": (0, "OK"),
|
||||||
}
|
}
|
||||||
):
|
):
|
||||||
await jail_service.reload_all(_SOCKET) # should not raise
|
await jail_service.reload_all(_SOCKET) # should not raise
|
||||||
@@ -307,6 +307,38 @@ class TestJailControls:
|
|||||||
):
|
):
|
||||||
await jail_service.reload_all(_SOCKET) # should not raise
|
await jail_service.reload_all(_SOCKET) # should not raise
|
||||||
|
|
||||||
|
async def test_reload_all_include_jails(self) -> None:
|
||||||
|
"""reload_all with include_jails adds the new jail to the stream."""
|
||||||
|
with _patch_client(
|
||||||
|
{
|
||||||
|
"status": _make_global_status("sshd, nginx"),
|
||||||
|
"reload|--all|[]|[['start', 'apache-auth'], ['start', 'nginx'], ['start', 'sshd']]": (0, "OK"),
|
||||||
|
}
|
||||||
|
):
|
||||||
|
await jail_service.reload_all(_SOCKET, include_jails=["apache-auth"])
|
||||||
|
|
||||||
|
async def test_reload_all_exclude_jails(self) -> None:
|
||||||
|
"""reload_all with exclude_jails removes the jail from the stream."""
|
||||||
|
with _patch_client(
|
||||||
|
{
|
||||||
|
"status": _make_global_status("sshd, nginx"),
|
||||||
|
"reload|--all|[]|[['start', 'nginx']]": (0, "OK"),
|
||||||
|
}
|
||||||
|
):
|
||||||
|
await jail_service.reload_all(_SOCKET, exclude_jails=["sshd"])
|
||||||
|
|
||||||
|
async def test_reload_all_include_and_exclude(self) -> None:
|
||||||
|
"""reload_all with both include and exclude applies both correctly."""
|
||||||
|
with _patch_client(
|
||||||
|
{
|
||||||
|
"status": _make_global_status("old, nginx"),
|
||||||
|
"reload|--all|[]|[['start', 'new'], ['start', 'nginx']]": (0, "OK"),
|
||||||
|
}
|
||||||
|
):
|
||||||
|
await jail_service.reload_all(
|
||||||
|
_SOCKET, include_jails=["new"], exclude_jails=["old"]
|
||||||
|
)
|
||||||
|
|
||||||
async def test_start_not_found_raises(self) -> None:
|
async def test_start_not_found_raises(self) -> None:
|
||||||
"""start_jail raises JailNotFoundError for unknown jail."""
|
"""start_jail raises JailNotFoundError for unknown jail."""
|
||||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
||||||
|
|||||||
Reference in New Issue
Block a user