Compare commits

...

10 Commits

Author SHA1 Message Date
6bb38dbd8c Add ignore-self toggle to Jail Detail page
Implements the missing UI control for POST /api/jails/{name}/ignoreself:
- Add jailIgnoreSelf endpoint constant to endpoints.ts
- Add toggleIgnoreSelf(name, on) API function to jails.ts
- Expose toggleIgnoreSelf action from useJailDetail hook
- Replace read-only 'ignore self' badge with a Fluent Switch in
  IgnoreListSection to allow enabling/disabling the flag per jail
- Add 5 vitest tests for checked/unchecked state and toggle behaviour
2026-03-14 20:24:49 +01:00
d3b2022ffb Mark Task 7 as done in Tasks.md 2026-03-14 19:51:12 +01:00
4b6e118a88 Fix ActivateJailDialog blocking logic and mypy false positive
Two frontend bugs and one mypy false positive fixed:

- ActivateJailDialog: Activate button was never disabled when
  blockingIssues.length > 0 (missing condition in disabled prop).
- ActivateJailDialog: handleConfirm called onActivated() even when
  the backend returned active=false (blocked activation). Dialog now
  stays open and shows result.message instead.
- config.py: Settings() call flagged by mypy --strict because
  pydantic-settings loads required fields from env vars at runtime;
  suppressed with a targeted type: ignore[call-arg] comment.

Tests: added ActivateJailDialog.test.tsx (5 tests covering button state,
backend-rejection handling, success path, and crash detection callback).
2026-03-14 19:50:55 +01:00
936946010f Run immediate health probe after jail deactivation
After deactivation the endpoint now calls _run_probe to flush the
cached server status immediately, matching the activate_jail behaviour
added in Task 5. Without this, the dashboard active-jail count could
remain stale for up to 30 s after a deactivation reload.

- config.py: capture result, await _run_probe, return result
- test_config.py: add test_deactivate_triggers_health_probe; fix 3
  pre-existing UP017 ruff warnings (datetime.UTC alias)
- test_health.py: update test to assert the new fail2ban field
2026-03-14 19:25:24 +01:00
ee7412442a Complete tasks 1-5: UI cleanup, pie chart fix, log path allowlist, activation hardening
Task 1: Remove ActiveBansSection from JailsPage
- Delete buildBanColumns, fmtTimestamp, ActiveBansSection
- Remove Dialog/Delete/Dismiss imports, ActiveBan type
- Update JSDoc to reflect three sections

Task 2: Remove JailDistributionChart from Dashboard
- Delete import and JSX block from DashboardPage.tsx

Task 3: Fix transparent pie chart (TopCountriesPieChart)
- Add Cell import and per-slice <Cell fill={slice.fill}> children inside <Pie>
- Suppress @typescript-eslint/no-deprecated (recharts v3 types)

Task 4: Allow /config/log as safe log prefix
- Add '/config/log' to _SAFE_LOG_PREFIXES in config_service.py
- Update error message to list both allowed directories

Task 5: Block jail activation on missing filter/logpath
- activate_jail refuses to proceed when filter/logpath issues found
- ActivateJailDialog treats all validation issues as blocking
- Trigger immediate _run_probe after activation in config router
- /api/health now reports fail2ban online/offline from cached probe
- Add TestActivateJailBlocking tests; fix existing tests to mock validation
2026-03-14 18:57:01 +01:00
68d8056d2e fix: resolve ESLint no-confusing-void-expression in LogTab tests 2026-03-14 17:58:35 +01:00
528d0bd8ea fix: make all tests pass
backend/tests/test_routers/test_file_config.py:
  - TestListActionFiles.test_200_returns_files: GET /api/config/actions is
    handled by config.router (registered before file_config.router), so mock
    config_file_service.list_actions and assert on ActionListResponse.actions
  - TestCreateActionFile.test_201_creates_file: same route conflict; mock
    config_file_service.create_action and use ActionCreateRequest body format

frontend/src/components/__tests__/ConfigPageLogPath.test.tsx:
  - Log paths are rendered as <Input value={path}>, not text nodes; replace
    getByText() with getByDisplayValue() for both test assertions
2026-03-14 17:41:06 +01:00
baf45c6c62 feat: Task 4 — paginated banned-IPs section on jail detail page
Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
  IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
  query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)

Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
  (300 ms), prev/next pagination, page-size dropdown, per-row unban
  button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
2026-03-14 16:28:43 +01:00
0966f347c4 feat: Task 3 — invalid jail config recovery (pre-validation, crash detection, rollback)
- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
  health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
  GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
  add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
  record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
  extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
  result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
  alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
  + 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
  frontend tests; fix mock setup in existing activate_jail tests
2026-03-14 14:13:07 +01:00
ab11ece001 Add fail2ban log viewer and service health to Config page
Task 2: adds a new Log tab to the Configuration page.

Backend:
- New Pydantic models: Fail2BanLogResponse, ServiceStatusResponse
  (backend/app/models/config.py)
- New service methods in config_service.py:
    read_fail2ban_log() — queries socket for log target/level, validates the
    resolved path against a safe-prefix allowlist (/var/log) to prevent
    path traversal, then reads the tail of the file via the existing
    _read_tail_lines() helper; optional substring filter applied server-side.
    get_service_status() — delegates to health_service.probe() and appends
    log level/target from the socket.
- New endpoints in routers/config.py:
    GET /api/config/fail2ban-log?lines=200&filter=...
    GET /api/config/service-status
  Both require authentication; log endpoint returns 400 for non-file log
  targets or path-traversal attempts, 502 when fail2ban is unreachable.

Frontend:
- New LogTab.tsx component:
    Service Health panel (Running/Offline badge, version, jail count, bans,
    failures, log level/target, offline warning banner).
    Log viewer with color-coded lines (error=red, warning=yellow,
    debug=grey), toolbar (filter input + debounce, lines selector, manual
    refresh, auto-refresh with interval selector), truncation notice, and
    auto-scroll to bottom on data updates.
  fetchData uses Promise.allSettled so a log-read failure never hides the
  service-health panel.
- Types: Fail2BanLogResponse, ServiceStatusResponse (types/config.ts)
- API functions: fetchFail2BanLog, fetchServiceStatus (api/config.ts)
- Endpoint constants (api/endpoints.ts)
- ConfigPage.tsx: Log tab added after existing tabs

Tests:
- Backend service tests: TestReadFail2BanLog (6), TestGetServiceStatus (2)
- Backend router tests: TestGetFail2BanLog (8), TestGetServiceStatus (3)
- Frontend: LogTab.test.tsx (8 tests)

Docs:
- Features.md: Log section added under Configuration View
- Architekture.md: config.py router and config_service.py descriptions updated
- Tasks.md: Task 2 marked done
2026-03-14 12:54:03 +01:00
45 changed files with 5890 additions and 576 deletions

View File

@@ -152,7 +152,7 @@ The HTTP interface layer. Each router maps URL paths to handler functions. Route
| `dashboard.py` | `/api/dashboard` | Server status bar data, recent bans for the dashboard |
| `jails.py` | `/api/jails` | List jails, jail detail, start/stop/reload/idle controls |
| `bans.py` | `/api/bans` | Ban an IP, unban an IP, unban all, list currently banned IPs |
| `config.py` | `/api/config` | Read and write fail2ban jail/filter/server configuration via the socket |
| `config.py` | `/api/config` | Read and write fail2ban jail/filter/server configuration via the socket; also serves the fail2ban log tail and service status for the Log tab |
| `file_config.py` | `/api/config` | Read and write fail2ban config files on disk (jail.d/, filter.d/, action.d/) — list, get, and overwrite raw file contents, toggle jail enabled/disabled |
| `history.py` | `/api/history` | Query historical bans, per-IP timeline |
| `blocklist.py` | `/api/blocklists` | CRUD blocklist sources, trigger import, view import logs |
@@ -169,7 +169,7 @@ The business logic layer. Services orchestrate operations, enforce rules, and co
| `setup_service.py` | Validates setup input, persists initial configuration, ensures setup runs only once |
| `jail_service.py` | Retrieves jail list and details from fail2ban, aggregates metrics (banned count, failure count), sends start/stop/reload/idle commands |
| `ban_service.py` | Executes ban and unban commands via the fail2ban socket, queries the currently banned IP list, validates IPs before banning |
| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload |
| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload; reads the fail2ban log file tail and queries service status for the Log tab |
| `file_config_service.py` | Reads and writes raw fail2ban config files on disk (jail.d/, filter.d/, action.d/); lists files, reads content, overwrites files, toggles enabled/disabled |
| `config_file_service.py` | Parses jail.conf / jail.local / jail.d/* to discover inactive jails; writes .local overrides to activate or deactivate jails; triggers fail2ban reload |
| `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text |

View File

@@ -220,6 +220,27 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- Countries with zero bans remain transparent (no fill).
- Changes take effect immediately on the World Map view without requiring a page reload.
### Log
- A dedicated **Log** tab on the Configuration page shows fail2ban service health and a live log viewer in one place.
- **Service Health panel** (always visible):
- Online/offline **badge** (Running / Offline).
- When online: version, active jail count, currently banned IPs, and currently failed attempts as stat cards.
- Log level and log target displayed as meta labels.
- Warning banner when fail2ban is offline, prompting the user to check the server and socket configuration.
- **Log Viewer** (shown when fail2ban logs to a file):
- Displays the tail of the fail2ban log file in a scrollable monospace container.
- Log lines are **color-coded by severity**: errors and critical messages in red, warnings in yellow, debug lines in grey, and informational lines in the default color.
- Toolbar controls:
- **Filter** — substring input with 300 ms debounce; only lines containing the filter text are shown.
- **Lines** — selector for how many tail lines to fetch (100 / 200 / 500 / 1000).
- **Refresh** button for an on-demand reload.
- **Auto-refresh** toggle with interval selector (5 s / 10 s / 30 s) for live monitoring.
- Truncation notice when the total log file line count exceeds the requested tail limit.
- Container automatically scrolls to the bottom after each data update.
- When fail2ban is configured to log to a non-file target (STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL), an informational banner explains that file-based log viewing is unavailable.
- The log file path is validated against a safe prefix allowlist on the backend to prevent path-traversal reads.
---
## 7. Ban History

View File

@@ -4,254 +4,599 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Task 1 — Jail Page: Show Only Active Jails (No Inactive Configs)
## Task 1 — Remove "Currently Banned IPs" section from the Jails page
**Status:** done
**Summary:** Backend `GET /api/jails` already only returned active jails (queries fail2ban socket `status` command). Frontend `JailsPage.tsx` updated: removed the "Inactive Jails" section, the "Show inactive" toggle, the `fetchInactiveJails()` call, the `ActivateJailDialog` import/usage, and the `InactiveJail` type import. The Config page (`JailsTab.tsx`) retains full inactive-jail management. All backend tests pass (96/96). TypeScript and ESLint report zero errors. (`JailsPage.tsx`) currently displays inactive jail configurations alongside active jails. Inactive jails — those defined in config files but not running — belong on the **Configuration** page (`ConfigPage.tsx`, Jails tab), not on the operational Jail management page. The Jail page should be a pure operational view: only jails that fail2ban reports as active/running appear here.
**Summary:** Removed `ActiveBansSection` component, `buildBanColumns` helper, `fmtTimestamp` helper, `ActiveBan` type import, Dialog/DeleteRegular/DismissRegular imports from `JailsPage.tsx`. Updated file-level and component-level JSDoc to say "three sections". `useActiveBans` kept for `banIp`/`unbanIp` used by `BanUnbanForm`.
### Goal
**Page:** `/jails` — rendered by `frontend/src/pages/JailsPage.tsx`
Remove all inactive-jail display and activation UI from the Jail management page. The Jail page shows only jails that are currently loaded in the running fail2ban instance. Users who want to discover and activate inactive jails do so exclusively through the Configuration page's Jails tab.
The Jails page currently has four sections: Jail Overview, Ban / Unban IP, **Currently Banned IPs**, and IP Lookup. Remove the "Currently Banned IPs" section entirely.
### Backend Changes
### What to do
1. **Review `GET /api/jails`** in `backend/app/routers/jails.py` and `jail_service.py`. Confirm this endpoint only returns jails that are reported as active by fail2ban via the socket (`status` command). If it already does, no change needed. If it includes inactive/config-only jails in its response, strip them out.
2. **No new endpoints needed.** The inactive-jail listing and activation endpoints already live under `/api/config/jails` and `/api/config/jails/{name}/activate` in `config.py` / `config_file_service.py` — those stay as-is for the Config page.
1. In `frontend/src/pages/JailsPage.tsx`, find the `ActiveBansSection` sub-component (the function that renders the "Currently Banned IPs" heading, the DataGrid of active bans, the refresh/clear-all buttons, and the confirmation dialog). Delete the entire `ActiveBansSection` function.
2. In the `JailsPage` component at the bottom of the same file, remove the `<ActiveBansSection />` JSX element from the returned markup.
3. Remove any imports, hooks, types, or helper functions that were **only** used by `ActiveBansSection` and are now unused (e.g. `useActiveBans` if it is no longer referenced elsewhere, the `buildBanColumns` helper, the `ActiveBan` type import, etc.). Check the remaining code — `useActiveBans` is also destructured for `banIp`/`unbanIp` in `JailsPage`, so keep it if still needed there.
4. Update the file-level JSDoc comment at the top of the file: change the list from four sections to three and remove the "Currently Banned IPs" bullet.
5. Update the JSDoc on the `JailsPage` component to say "three sections" instead of "four sections".
### Frontend Changes
### Verification
3. **`JailsPage.tsx`** — Remove the "Inactive Jails" section, the toggle that reveals inactive jails, and the `fetchInactiveJails()` call. The page should only call `fetchJails()` (which queries `/api/jails`) and render that list. Remove the `ActivateJailDialog` import and usage from this page if present.
4. **`JailsPage.tsx`** — Remove any "Activate" buttons or affordances that reference inactive jails. The jail overview table should show: jail name, status (running / stopped / idle), backend type, currently banned count, total bans, currently failed, total failed, find time, ban time, max retries. No "Inactive" badge or "Activate" button.
5. **Verify the Config page** (`ConfigPage.tsx` → Jails tab / `JailsTab.tsx`) still shows the full list including inactive jails with Active/Inactive badges and the Activate button. This is the only place where inactive jails are managed. No changes expected here — just verify nothing broke.
### Tests
6. **Backend:** If there are existing tests for `GET /api/jails` that assert inactive jails are included, update them so they assert inactive jails are excluded.
7. **Frontend:** Update or remove any component tests for the inactive-jail section on `JailsPage`. Ensure Config-page tests for inactive jail activation still pass.
### Acceptance Criteria
- The Jail page shows zero inactive jails under any circumstance.
- All Jail page data comes only from the fail2ban socket's active jail list.
- Inactive-jail discovery and activation remain fully functional on the Configuration page, Jails tab.
- No regressions in existing jail control actions (start, stop, reload, idle, ignore-list) on the Jail page.
- `npx tsc --noEmit` passes with no errors.
- `npx eslint src/pages/JailsPage.tsx` reports no warnings.
- The `/jails` page renders without the "Currently Banned IPs" section; the remaining three sections (Jail Overview, Ban/Unban IP, IP Lookup) still work.
---
## Task 2 — Configuration Subpage: fail2ban Log Viewer & Service Health
## Task 2 — Remove "Jail Distribution" section from the Dashboard
**Status:** not started
**References:** [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
**Status:** done
### Problem
**Summary:** Removed `JailDistributionChart` import and JSX block from `DashboardPage.tsx`. The component file is retained (still importable) but no longer rendered.
There is currently no way to view the fail2ban daemon log (`/var/log/fail2ban.log` or wherever the log target is configured) through the web interface. There is also no dedicated place in the Configuration section that shows at a glance whether fail2ban is running correctly. The existing health probe (`health_service.py`) and dashboard status bar give connectivity info, but the Configuration page should have its own panel showing service health alongside the raw log output.
**Page:** `/` (Dashboard) — rendered by `frontend/src/pages/DashboardPage.tsx`
### Goal
The Dashboard currently shows: Server Status Bar, Filter Bar, Ban Trend, Top Countries, **Jail Distribution**, and Ban List. Remove the "Jail Distribution" section.
Add a new **Log** tab to the Configuration page. This tab shows two things:
1. A **Service Health panel** — a compact summary showing whether fail2ban is running, its version, active jail count, total bans, total failures, and the current log level/target. This reuses data from the existing health probe.
2. A **Log viewer** — displays the tail of the fail2ban daemon log file with newest entries at the bottom. Supports manual refresh and optional auto-refresh on an interval.
### What to do
### Backend Changes
1. In `frontend/src/pages/DashboardPage.tsx`, delete the entire `{/* Jail Distribution section */}` block — this is the `<div className={styles.section}>` that wraps the `<JailDistributionChart>` component (approximately lines 164177).
2. Remove the `JailDistributionChart` import at the top of the file (`import { JailDistributionChart } from "../components/JailDistributionChart";`).
3. If the `JailDistributionChart` component file (`frontend/src/components/JailDistributionChart.tsx`) is not imported anywhere else in the codebase, you may optionally delete it, but this is not required.
#### New Endpoint: Read fail2ban Log
### Verification
1. **Create `GET /api/config/fail2ban-log`** in `backend/app/routers/config.py` (or a new router file `backend/app/routers/log.py` if `config.py` is getting large).
- **Query parameters:**
- `lines` (int, default 200, max 2000) — number of lines to return from the tail of the log file.
- `filter` (optional string) — a plain-text substring filter; only return lines containing this string (for searching).
- **Response model:** `Fail2BanLogResponse` with fields:
- `log_path: str` — the resolved path of the log file being read.
- `lines: list[str]` — the log lines.
- `total_lines: int` — total number of lines in the file (so the UI can indicate if it's truncated).
- `log_level: str` — the current fail2ban log level.
- `log_target: str` — the current fail2ban log target.
- **Behaviour:** Query the fail2ban socket for `get logtarget` to find the current log file path. Read the last N lines from that file using an efficient tail implementation (read from end of file, do not load the entire file into memory). If the log target is not a file (stdout, syslog, systemd-journal), return an informative error explaining that log viewing is only available when fail2ban logs to a file.
- **Security:** Validate that the resolved log path is under an expected directory (e.g. `/var/log/`). Do not allow path traversal. Never expose arbitrary file contents.
2. **Create the service method** `read_fail2ban_log()` in `backend/app/services/config_service.py` (or a new `log_service.py`).
- Use `fail2ban_client.py` to query `get logtarget` and `get loglevel`.
- Implement an async file tail: open the file, seek to end, read backwards until N newlines are found OR the beginning of the file is reached.
- Apply the optional substring filter on the server side before returning.
3. **Create Pydantic models** in `backend/app/models/config.py`:
- `Fail2BanLogResponse(log_path: str, lines: list[str], total_lines: int, log_level: str, log_target: str)`
#### Extend Health Data for Config Page
4. **Create `GET /api/config/service-status`** (or reuse/extend `GET /api/dashboard/status` if appropriate).
- Returns: `online` (bool), `version` (str), `jail_count` (int), `total_bans` (int), `total_failures` (int), `log_level` (str), `log_target` (str), `db_path` (str), `uptime` or `start_time` if available.
- This can delegate to the existing `health_service.probe()` and augment with the log-level/target info from the socket.
### Frontend Changes
#### New Tab: Log
5. **Create `frontend/src/components/config/LogTab.tsx`.**
- **Service Health panel** at the top:
- A status badge: green "Running" or red "Offline".
- Version, active jails count, total bans, total failures displayed in a compact row of stat cards.
- Current log level and log target shown as labels.
- If fail2ban is offline, show a prominent warning banner with the text: "fail2ban is not running or unreachable. Check the server and socket configuration."
- **Log viewer** below:
- A monospace-font scrollable container showing the log lines.
- A toolbar above the log area with:
- A **Refresh** button to re-fetch the log.
- An **Auto-refresh** toggle (off by default) with a selectable interval (5s, 10s, 30s).
- A **Lines** dropdown to choose how many lines to load (100, 200, 500, 1000).
- A **Filter** text input to search within the log (sends the filter param to the backend).
- Log lines should be syntax-highlighted or at minimum color-coded by log level (ERROR = red, WARNING = yellow, INFO = default, DEBUG = muted).
- The container auto-scrolls to the bottom on load and on refresh (since newest entries are at the end).
- If the log target is not a file, show an info banner: "fail2ban is logging to [target]. File-based log viewing is not available."
6. **Register the tab** in `ConfigPage.tsx`. Add a "Log" tab after the existing tabs (Jails, Filters, Actions, Global, Server, Map, Regex Tester). Use a log-file icon.
7. **Create API functions** in `frontend/src/api/config.ts`:
- `fetchFail2BanLog(lines?: number, filter?: string): Promise<Fail2BanLogResponse>`
- `fetchServiceStatus(): Promise<ServiceStatusResponse>`
8. **Create TypeScript types** in `frontend/src/types/config.ts` (or wherever config types live):
- `Fail2BanLogResponse { log_path: string; lines: string[]; total_lines: number; log_level: string; log_target: string; }`
- `ServiceStatusResponse { online: boolean; version: string; jail_count: number; total_bans: number; total_failures: number; log_level: string; log_target: string; }`
### Tests
9. **Backend:** Write tests for the new log endpoint — mock the file read, test line-count limiting, test the substring filter, test the error case when log target is not a file, test path-traversal prevention.
10. **Backend:** Write tests for the service-status endpoint.
11. **Frontend:** Write component tests for `LogTab.tsx` — renders health panel, renders log lines, filter input works, handles offline state.
### Acceptance Criteria
- The Configuration page has a new "Log" tab.
- The Log tab shows a clear health summary with running/offline state and key metrics.
- The Log tab displays the tail of the fail2ban daemon log file.
- Users can choose how many lines to display, can refresh manually, and can optionally enable auto-refresh.
- Users can filter log lines by substring.
- Log lines are visually differentiated by severity level.
- If fail2ban logs to a non-file target, a clear message is shown instead of the log viewer.
- The log endpoint does not allow reading arbitrary files — only the actual fail2ban log target.
- `npx tsc --noEmit` passes with no errors.
- `npx eslint src/pages/DashboardPage.tsx` reports no warnings.
- The Dashboard renders without the "Jail Distribution" chart; all other sections remain functional.
---
## Task 3 — Invalid Jail Config Recovery: Detect Broken fail2ban & Auto-Disable Bad Jails
## Task 3 — Fix transparent pie chart in "Top Countries" on the Dashboard
**Status:** not started
**References:** [Features.md § 5 — Jail Management](Features.md), [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
**Status:** done
### Problem
**Summary:** Added `Cell` import from recharts and rendered per-slice `<Cell key={index} fill={slice.fill} />` children inside `<Pie>` in `TopCountriesPieChart.tsx`. Added `// eslint-disable-next-line @typescript-eslint/no-deprecated` since recharts v3 type-marks `Cell` as deprecated but it remains the correct mechanism for per-slice colours.
When a user activates a jail from the Configuration page, the system writes `enabled = true` to a `.local` override file and triggers a fail2ban reload. If the jail's configuration is invalid (bad regex, missing log file, broken filter reference, syntax error in an action), fail2ban may **refuse to start entirely** — not just skip the one bad jail but stop the whole daemon. At that point every jail is down, all monitoring stops, and the user is locked out of all fail2ban operations in BanGUI.
**Page:** `/` (Dashboard) — pie chart rendered by `frontend/src/components/TopCountriesPieChart.tsx`
The current `activate_jail()` flow in `config_file_service.py` does a post-reload check (queries fail2ban for the jail's status and returns `active=false` if it didn't start), but this only works when fail2ban is still running. If the entire daemon crashes after the reload, the socket is gone and BanGUI cannot query anything. The user sees generic "offline" errors but has no clear path to fix the problem.
The pie chart in the "Top Countries" section is transparent (invisible slices). The root cause is that each slice's `fill` colour is set on the data objects but the `<Pie>` component does not apply them. Recharts needs either a `fill` prop on `<Pie>`, per-slice `<Cell>` elements, or the data items' `fill` field to be picked up correctly.
### Goal
### What to do
Build a multi-layered safety net that:
1. **Pre-validates** the jail config before activating it (catch obvious errors before the reload).
2. **Detects** when fail2ban goes down after a jail activation (detect the crash quickly).
3. **Alerts** the user with a clear, actionable message explaining which jail was just activated and that it likely caused the failure.
4. **Offers a one-click rollback** that disables the bad jail config and restarts fail2ban.
1. Open `frontend/src/components/TopCountriesPieChart.tsx`.
2. The `buildSlices` helper already attaches a resolved `fill` colour string to every `SliceData` item. However, the `<Pie>` element does not render individual `<Cell>` elements to apply those colours.
3. Import `Cell` from `recharts`:
```tsx
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
```
4. Inside the `<Pie>` element, add child `<Cell>` elements that map each slice to its colour:
```tsx
<Pie
data={slices}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
label={…}
labelLine={false}
>
{slices.map((slice, index) => (
<Cell key={index} fill={slice.fill} />
))}
</Pie>
```
This ensures each pie slice is painted with the colour from `CHART_PALETTE` that `buildSlices` resolved.
### Plan
### Verification
#### Layer 1: Pre-Activation Validation
1. **Extend `activate_jail()` in `config_file_service.py`** (or add a new `validate_jail_config()` method) to perform dry-run checks before writing the `.local` file and reloading:
- **Filter existence:** Verify the jail's `filter` setting references a filter file that actually exists in `filter.d/`.
- **Action existence:** Verify every action referenced by the jail exists in `action.d/`.
- **Regex compilation:** Attempt to compile all `failregex` and `ignoreregex` patterns with Python's `re` module. Report which pattern is broken.
- **Log path check:** Verify that the log file paths declared in the jail config actually exist on disk and are readable.
- **Syntax check:** Parse the full merged config (base + overrides) and check for obvious syntax issues (malformed interpolation, missing required keys).
2. **Return validation errors as a structured response** before proceeding with activation. The response should list every issue found so the user can fix them before trying again.
3. **Create a new endpoint `POST /api/config/jails/{name}/validate`** that runs only the validation step without actually activating. The frontend can call this for a "Check Config" button.
#### Layer 2: Post-Activation Health Check
4. **After each `activate_jail()` reload**, perform a health-check sequence with retries:
- Wait 2 seconds after sending the reload command.
- Probe the fail2ban socket with `ping`.
- If the probe succeeds, check if the specific jail is active.
- If the probe fails (socket gone / connection refused), retry up to 3 times with 2-second intervals.
- Return the probe result as part of the activation response.
5. **Extend the `JailActivationResponse` model** to include:
- `fail2ban_running: bool` — whether the fail2ban daemon is still running after reload.
- `validation_warnings: list[str]` — any non-fatal warnings from the pre-validation step.
- `error: str | None` — a human-readable error message if something went wrong.
#### Layer 3: Automatic Crash Detection via Background Task
6. **Extend `tasks/health_check.py`** (the periodic health probe that runs every 30 seconds):
- Track the **last known activation event**: when a jail was activated, store its name and timestamp in an in-memory variable (or a lightweight DB record).
- If the health check detects that fail2ban transitioned from `online` to `offline`, and a jail was activated within the last 60 seconds, flag this as a **probable activation failure**.
- Store a `PendingRecovery` record: `{ jail_name: str, activated_at: datetime, detected_at: datetime, recovered: bool }`.
7. **Create a new endpoint `GET /api/config/pending-recovery`** that returns the current `PendingRecovery` record (or `null` if none).
- The frontend polls this endpoint (or it is included in the dashboard status response) to detect when a recovery state is active.
#### Layer 4: User Alert & One-Click Rollback
8. **Frontend — Global alert banner.** When the health status transitions to offline and a `PendingRecovery` record exists:
- Show a **full-width warning banner** at the top of every page (not just the Config page). The banner is dismissible only after the issue is resolved.
- Banner text: "fail2ban stopped after activating jail **{name}**. The jail's configuration may be invalid. Disable this jail and restart fail2ban?"
- Two buttons:
- **"Disable & Restart"** — calls the rollback endpoint (see below).
- **"View Details"** — navigates to the Config page Log tab so the user can inspect the fail2ban log for the exact error message.
9. **Create a rollback endpoint `POST /api/config/jails/{name}/rollback`** in the backend:
- Writes `enabled = false` to the jail's `.local` override (same as `deactivate_jail()` but works even when fail2ban is down since it only writes a file).
- Attempts to start (not reload) the fail2ban daemon via the configured start command (e.g. `systemctl start fail2ban` or `fail2ban-client start`). Make the start command configurable in the app settings.
- Waits up to 10 seconds for the socket to come back, probing every 2 seconds.
- Returns a response indicating whether fail2ban is back online and how many jails are now active.
- Clears the `PendingRecovery` record on success.
10. **Frontend — Rollback result.** After the rollback call returns:
- If successful: show a success toast "fail2ban restarted with {n} active jails. The jail **{name}** has been disabled." and dismiss the banner.
- If fail2ban still doesn't start: show an error dialog explaining that the problem may not be limited to the last activated jail. Suggest the user check the fail2ban log (link to the Log tab) or SSH into the server. Keep the banner visible.
#### Layer 5: Config Page Enhancements
11. **On the Config page Jails tab**, when activating a jail:
- Before activation, show a confirmation dialog that includes any validation warnings from the pre-check.
- During activation, show a spinner with the text "Activating jail and verifying fail2ban…" (acknowledge the post-activation health check takes a few seconds).
- After activation, if `fail2ban_running` is false in the response, immediately show the recovery banner and rollback option without waiting for the background health check.
12. **Add a "Validate" button** next to the "Activate" button on inactive jails. Clicking it calls `POST /api/config/jails/{name}/validate` and shows the validation results in a panel (green for pass, red for each issue found).
### Backend File Map
| File | Changes |
|---|---|
| `services/config_file_service.py` | Add `validate_jail_config()`, extend `activate_jail()` with pre-validation and post-reload health check. |
| `routers/config.py` | Add `POST /api/config/jails/{name}/validate`, `GET /api/config/pending-recovery`, `POST /api/config/jails/{name}/rollback`. |
| `models/config.py` | Add `JailValidationResult`, `PendingRecovery`, extend `JailActivationResponse`. |
| `tasks/health_check.py` | Track last activation event, detect crash-after-activation, write `PendingRecovery` record. |
| `services/health_service.py` | Add helper to attempt daemon start (not just probe). |
### Frontend File Map
| File | Changes |
|---|---|
| `components/config/ActivateJailDialog.tsx` | Add pre-validation call, show warnings, show extended activation feedback. |
| `components/config/JailsTab.tsx` | Add "Validate" button next to "Activate" for inactive jails. |
| `components/common/RecoveryBanner.tsx` (new) | Global warning banner for activation failures with rollback button. |
| `pages/AppLayout.tsx` (or root layout) | Mount the `RecoveryBanner` component so it appears on all pages. |
| `api/config.ts` | Add `validateJailConfig()`, `fetchPendingRecovery()`, `rollbackJail()`. |
| `types/config.ts` | Add `JailValidationResult`, `PendingRecovery`, extend `JailActivationResponse`. |
### Tests
13. **Backend:** Test `validate_jail_config()` — valid config passes, missing filter fails, bad regex fails, missing log path fails.
14. **Backend:** Test the rollback endpoint — mock file write, mock daemon start, verify response for success and failure cases.
15. **Backend:** Test the health-check crash detection — simulate online→offline transition with a recent activation, verify `PendingRecovery` is set.
16. **Frontend:** Test `RecoveryBanner` — renders when `PendingRecovery` is present, disappears after successful rollback, shows error on failed rollback.
17. **Frontend:** Test the "Validate" button on the Jails tab — shows green on valid, shows errors on invalid.
### Acceptance Criteria
- Obvious config errors (missing filter, bad regex, missing log file) are caught **before** the jail is activated.
- If fail2ban crashes after a jail activation, BanGUI detects it within 30 seconds and shows a prominent alert.
- The user can disable the problematic jail and restart fail2ban with a single click from the alert banner.
- If the automatic rollback succeeds, BanGUI confirms fail2ban is back and shows the number of recovered jails.
- If the automatic rollback fails, the user is guided to check the log or intervene manually.
- A standalone "Validate" button lets users check a jail's config without activating it.
- All new endpoints have tests covering success, failure, and edge cases.
- `npx tsc --noEmit` passes with no errors.
- The pie chart on the Dashboard now displays coloured slices (blue, red, green, gold, purple) matching the Fluent palette.
- The legend and tooltip still work and show correct country names and percentages.
---
## Task 4 — Fix log viewer rejecting fail2ban log path under `/config/log`
**Status:** done
**Summary:** Added `"/config/log"` to `_SAFE_LOG_PREFIXES` tuple in `config_service.py` and updated the error message to reference both allowed prefixes (`/var/log` and `/config/log`). All existing tests continue to pass.
**Error:** `API error 400: {"detail":"Log path '/config/log/fail2ban/fail2ban.log' is outside the allowed directory. Only paths under /var/log are permitted."}`
**Root cause:** The linuxserver/fail2ban Docker image writes its own log to `/config/log/fail2ban/fail2ban.log` (this is configured via `logtarget` in `Docker/fail2ban-dev-config/fail2ban/fail2ban.conf`). In the Docker Compose setup, the `/config` volume is shared between the fail2ban and backend containers, so the file exists and is readable. However, the backend's `read_fail2ban_log` function in `backend/app/services/config_service.py` hard-codes the allowed path prefix list as:
```python
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)
```
This causes any log target under `/config/log/` to be rejected with a 400 error.
### What to do
1. Open `backend/app/services/config_service.py`.
2. Find the `_SAFE_LOG_PREFIXES` constant (line ~771). Add `"/config/log"` as a second allowed prefix:
```python
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
```
This is safe because:
- `/config/log` is a volume mount controlled by the Docker Compose setup, not user input.
- The path is still validated via `Path.resolve()` to prevent traversal (e.g. `/config/log/../../etc/shadow` resolves outside and is rejected).
3. Update the error message on the next line (inside `read_fail2ban_log`) to reflect both allowed directories:
```python
"Only paths under /var/log or /config/log are permitted."
```
4. Update the existing test `test_path_outside_safe_dir_raises_operation_error` in `backend/tests/test_services/test_config_service.py` — it currently patches `_SAFE_LOG_PREFIXES` to `("/var/log",)` in the assertion, so it will still pass. Verify no other tests break.
### Verification
- `python -m pytest backend/tests/test_services/test_config_service.py -q` passes.
- The log viewer page in the UI successfully loads the fail2ban log file at `/config/log/fail2ban/fail2ban.log` without a 400 error.
---
## Task 5 — Harden jail activation: block on missing logpaths and improve post-activation health checks
**Status:** done
**Summary:**
- **Part A** (`config_file_service.py`): `activate_jail` now refuses to proceed when `_validate_jail_config_sync` returns any issue with `field` in `("filter", "logpath")`. Returns `active=False, fail2ban_running=True` with a descriptive message without writing to disk or reloading fail2ban.
- **Part B** (`ActivateJailDialog.tsx`): All validation issues are now blocking (`blockingIssues = validationIssues`); the advisory-only `logpath` exclusion was removed.
- **Part C** (`config.py` router): After activation, `await _run_probe(request.app)` is called immediately to refresh the cached fail2ban status.
- **Part D** (`health.py`): `/api/health` now returns `{"status": "ok", "fail2ban": "online"|"offline"}` from cached probe state.
- **Tests**: Added `TestActivateJailBlocking` (3 tests) in `test_config_file_service.py`; added `test_200_with_active_false_on_missing_logpath` in `test_config.py`; updated 5 existing `TestActivateJail`/`TestActivateJailReloadArgs` tests to mock `_validate_jail_config_sync`.
### Problem description
Activating a jail whose `logpath` references a non-existent file (e.g. `airsonic-auth.conf` referencing a log file that doesn't exist) causes **fail2ban to crash entirely**. All previously running jails go down (Active Jails drops from 2 → 0). The GUI still shows "Service Health: Running" because:
1. The **backend `/api/health`** endpoint (`backend/app/routers/health.py`) only checks that the FastAPI process itself is alive — it does **not** probe fail2ban. So Docker health checks pass even when fail2ban is dead.
2. The **background health probe** runs every 30 seconds. If the user checks the dashboard during the window between the crash and the next probe, the cached status is stale (still shows "Running").
3. The **pre-activation validation** (`_validate_jail_config_sync` in `backend/app/services/config_file_service.py`) treats missing log paths as **warnings only** (`field="logpath"` issues). The `ActivateJailDialog` frontend component filters these as "advisory" and does not block activation. This means a jail with a non-existent log file is activated, causing fail2ban to crash on reload.
### What to do
#### Part A — Block activation when log files don't exist (backend)
**File:** `backend/app/services/config_file_service.py`
1. In `_validate_jail_config_sync()` (around line 715723), change the log path existence check from a **warning** to an **error** for literal, non-glob paths. Currently it appends a `JailValidationIssue` with `field="logpath"` but the function still returns `valid=True` if these are the only issues. Instead, treat a missing logpath as a blocking validation failure — the `valid` field at the bottom (line ~729) already uses `len(issues) == 0`, so if the issue is appended it will set `valid=False`.
The current code already does this correctly — the issue is in `activate_jail()` itself. Find the post-validation block (around line 11301138) where `warnings` are collected. Currently activation **always proceeds** regardless of validation result. Change this:
2. In `activate_jail()`, after running `_validate_jail_config_sync`, check `validation_result.valid`. If it is `False` **and** any issue has `field` in `("filter", "logpath")`, **refuse to activate**. Return a `JailActivationResponse` with `active=False`, `fail2ban_running=True`, and a descriptive `message` listing the blocking issues. This prevents writing `enabled = true` and reloading fail2ban with a known-bad config.
```python
# Block activation on critical validation failures (missing filter or logpath).
blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")]
if blocking:
log.warning("jail_activation_blocked", jail=name, issues=[str(i) for i in blocking])
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
validation_warnings=[f"{i.field}: {i.message}" for i in validation_result.issues],
message=(
f"Jail {name!r} cannot be activated: "
+ "; ".join(i.message for i in blocking)
),
)
```
#### Part B — Block activation in the frontend dialog
**File:** `frontend/src/components/config/ActivateJailDialog.tsx`
Currently `blockingIssues` is computed by filtering **out** `logpath` issues (line ~175):
```tsx
const blockingIssues = validationIssues.filter((i) => i.field !== "logpath");
```
Change this so that `logpath` issues **are** blocking too:
```tsx
const blockingIssues = validationIssues.filter(
(i) => i.field !== "logpath" || i.message.includes("not found"),
);
```
Or simply remove the `logpath` exclusion entirely so all validation issues block:
```tsx
const blockingIssues = validationIssues; // all issues block activation
const advisoryIssues: JailValidationIssue[] = []; // nothing is advisory anymore
```
The "Activate" button should remained disabled when `blockingIssues.length > 0` (this logic already exists).
#### Part C — Run an immediate health probe after activation (backend)
**File:** `backend/app/routers/config.py` — `activate_jail` endpoint (around line 640660)
After `config_file_service.activate_jail()` returns, **trigger an immediate health check** so the cached status is updated right away (instead of waiting up to 30 seconds):
```python
# Force an immediate health probe to refresh cached status.
from app.tasks.health_check import _run_probe
await _run_probe(request.app)
```
Add this right after the `last_activation` recording block (around line 653), before the `return result`. This ensures the dashboard immediately reflects the current fail2ban state.
#### Part D — Include fail2ban liveness in `/api/health` endpoint
**File:** `backend/app/routers/health.py`
The current health endpoint always returns `{"status": "ok"}`. Enhance it to also report fail2ban status from the cached probe:
```python
@router.get("/health", summary="Application health check")
async def health_check(request: Request) -> JSONResponse:
cached: ServerStatus = getattr(
request.app.state, "server_status", ServerStatus(online=False)
)
return JSONResponse(content={
"status": "ok",
"fail2ban": "online" if cached.online else "offline",
})
```
Keep the HTTP status code as 200 so Docker health checks don't restart the backend container when fail2ban is down. But having `"fail2ban": "offline"` in the response allows monitoring and debugging.
### Tests to add or update
**File:** `backend/tests/test_services/test_config_file_service.py`
1. **Add test**: `test_activate_jail_blocked_when_logpath_missing` — mock `_validate_jail_config_sync` to return `valid=False` with a `logpath` issue. Assert `activate_jail()` returns `active=False` and `fail2ban_running=True` without calling `reload_all`.
2. **Add test**: `test_activate_jail_blocked_when_filter_missing` — same pattern but with a `filter` issue.
3. **Add test**: `test_activate_jail_proceeds_when_only_regex_warnings` — mock validation with a non-blocking `failregex` issue and assert activation still proceeds.
**File:** `backend/tests/test_routers/test_config.py`
4. **Add test**: `test_activate_returns_400_style_response_on_missing_logpath` — POST to `/api/config/jails/{name}/activate` with a jail that has a missing logpath. Assert the response body has `active=False` and contains the logpath error message.
**File:** `backend/tests/test_tasks/test_health_check.py`
5. **Existing tests** should still pass — `_run_probe` behavior is unchanged.
### Verification
1. Run backend tests:
```bash
.venv/bin/python -m pytest backend/tests/ -q --tb=short
```
All tests pass with no failures.
2. Run frontend type check and lint:
```bash
cd frontend && npx tsc --noEmit && npx eslint src/components/config/ActivateJailDialog.tsx
```
3. **Manual test with running server:**
- Go to `/config`, find a jail with a non-existent logpath (e.g. `airsonic-auth`).
- Click "Activate" — the dialog should show a **blocking error** about the missing log file and the Activate button should be disabled.
- Verify that fail2ban is still running with the original jails intact (Active Jails count unchanged).
- Go to dashboard — "Service Health" should correctly reflect the live fail2ban state.
---
## Task 6 — Run immediate health probe after jail deactivation
**Status:** done
**Summary:** `deactivate_jail` endpoint in `config.py` now captures the service result, calls `await _run_probe(request.app)`, and then returns the result — matching the behaviour added to `activate_jail` in Task 5. Added `test_deactivate_triggers_health_probe` to `TestDeactivateJail` in `test_config.py` (verifies `_run_probe` is awaited once on success). Also fixed 3 pre-existing ruff UP017 warnings (`datetime.timezone.utc` → `datetime.UTC`) in `test_config.py`.
The `deactivate_jail` endpoint in `backend/app/routers/config.py` is inconsistent with `activate_jail`: after activation the router calls `await _run_probe(request.app)` to immediately refresh the cached fail2ban status (added in Task 5 Part C). Deactivation performs a full `reload_all` which also causes a brief fail2ban restart; without the probe the dashboard can show a stale active-jail count for up to 30 seconds.
### What to do
**File:** `backend/app/routers/config.py` — `deactivate_jail` endpoint (around line 670698)
1. The handler currently calls `config_file_service.deactivate_jail(...)` and returns its result directly via `return await ...`. Refactor it to capture the result first, run the probe, then return:
```python
result = await config_file_service.deactivate_jail(config_dir, socket_path, name)
# Force an immediate health probe so the cached status reflects the current
# fail2ban state (reload changes the active-jail count).
await _run_probe(request.app)
return result
```
`_run_probe` is already imported at the top of the file (added in Task 5).
### Tests to add or update
**File:** `backend/tests/test_routers/test_config.py`
2. **Add test**: `test_deactivate_triggers_health_probe` — in the `TestDeactivateJail` class, mock both `config_file_service.deactivate_jail` and `app.routers.config._run_probe`. POST to `/api/config/jails/sshd/deactivate` and assert that `_run_probe` was awaited exactly once.
3. **Update test** `test_200_deactivates_jail` — it already passes without the probe mock, so no changes are needed unless the test client setup causes `_run_probe` to raise. Add a mock for `_run_probe` to prevent real socket calls in that test too.
### Verification
1. Run backend tests:
```bash
.venv/bin/python -m pytest backend/tests/test_routers/test_config.py -q --tb=short
```
All tests pass with no failures.
2. Run the full backend suite to confirm no regressions:
```bash
.venv/bin/python -m pytest backend/tests/ --no-cov --tb=no -q
```
3. **Manual test with running server:**
- Go to `/config`, find an active jail and click "Deactivate".
- Immediately navigate to the Dashboard — "Active Jails" count should already reflect the reduced count without any delay.
---
## Task 7 — Fix ActivateJailDialog not honouring backend rejection and mypy false positive
**Status:** done
**Summary:**
- **Bug 1** (`ActivateJailDialog.tsx`): Added `|| blockingIssues.length > 0` to the "Activate" button's `disabled` prop so the button is correctly greyed-out when pre-validation surfaces any blocking issue (filter or logpath problems).
- **Bug 2** (`ActivateJailDialog.tsx`): `handleConfirm`'s `.then()` handler now checks `result.active` first. When `active=false` the dialog stays open and shows `result.message` as an error; `resetForm()` and `onActivated()` are only called on `active=true`.
- **Bug 3** (`config.py`): Added `# type: ignore[call-arg]` with a comment to `Settings()` call to suppress the mypy strict-mode false positive caused by pydantic-settings loading required fields from environment variables at runtime.
- **Tests**: Added `ActivateJailDialog.test.tsx` with 5 tests (button disabled on blocking issues, button enabled on clean validation, dialog stays open on backend rejection, `onActivated` called on success, `onCrashDetected` fired when `fail2ban_running=false`).
### Problem description
Two independent bugs were introduced during Tasks 56:
**Bug 1 — "Activate" button is never disabled on validation errors (frontend)**
In `frontend/src/components/config/ActivateJailDialog.tsx`, Task 5 Part B set:
```tsx
const blockingIssues = validationIssues; // all issues block activation
```
but the "Activate" `<Button>` `disabled` prop was never updated to include `blockingIssues.length > 0`:
```tsx
disabled={submitting || validating} // BUG: missing `|| blockingIssues.length > 0`
```
The pre-validation error message renders correctly, but the button stays clickable. A user can press "Activate" despite seeing a red error — the backend will refuse (returning `active=false`) but the UX is broken and confusing.
**Bug 2 — Dialog closes and fires `onActivated()` even when backend rejects activation (frontend)**
`handleConfirm`'s `.then()` handler never inspects `result.active`. When the backend blocks activation and returns `{ active: false, message: "...", validation_warnings: [...] }`, the frontend still:
1. Calls `setValidationWarnings(result.validation_warnings)` — sets warnings in state.
2. Immediately calls `resetForm()` — which **clears** the newly-set warnings.
3. Calls `onActivated()` — which triggers the parent to refresh the jail list (and may close the dialog).
The user sees the dialog briefly appear to succeed, the parent refreshes, but the jail never activated.
**Bug 3 — mypy strict false positive in `config.py`**
`get_settings()` calls `Settings()` without arguments. mypy strict mode flags this as:
```
backend/app/config.py:88: error: Missing named argument "session_secret" for "Settings" [call-arg]
```
This is a known pydantic-settings limitation: the library loads required fields from environment variables at runtime, which mypy cannot see statically. A targeted suppression with an explanatory comment is the correct fix.
### What to do
#### Part A — Disable "Activate" button when blocking issues are present (frontend)
**File:** `frontend/src/components/config/ActivateJailDialog.tsx`
Find the "Activate" `<Button>` near the bottom of the returned JSX and change its `disabled` prop:
```tsx
// Before:
disabled={submitting || validating}
// After:
disabled={submitting || validating || blockingIssues.length > 0}
```
#### Part B — Handle `active=false` response from backend (frontend)
**File:** `frontend/src/components/config/ActivateJailDialog.tsx`
In `handleConfirm`'s `.then()` callback, add a check for `result.active` before calling `resetForm()` and `onActivated()`:
```tsx
.then((result) => {
if (!result.active) {
// Backend rejected the activation (e.g. missing logpath).
// Show the server's message and keep the dialog open.
setError(result.message);
return;
}
if (result.validation_warnings.length > 0) {
setValidationWarnings(result.validation_warnings);
}
resetForm();
if (!result.fail2ban_running) {
onCrashDetected?.();
}
onActivated();
})
```
#### Part C — Fix mypy false positive (backend)
**File:** `backend/app/config.py`
Add a targeted `# type: ignore[call-arg]` with an explanatory comment to the `Settings()` call in `get_settings()`:
```python
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
```
### Tests to add or update
**File:** `frontend/src/components/config/__tests__/ActivateJailDialog.test.tsx` (new file)
Write tests covering:
1. **`test_activate_button_disabled_when_blocking_issues`** — render the dialog with mocked `validateJailConfig` returning an issue with `field="logpath"`. Assert the "Activate" button is disabled.
2. **`test_activate_button_enabled_when_no_issues`** — render the dialog with mocked `validateJailConfig` returning no issues. Assert the "Activate" button is enabled after validation completes.
3. **`test_dialog_stays_open_when_backend_returns_active_false`** — mock `activateJail` to return `{ active: false, message: "Jail cannot be activated", validation_warnings: [], fail2ban_running: true, name: "test" }`. Click "Activate". Assert: (a) `onActivated` is NOT called; (b) the error message text appears.
4. **`test_dialog_calls_on_activated_when_backend_returns_active_true`** — mock `activateJail` to return `{ active: true, message: "ok", validation_warnings: [], fail2ban_running: true, name: "test" }`. Click "Activate". Assert `onActivated` is called once.
5. **`test_crash_detected_callback_fires_when_fail2ban_not_running`** — mock `activateJail` to return `active: true, fail2ban_running: false`. Assert `onCrashDetected` is called.
### Verification
1. Run frontend type check and lint:
```bash
cd frontend && npx tsc --noEmit && npx eslint src/components/config/ActivateJailDialog.tsx
```
Zero errors and zero warnings.
2. Run frontend tests:
```bash
cd frontend && npx vitest run src/components/config/__tests__/ActivateJailDialog
```
All 5 new tests pass.
3. Run mypy:
```bash
.venv/bin/mypy backend/app/ --strict
```
Zero errors.
---
## Task 8 — Add "ignore self" toggle to Jail Detail page
**Status:** done
**Summary:** Added `jailIgnoreSelf` endpoint constant to `endpoints.ts`; added `toggleIgnoreSelf(name, on)` API function to `jails.ts`; extended `useJailDetail` return type and hook implementation to expose `toggleIgnoreSelf`; replaced the read-only "ignore self" badge in `IgnoreListSection` (`JailDetailPage.tsx`) with a Fluent UI `Switch` that calls the toggle action and surfaces any error in the existing `opError` message bar; added 5 new tests in `JailDetailIgnoreSelf.test.tsx` covering checked/unchecked rendering, toggle-on, toggle-off, and error display.
**Page:** `/jails/:name` — rendered by `frontend/src/pages/JailDetailPage.tsx`
### Problem description
`Features.md` §5 (Jail Management / IP Whitelist) requires: "Toggle the 'ignore self' option per jail, which automatically excludes the server's own IP addresses."
The backend already exposes `POST /api/jails/{name}/ignoreself` (accepts a JSON boolean `on`). The `useJailDetail` hook reads `ignore_self` from the `GET /api/jails/{name}` response and exposes it as `ignoreSelf: boolean`. However:
1. **No API wrapper** — `frontend/src/api/jails.ts` has no `toggleIgnoreSelf` function, and `frontend/src/api/endpoints.ts` has no `jailIgnoreSelf` entry.
2. **No hook action** — `UseJailDetailResult` in `useJails.ts` does not expose a `toggleIgnoreSelf` helper.
3. **Read-only UI** — `IgnoreListSection` in `JailDetailPage.tsx` shows an "ignore self" badge when enabled but has no control to change the setting.
As a result, users must use the fail2ban CLI to manage this flag even though the backend is ready.
### What to do
#### Part A — Add endpoint constant (frontend)
**File:** `frontend/src/api/endpoints.ts`
Inside the Jails block, after `jailIgnoreIp`, add:
```ts
jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`,
```
#### Part B — Add API wrapper function (frontend)
**File:** `frontend/src/api/jails.ts`
After the `delIgnoreIp` function in the "Ignore list" section, add:
```ts
/**
* Enable or disable the `ignoreself` flag for a jail.
*
* When enabled, fail2ban automatically adds the server's own IP addresses to
* the ignore list so the host can never ban itself.
*
* @param name - Jail name.
* @param on - `true` to enable, `false` to disable.
* @returns A {@link JailCommandResponse} confirming the change.
* @throws {ApiError} On non-2xx responses.
*/
export async function toggleIgnoreSelf(
name: string,
on: boolean,
): Promise<JailCommandResponse> {
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreSelf(name), on);
}
```
#### Part C — Expose toggle action from hook (frontend)
**File:** `frontend/src/hooks/useJails.ts`
1. Import `toggleIgnoreSelf` at the top (alongside the other API imports).
2. Add `toggleIgnoreSelf: (on: boolean) => Promise<void>` to the `UseJailDetailResult` interface with a JSDoc comment: `/** Enable or disable the ignoreself option for this jail. */`.
3. Inside `useJailDetail`, add the implementation:
```ts
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
};
```
Alias the import as `toggleIgnoreSelfApi` to avoid shadowing the local function name.
4. Add the function to the returned object.
#### Part D — Add toggle control to UI (frontend)
**File:** `frontend/src/pages/JailDetailPage.tsx`
1. Accept `toggleIgnoreSelf: (on: boolean) => Promise<void>` in the `IgnoreListSectionProps` interface.
2. Pass the function from `useJailDetail` down via the existing destructuring and JSX prop.
3. Inside `IgnoreListSection`, render a `<Switch>` next to (or in place of) the read-only badge:
```tsx
<Switch
label="Ignore self (exclude this server's own IPs)"
checked={ignoreSelf}
onChange={(_e, data): void => {
toggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setOpError(msg);
});
}}
/>
```
Import `Switch` from `"@fluentui/react-components"`. Remove the existing read-only badge (it is replaced by the labelled switch, which is self-explanatory). Keep the existing `opError` state and `<MessageBar>` for error display.
### Tests to add
**File:** `frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx` (new file)
Write tests that render the `IgnoreListSection` component (or the full `JailDetailPage` via a shallow-enough render) and cover:
1. **`test_ignore_self_switch_is_checked_when_ignore_self_true`** — when `ignoreSelf=true`, the switch is checked.
2. **`test_ignore_self_switch_is_unchecked_when_ignore_self_false`** — when `ignoreSelf=false`, the switch is unchecked.
3. **`test_toggling_switch_calls_toggle_ignore_self`** — clicking the switch calls `toggleIgnoreSelf` with `false` (when it was `true`).
4. **`test_toggle_error_shows_message_bar`** — when `toggleIgnoreSelf` rejects, the error message bar is rendered.
### Verification
1. Run frontend type check and lint:
```bash
cd frontend && npx tsc --noEmit && npx eslint src/api/jails.ts src/api/endpoints.ts src/hooks/useJails.ts src/pages/JailDetailPage.tsx
```
Zero errors and zero warnings.
2. Run frontend tests:
```bash
cd frontend && npx vitest run src/pages/__tests__/JailDetailIgnoreSelf
```
All 4 new tests pass.
3. **Manual test with running server:**
- Go to `/jails`, click a running jail.
- On the Jail Detail page, scroll to "Ignore List (IP Whitelist)".
- Toggle the "Ignore self" switch on and off — the switch should reflect the live state and the change should survive a page refresh.

View File

@@ -60,6 +60,15 @@ class Settings(BaseSettings):
"Used for listing, viewing, and editing configuration files through the web UI."
),
)
fail2ban_start_command: str = Field(
default="fail2ban-client start",
description=(
"Shell command used to start (not reload) the fail2ban daemon during "
"recovery rollback. Split by whitespace to build the argument list — "
"no shell interpretation is performed. "
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
),
)
model_config = SettingsConfigDict(
env_prefix="BANGUI_",
@@ -76,4 +85,4 @@ def get_settings() -> Settings:
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
if required keys are absent or values fail validation.
"""
return Settings()
return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars

View File

@@ -306,3 +306,30 @@ class BansByJailResponse(BaseModel):
description="Jails ordered by ban count descending.",
)
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
# ---------------------------------------------------------------------------
# Jail-specific paginated bans
# ---------------------------------------------------------------------------
class JailBannedIpsResponse(BaseModel):
"""Paginated response for ``GET /api/jails/{name}/banned``.
Contains only the current page of active ban entries for a single jail,
geo-enriched exclusively for the page slice to avoid rate-limit issues.
"""
model_config = ConfigDict(strict=True)
items: list[ActiveBan] = Field(
default_factory=list,
description="Active ban entries for the current page.",
)
total: int = Field(
...,
ge=0,
description="Total matching entries (after applying the search filter).",
)
page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.")

View File

@@ -3,6 +3,8 @@
Request, response, and domain models for the config router and service.
"""
import datetime
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
@@ -860,3 +862,130 @@ class JailActivationResponse(BaseModel):
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
)
message: str = Field(..., description="Human-readable result message.")
fail2ban_running: bool = Field(
default=True,
description=(
"Whether the fail2ban daemon is still running after the activation "
"and reload. ``False`` signals that the daemon may have crashed."
),
)
validation_warnings: list[str] = Field(
default_factory=list,
description="Non-fatal warnings from the pre-activation validation step.",
)
# ---------------------------------------------------------------------------
# Jail validation models (Task 3)
# ---------------------------------------------------------------------------
class JailValidationIssue(BaseModel):
"""A single issue found during pre-activation validation of a jail config."""
model_config = ConfigDict(strict=True)
field: str = Field(
...,
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
)
message: str = Field(..., description="Human-readable description of the issue.")
class JailValidationResult(BaseModel):
"""Result of pre-activation validation of a single jail configuration."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the validated jail.")
valid: bool = Field(..., description="True when no issues were found.")
issues: list[JailValidationIssue] = Field(
default_factory=list,
description="Validation issues found. Empty when valid=True.",
)
# ---------------------------------------------------------------------------
# Rollback response model (Task 3)
# ---------------------------------------------------------------------------
class RollbackResponse(BaseModel):
"""Response for ``POST /api/config/jails/{name}/rollback``."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the jail that was disabled.")
disabled: bool = Field(
...,
description="Whether the jail's .local override was successfully written with enabled=false.",
)
fail2ban_running: bool = Field(
...,
description="Whether fail2ban is online after the rollback attempt.",
)
active_jails: int = Field(
default=0,
ge=0,
description="Number of currently active jails after a successful restart.",
)
message: str = Field(..., description="Human-readable result message.")
# ---------------------------------------------------------------------------
# Pending recovery model (Task 3)
# ---------------------------------------------------------------------------
class PendingRecovery(BaseModel):
"""Records a probable activation-caused fail2ban crash pending user action."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(
...,
description="Name of the jail whose activation likely caused the crash.",
)
activated_at: datetime.datetime = Field(
...,
description="ISO-8601 UTC timestamp of when the jail was activated.",
)
detected_at: datetime.datetime = Field(
...,
description="ISO-8601 UTC timestamp of when the crash was detected.",
)
recovered: bool = Field(
default=False,
description="Whether fail2ban has been successfully restarted.",
)
# ---------------------------------------------------------------------------
# fail2ban log viewer models
# ---------------------------------------------------------------------------
class Fail2BanLogResponse(BaseModel):
"""Response for ``GET /api/config/fail2ban-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
log_level: str = Field(..., description="Current fail2ban log level.")
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
class ServiceStatusResponse(BaseModel):
"""Response for ``GET /api/config/service-status``."""
model_config = ConfigDict(strict=True)
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")

View File

@@ -9,6 +9,9 @@ global settings, test regex patterns, add log paths, and preview log files.
* ``GET /api/config/jails/inactive`` — list all inactive jails
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
* ``POST /api/config/jails/{name}/validate`` — validate jail config pre-activation (Task 3)
* ``POST /api/config/jails/{name}/rollback`` — disable bad jail and restart fail2ban (Task 3)
* ``GET /api/config/pending-recovery`` — active crash-recovery record (Task 3)
* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail
* ``POST /api/config/jails/{name}/action`` — add an action to a jail
* ``DELETE /api/config/jails/{name}/action/{action_name}`` — remove an action from a jail
@@ -28,10 +31,13 @@ global settings, test regex patterns, add log paths, and preview log files.
* ``PUT /api/config/actions/{name}`` — update an action's .local override
* ``POST /api/config/actions`` — create a new user-defined action
* ``DELETE /api/config/actions/{name}`` — delete an action's .local file
* ``GET /api/config/fail2ban-log`` — read the tail of the fail2ban log file
* ``GET /api/config/service-status`` — fail2ban health + log configuration
"""
from __future__ import annotations
import datetime
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
@@ -46,6 +52,7 @@ from app.models.config import (
AddLogPathRequest,
AssignActionRequest,
AssignFilterRequest,
Fail2BanLogResponse,
FilterConfig,
FilterCreateRequest,
FilterListResponse,
@@ -57,12 +64,16 @@ from app.models.config import (
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
JailValidationResult,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
PendingRecovery,
RegexTestRequest,
RegexTestResponse,
RollbackResponse,
ServiceStatusResponse,
)
from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
@@ -86,6 +97,7 @@ from app.services.config_service import (
ConfigValidationError,
JailNotFoundError,
)
from app.tasks.health_check import _run_probe
from app.utils.fail2ban_client import Fail2BanConnectionError
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
@@ -607,7 +619,7 @@ async def activate_jail(
req = body if body is not None else ActivateJailRequest()
try:
return await config_file_service.activate_jail(
result = await config_file_service.activate_jail(
config_dir, socket_path, name, req
)
except JailNameError as exc:
@@ -627,6 +639,28 @@ async def activate_jail(
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Record this activation so the health-check task can attribute a
# subsequent fail2ban crash to it.
request.app.state.last_activation = {
"jail_name": name,
"at": datetime.datetime.now(tz=datetime.UTC),
}
# If fail2ban stopped responding after the reload, create a pending-recovery
# record immediately (before the background health task notices).
if not result.fail2ban_running:
request.app.state.pending_recovery = PendingRecovery(
jail_name=name,
activated_at=request.app.state.last_activation["at"],
detected_at=datetime.datetime.now(tz=datetime.UTC),
)
# Force an immediate health probe so the cached status reflects the current
# fail2ban state without waiting for the next scheduled check.
await _run_probe(request.app)
return result
@router.post(
"/jails/{name}/deactivate",
@@ -661,7 +695,7 @@ async def deactivate_jail(
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.deactivate_jail(config_dir, socket_path, name)
result = await config_file_service.deactivate_jail(config_dir, socket_path, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
@@ -679,6 +713,132 @@ async def deactivate_jail(
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Force an immediate health probe so the cached status reflects the current
# fail2ban state (reload changes the active-jail count) without waiting for
# the next scheduled background check (up to 30 seconds).
await _run_probe(request.app)
return result
# ---------------------------------------------------------------------------
# Jail validation & rollback endpoints (Task 3)
# ---------------------------------------------------------------------------
@router.post(
"/jails/{name}/validate",
response_model=JailValidationResult,
summary="Validate jail configuration before activation",
)
async def validate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> JailValidationResult:
"""Run pre-activation validation checks on a jail configuration.
Validates filter and action file existence, regex pattern compilation, and
log path existence without modifying any files or reloading fail2ban.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name to validate.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await config_file_service.validate_jail_config(config_dir, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
@router.get(
"/pending-recovery",
response_model=PendingRecovery | None,
summary="Return active crash-recovery record if one exists",
)
async def get_pending_recovery(
request: Request,
_auth: AuthDep,
) -> PendingRecovery | None:
"""Return the current :class:`~app.models.config.PendingRecovery` record.
A non-null response means fail2ban crashed shortly after a jail activation
and the user should be offered a rollback option. Returns ``null`` (HTTP
200 with ``null`` body) when no recovery is pending.
Args:
request: FastAPI request object.
_auth: Validated session.
Returns:
:class:`~app.models.config.PendingRecovery` or ``None``.
"""
return getattr(request.app.state, "pending_recovery", None)
@router.post(
"/jails/{name}/rollback",
response_model=RollbackResponse,
summary="Disable a bad jail config and restart fail2ban",
)
async def rollback_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> RollbackResponse:
"""Disable the specified jail and attempt to restart fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
fail2ban is down — no socket is needed), then runs the configured start
command and waits up to ten seconds for the daemon to come back online.
On success, clears the :class:`~app.models.config.PendingRecovery` record.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name to disable and roll back.
Returns:
:class:`~app.models.config.RollbackResponse`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 500 if writing the .local override file fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
start_cmd: str = request.app.state.settings.fail2ban_start_command
start_cmd_parts: list[str] = start_cmd.split()
try:
result = await config_file_service.rollback_jail(
config_dir, socket_path, name, start_cmd_parts
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
# Clear pending recovery if fail2ban came back online.
if result.fail2ban_running:
request.app.state.pending_recovery = None
request.app.state.last_activation = None
return result
# ---------------------------------------------------------------------------
# Filter discovery endpoints (Task 2.1)
@@ -1319,3 +1479,83 @@ async def remove_action_from_jail(
detail=f"Failed to write jail override: {exc}",
) from exc
# ---------------------------------------------------------------------------
# fail2ban log viewer endpoints
# ---------------------------------------------------------------------------
@router.get(
"/fail2ban-log",
response_model=Fail2BanLogResponse,
summary="Read the tail of the fail2ban daemon log file",
)
async def get_fail2ban_log(
request: Request,
_auth: AuthDep,
lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200,
filter: Annotated[ # noqa: A002
str | None,
Query(description="Plain-text substring filter; only matching lines are returned."),
] = None,
) -> Fail2BanLogResponse:
"""Return the tail of the fail2ban daemon log file.
Queries the fail2ban socket for the current log target and log level,
reads the last *lines* entries from the file, and optionally filters
them by *filter*. Only file-based log targets are supported.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
lines: Number of tail lines to return (12000, default 200).
filter: Optional plain-text substring — only matching lines returned.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
HTTPException: 400 when the log target is not a file or path is outside
the allowed directory.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.read_fail2ban_log(socket_path, lines, filter)
except config_service.ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get(
"/service-status",
response_model=ServiceStatusResponse,
summary="Return fail2ban service health status with log configuration",
)
async def get_service_status(
request: Request,
_auth: AuthDep,
) -> ServiceStatusResponse:
"""Return fail2ban service health and current log configuration.
Probes the fail2ban daemon to determine online/offline state, then
augments the result with the current log level and log target values.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable (the service itself
handles this gracefully and returns ``online=False``).
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.get_service_status(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -1,21 +1,37 @@
"""Health check router.
A lightweight ``GET /api/health`` endpoint that verifies the application
is running and can serve requests. It does not probe fail2ban — that
responsibility belongs to the health service (Stage 4).
is running and can serve requests. Also reports the cached fail2ban liveness
state so monitoring tools and Docker health checks can observe daemon status
without probing the socket directly.
"""
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from app.models.server import ServerStatus
router: APIRouter = APIRouter(prefix="/api", tags=["Health"])
@router.get("/health", summary="Application health check")
async def health_check() -> JSONResponse:
"""Return a 200 response confirming the API is operational.
async def health_check(request: Request) -> JSONResponse:
"""Return 200 with application and fail2ban status.
HTTP 200 is always returned so Docker health checks do not restart the
backend container when fail2ban is temporarily offline. The
``fail2ban`` field in the body indicates the daemon's current state.
Args:
request: FastAPI request (used to read cached server status).
Returns:
A JSON object with ``{"status": "ok"}``.
A JSON object with ``{"status": "ok", "fail2ban": "online"|"offline"}``.
"""
return JSONResponse(content={"status": "ok"})
cached: ServerStatus = getattr(
request.app.state, "server_status", ServerStatus(online=False)
)
return JSONResponse(content={
"status": "ok",
"fail2ban": "online" if cached.online else "offline",
})

View File

@@ -4,6 +4,7 @@ Provides CRUD and control operations for fail2ban jails:
* ``GET /api/jails`` — list all jails
* ``GET /api/jails/{name}`` — full detail for one jail
* ``GET /api/jails/{name}/banned`` — paginated currently-banned IPs for one jail
* ``POST /api/jails/{name}/start`` — start a jail
* ``POST /api/jails/{name}/stop`` — stop a jail
* ``POST /api/jails/{name}/idle`` — toggle idle mode
@@ -23,6 +24,7 @@ from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Path, Request, status
from app.dependencies import AuthDep
from app.models.ban import JailBannedIpsResponse
from app.models.jail import (
IgnoreIpRequest,
JailCommandResponse,
@@ -540,3 +542,74 @@ async def toggle_ignore_self(
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Currently banned IPs (paginated)
# ---------------------------------------------------------------------------
@router.get(
"/{name}/banned",
response_model=JailBannedIpsResponse,
summary="Return paginated currently-banned IPs for a single jail",
)
async def get_jail_banned_ips(
request: Request,
_auth: AuthDep,
name: _NamePath,
page: int = 1,
page_size: int = 25,
search: str | None = None,
) -> JailBannedIpsResponse:
"""Return a paginated list of IPs currently banned by a specific jail.
The full ban list is fetched from the fail2ban socket, filtered by the
optional *search* substring, sliced to the requested page, and then
geo-enriched exclusively for that page slice.
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
name: Jail name.
page: 1-based page number (default 1, min 1).
page_size: Items per page (default 25, max 100).
search: Optional case-insensitive substring filter on the IP address.
Returns:
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
Raises:
HTTPException: 400 when *page* or *page_size* are out of range.
HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable.
"""
if page < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="page must be >= 1.",
)
if not (1 <= page_size <= 100):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="page_size must be between 1 and 100.",
)
socket_path: str = request.app.state.settings.fail2ban_socket
http_session = getattr(request.app.state, "http_session", None)
app_db = getattr(request.app.state, "db", None)
try:
return await jail_service.get_jail_banned_ips(
socket_path=socket_path,
jail_name=name,
page=page,
page_size=page_size,
search=search,
http_session=http_session,
app_db=app_db,
)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -50,6 +50,9 @@ from app.models.config import (
InactiveJail,
InactiveJailListResponse,
JailActivationResponse,
JailValidationIssue,
JailValidationResult,
RollbackResponse,
)
from app.services import conffile_parser, jail_service
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
@@ -560,6 +563,242 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
return set()
# ---------------------------------------------------------------------------
# Validation helpers (Task 3)
# ---------------------------------------------------------------------------
# Seconds to wait between fail2ban liveness probes after a reload.
_POST_RELOAD_PROBE_INTERVAL: float = 2.0
# Maximum number of post-reload probe attempts (initial attempt + retries).
_POST_RELOAD_MAX_ATTEMPTS: int = 4
def _extract_action_base_name(action_str: str) -> str | None:
"""Return the base action name from an action assignment string.
Returns ``None`` for complex fail2ban expressions that cannot be resolved
to a single filename (e.g. ``%(action_)s`` interpolations or multi-token
composite actions).
Args:
action_str: A single line from the jail's ``action`` setting.
Returns:
Simple base name suitable for a filesystem lookup, or ``None``.
"""
if "%" in action_str or "$" in action_str:
return None
base = action_str.split("[")[0].strip()
if _SAFE_ACTION_NAME_RE.match(base):
return base
return None
def _validate_jail_config_sync(
config_dir: Path,
name: str,
) -> JailValidationResult:
"""Run synchronous pre-activation checks on a jail configuration.
Validates:
1. Filter file existence in ``filter.d/``.
2. Action file existence in ``action.d/`` (for resolvable action names).
3. Regex compilation for every ``failregex`` and ``ignoreregex`` pattern.
4. Log path existence on disk (generates warnings, not errors).
Args:
config_dir: The fail2ban configuration root directory.
name: Validated jail name.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
"""
issues: list[JailValidationIssue] = []
all_jails, _ = _parse_jails_sync(config_dir)
settings = all_jails.get(name)
if settings is None:
return JailValidationResult(
jail_name=name,
valid=False,
issues=[
JailValidationIssue(
field="name",
message=f"Jail {name!r} not found in config files.",
)
],
)
filter_d = config_dir / "filter.d"
action_d = config_dir / "action.d"
# 1. Filter existence check.
raw_filter = settings.get("filter", "")
if raw_filter:
mode = settings.get("mode", "normal")
resolved = _resolve_filter(raw_filter, name, mode)
base_filter = _extract_filter_base_name(resolved)
if base_filter:
conf_ok = (filter_d / f"{base_filter}.conf").is_file()
local_ok = (filter_d / f"{base_filter}.local").is_file()
if not conf_ok and not local_ok:
issues.append(
JailValidationIssue(
field="filter",
message=(
f"Filter file not found: filter.d/{base_filter}.conf"
" (or .local)"
),
)
)
# 2. Action existence check.
raw_action = settings.get("action", "")
if raw_action:
for action_line in _parse_multiline(raw_action):
action_name = _extract_action_base_name(action_line)
if action_name:
conf_ok = (action_d / f"{action_name}.conf").is_file()
local_ok = (action_d / f"{action_name}.local").is_file()
if not conf_ok and not local_ok:
issues.append(
JailValidationIssue(
field="action",
message=(
f"Action file not found: action.d/{action_name}.conf"
" (or .local)"
),
)
)
# 3. failregex compilation.
for pattern in _parse_multiline(settings.get("failregex", "")):
try:
re.compile(pattern)
except re.error as exc:
issues.append(
JailValidationIssue(
field="failregex",
message=f"Invalid regex pattern: {exc}",
)
)
# 4. ignoreregex compilation.
for pattern in _parse_multiline(settings.get("ignoreregex", "")):
try:
re.compile(pattern)
except re.error as exc:
issues.append(
JailValidationIssue(
field="ignoreregex",
message=f"Invalid regex pattern: {exc}",
)
)
# 5. Log path existence (warning only — paths may be created at runtime).
raw_logpath = settings.get("logpath", "")
if raw_logpath:
for log_path in _parse_multiline(raw_logpath):
# Skip glob patterns and fail2ban variable references.
if "*" in log_path or "?" in log_path or "%(" in log_path:
continue
if not Path(log_path).exists():
issues.append(
JailValidationIssue(
field="logpath",
message=f"Log file not found on disk: {log_path}",
)
)
valid = len(issues) == 0
log.debug(
"jail_validation_complete",
jail=name,
valid=valid,
issue_count=len(issues),
)
return JailValidationResult(jail_name=name, valid=valid, issues=issues)
async def _probe_fail2ban_running(socket_path: str) -> bool:
"""Return ``True`` if the fail2ban socket responds to a ping.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
``True`` when fail2ban is reachable, ``False`` otherwise.
"""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=5.0)
resp = await client.send(["ping"])
return isinstance(resp, (list, tuple)) and resp[0] == 0
except Exception: # noqa: BLE001
return False
async def _wait_for_fail2ban(
socket_path: str,
max_wait_seconds: float = 10.0,
poll_interval: float = 2.0,
) -> bool:
"""Poll the fail2ban socket until it responds or the timeout expires.
Args:
socket_path: Path to the fail2ban Unix domain socket.
max_wait_seconds: Total time budget in seconds.
poll_interval: Delay between probe attempts in seconds.
Returns:
``True`` if fail2ban came online within the budget.
"""
elapsed = 0.0
while elapsed < max_wait_seconds:
if await _probe_fail2ban_running(socket_path):
return True
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return False
async def _start_daemon(start_cmd_parts: list[str]) -> bool:
"""Start the fail2ban daemon using *start_cmd_parts*.
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
to avoid command injection.
Args:
start_cmd_parts: Command and arguments, e.g.
``["fail2ban-client", "start"]``.
Returns:
``True`` when the process exited with code 0.
"""
if not start_cmd_parts:
log.warning("fail2ban_start_cmd_empty")
return False
try:
proc = await asyncio.create_subprocess_exec(
*start_cmd_parts,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.wait(), timeout=30.0)
success = proc.returncode == 0
if not success:
log.warning(
"fail2ban_start_cmd_nonzero",
cmd=start_cmd_parts,
returncode=proc.returncode,
)
return success
except (TimeoutError, OSError) as exc:
log.warning("fail2ban_start_cmd_error", cmd=start_cmd_parts, error=str(exc))
return False
def _write_local_override_sync(
config_dir: Path,
jail_name: str,
@@ -846,9 +1085,10 @@ async def activate_jail(
) -> JailActivationResponse:
"""Enable an inactive jail and reload fail2ban.
Writes ``enabled = true`` (plus any override values from *req*) to
``jail.d/{name}.local`` and then triggers a full fail2ban reload so the
jail starts immediately.
Performs pre-activation validation, writes ``enabled = true`` (plus any
override values from *req*) to ``jail.d/{name}.local``, and triggers a
full fail2ban reload. After the reload a multi-attempt health probe
determines whether fail2ban (and the specific jail) are still running.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
@@ -857,7 +1097,8 @@ async def activate_jail(
req: Optional override values to write alongside ``enabled = true``.
Returns:
:class:`~app.models.config.JailActivationResponse`.
:class:`~app.models.config.JailActivationResponse` including
``fail2ban_running`` and ``validation_warnings`` fields.
Raises:
JailNameError: If *name* contains invalid characters.
@@ -881,6 +1122,39 @@ async def activate_jail(
if name in active_names:
raise JailAlreadyActiveError(name)
# ---------------------------------------------------------------------- #
# Pre-activation validation — collect warnings but do not block #
# ---------------------------------------------------------------------- #
validation_result: JailValidationResult = await loop.run_in_executor(
None, _validate_jail_config_sync, Path(config_dir), name
)
warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues]
if warnings:
log.warning(
"jail_activation_validation_warnings",
jail=name,
warnings=warnings,
)
# Block activation on critical validation failures (missing filter or logpath).
blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")]
if blocking:
log.warning(
"jail_activation_blocked",
jail=name,
issues=[f"{i.field}: {i.message}" for i in blocking],
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
validation_warnings=warnings,
message=(
f"Jail {name!r} cannot be activated: "
+ "; ".join(i.message for i in blocking)
),
)
overrides: dict[str, Any] = {
"bantime": req.bantime,
"findtime": req.findtime,
@@ -903,9 +1177,35 @@ async def activate_jail(
except Exception as exc: # noqa: BLE001
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 health probe with retries #
# ---------------------------------------------------------------------- #
fail2ban_running = False
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
if attempt > 0:
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
if await _probe_fail2ban_running(socket_path):
fail2ban_running = True
break
if not fail2ban_running:
log.warning(
"fail2ban_down_after_activate",
jail=name,
message="fail2ban socket unreachable after reload — daemon may have crashed.",
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
validation_warnings=warnings,
message=(
f"Jail {name!r} was written to config but fail2ban stopped "
"responding after reload. The jail configuration may be invalid."
),
)
# Verify the jail actually started (config error may prevent it silently).
post_reload_names = await _get_active_jail_names(socket_path)
actually_running = name in post_reload_names
if not actually_running:
@@ -917,6 +1217,8 @@ async def activate_jail(
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
validation_warnings=warnings,
message=(
f"Jail {name!r} was written to config but did not start after "
"reload — check the jail configuration (filters, log paths, regex)."
@@ -927,6 +1229,8 @@ async def activate_jail(
return JailActivationResponse(
name=name,
active=True,
fail2ban_running=True,
validation_warnings=warnings,
message=f"Jail {name!r} activated successfully.",
)
@@ -994,6 +1298,117 @@ async def deactivate_jail(
)
async def validate_jail_config(
config_dir: str,
name: str,
) -> JailValidationResult:
"""Run pre-activation validation checks on a jail configuration.
Validates that referenced filter and action files exist in ``filter.d/``
and ``action.d/``, that all regex patterns compile, and that declared log
paths exist on disk.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Name of the jail to validate.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
Raises:
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
_validate_jail_config_sync,
Path(config_dir),
name,
)
async def rollback_jail(
config_dir: str,
socket_path: str,
name: str,
start_cmd_parts: list[str],
) -> RollbackResponse:
"""Disable a bad jail config and restart the fail2ban daemon.
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
fail2ban is down — only a file write), then attempts to start the daemon
with *start_cmd_parts*. Waits up to 10 seconds for the socket to respond.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to disable.
start_cmd_parts: Argument list for the daemon start command, e.g.
``["fail2ban-client", "start"]``.
Returns:
:class:`~app.models.config.RollbackResponse`.
Raises:
JailNameError: If *name* contains invalid characters.
ConfigWriteError: If writing the ``.local`` file fails.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
False,
{},
)
log.info("jail_rolled_back_disabled", jail=name)
# Attempt to start the daemon.
started = await _start_daemon(start_cmd_parts)
log.info("jail_rollback_start_attempted", jail=name, start_ok=started)
# Wait for the socket to come back.
fail2ban_running = await _wait_for_fail2ban(
socket_path, max_wait_seconds=10.0, poll_interval=2.0
)
active_jails = 0
if fail2ban_running:
names = await _get_active_jail_names(socket_path)
active_jails = len(names)
if fail2ban_running:
log.info("jail_rollback_success", jail=name, active_jails=active_jails)
return RollbackResponse(
jail_name=name,
disabled=True,
fail2ban_running=True,
active_jails=active_jails,
message=(
f"Jail {name!r} disabled and fail2ban restarted successfully "
f"with {active_jails} active jail(s)."
),
)
log.warning("jail_rollback_fail2ban_still_down", jail=name)
return RollbackResponse(
jail_name=name,
disabled=True,
fail2ban_running=False,
active_jails=0,
message=(
f"Jail {name!r} was disabled but fail2ban did not come back online. "
"Check the fail2ban log for additional errors."
),
)
# ---------------------------------------------------------------------------
# Filter discovery helpers (Task 2.1)
# ---------------------------------------------------------------------------

View File

@@ -26,6 +26,7 @@ if TYPE_CHECKING:
from app.models.config import (
AddLogPathRequest,
BantimeEscalation,
Fail2BanLogResponse,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfig,
@@ -39,6 +40,7 @@ from app.models.config import (
MapColorThresholdsUpdate,
RegexTestRequest,
RegexTestResponse,
ServiceStatusResponse,
)
from app.services import setup_service
from app.utils.fail2ban_client import Fail2BanClient
@@ -754,3 +756,174 @@ async def update_map_color_thresholds(
threshold_medium=update.threshold_medium,
threshold_low=update.threshold_low,
)
# ---------------------------------------------------------------------------
# fail2ban log file reader
# ---------------------------------------------------------------------------
# Log targets that are not file paths — log viewing is unavailable for these.
_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
{"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"}
)
# Only allow reading log files under these base directories (security).
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
def _count_file_lines(file_path: str) -> int:
"""Count the total number of lines in *file_path* synchronously.
Uses a memory-efficient buffered read to avoid loading the whole file.
Args:
file_path: Absolute path to the file.
Returns:
Total number of lines in the file.
"""
count = 0
with open(file_path, "rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
count += chunk.count(b"\n")
return count
async def read_fail2ban_log(
socket_path: str,
lines: int,
filter_text: str | None = None,
) -> Fail2BanLogResponse:
"""Read the tail of the fail2ban daemon log file.
Queries the fail2ban socket for the current log target and log level,
validates that the target is a readable file, then returns the last
*lines* entries optionally filtered by *filter_text*.
Security: the resolved log path is rejected unless it starts with one of
the paths in :data:`_SAFE_LOG_PREFIXES`, preventing path traversal.
Args:
socket_path: Path to the fail2ban Unix domain socket.
lines: Number of lines to return from the tail of the file (12000).
filter_text: Optional plain-text substring — only matching lines are
returned. Applied server-side; does not affect *total_lines*.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
ConfigOperationError: When the log target is not a file, when the
resolved path is outside the allowed directories, or when the
file cannot be read.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
)
log_level = str(log_level_raw or "INFO").upper()
log_target = str(log_target_raw or "STDOUT")
# Reject non-file targets up front.
if log_target.upper() in _NON_FILE_LOG_TARGETS:
raise ConfigOperationError(
f"fail2ban is logging to {log_target!r}. "
"File-based log viewing is only available when fail2ban logs to a file path."
)
# Resolve and validate (security: no path traversal outside safe dirs).
try:
resolved = Path(log_target).resolve()
except (ValueError, OSError) as exc:
raise ConfigOperationError(
f"Cannot resolve log target path {log_target!r}: {exc}"
) from exc
resolved_str = str(resolved)
if not any(resolved_str.startswith(safe) for safe in _SAFE_LOG_PREFIXES):
raise ConfigOperationError(
f"Log path {resolved_str!r} is outside the allowed directory. "
"Only paths under /var/log or /config/log are permitted."
)
if not resolved.is_file():
raise ConfigOperationError(f"Log file not found: {resolved_str!r}")
loop = asyncio.get_event_loop()
total_lines, raw_lines = await asyncio.gather(
loop.run_in_executor(None, _count_file_lines, resolved_str),
loop.run_in_executor(None, _read_tail_lines, resolved_str, lines),
)
filtered = (
[ln for ln in raw_lines if filter_text in ln]
if filter_text
else raw_lines
)
log.info(
"fail2ban_log_read",
log_path=resolved_str,
lines_requested=lines,
lines_returned=len(filtered),
filter_active=filter_text is not None,
)
return Fail2BanLogResponse(
log_path=resolved_str,
lines=filtered,
total_lines=total_lines,
log_level=log_level,
log_target=log_target,
)
async def get_service_status(socket_path: str) -> ServiceStatusResponse:
"""Return fail2ban service health status with log configuration.
Delegates to :func:`~app.services.health_service.probe` for the core
health snapshot and augments it with the current log-level and log-target
values from the socket.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
"""
from app.services.health_service import probe # lazy import avoids circular dep
server_status = await probe(socket_path)
if server_status.online:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
)
log_level = str(log_level_raw or "INFO").upper()
log_target = str(log_target_raw or "STDOUT")
else:
log_level = "UNKNOWN"
log_target = "UNKNOWN"
log.info(
"service_status_fetched",
online=server_status.online,
jail_count=server_status.active_jails,
)
return ServiceStatusResponse(
online=server_status.online,
version=server_status.version,
jail_count=server_status.active_jails,
total_bans=server_status.total_bans,
total_failures=server_status.total_failures,
log_level=log_level,
log_target=log_target,
)

View File

@@ -18,7 +18,7 @@ from typing import Any
import structlog
from app.models.ban import ActiveBan, ActiveBanListResponse
from app.models.ban import ActiveBan, ActiveBanListResponse, JailBannedIpsResponse
from app.models.config import BantimeEscalation
from app.models.jail import (
Jail,
@@ -862,6 +862,120 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
return None
# ---------------------------------------------------------------------------
# Public API — Jail-specific paginated bans
# ---------------------------------------------------------------------------
#: Maximum allowed page size for :func:`get_jail_banned_ips`.
_MAX_PAGE_SIZE: int = 100
async def get_jail_banned_ips(
socket_path: str,
jail_name: str,
page: int = 1,
page_size: int = 25,
search: str | None = None,
http_session: Any | None = None,
app_db: Any | None = None,
) -> JailBannedIpsResponse:
"""Return a paginated list of currently banned IPs for a single jail.
Fetches the full ban list from the fail2ban socket, applies an optional
substring search filter on the IP, paginates server-side, and geo-enriches
**only** the current page slice to stay within rate limits.
Args:
socket_path: Path to the fail2ban Unix domain socket.
jail_name: Name of the jail to query.
page: 1-based page number (default 1).
page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25).
search: Optional case-insensitive substring filter applied to IP addresses.
http_session: Optional shared :class:`aiohttp.ClientSession` for geo
enrichment via :func:`~app.services.geo_service.lookup_batch`.
app_db: Optional BanGUI application database for persistent geo cache.
Returns:
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
Raises:
JailNotFoundError: If *jail_name* is not a known active jail.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is
unreachable.
"""
from app.services import geo_service # noqa: PLC0415
# Clamp page_size to the allowed maximum.
page_size = min(page_size, _MAX_PAGE_SIZE)
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
# Verify the jail exists.
try:
_ok(await client.send(["status", jail_name, "short"]))
except ValueError as 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"]))
except (ValueError, TypeError):
raw_result = []
ban_list: list[str] = raw_result or []
# Parse all entries.
all_bans: list[ActiveBan] = []
for entry in ban_list:
ban = _parse_ban_entry(str(entry), jail_name)
if ban is not None:
all_bans.append(ban)
# Apply optional substring search filter (case-insensitive).
if search:
search_lower = search.lower()
all_bans = [b for b in all_bans if search_lower in b.ip.lower()]
total = len(all_bans)
# Slice the requested page.
start = (page - 1) * page_size
page_bans = all_bans[start : start + page_size]
# Geo-enrich only the page slice.
if http_session is not None and page_bans:
page_ips = [b.ip for b in page_bans]
try:
geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db)
except Exception: # noqa: BLE001
log.warning("jail_banned_ips_geo_failed", jail=jail_name)
geo_map = {}
enriched_page: list[ActiveBan] = []
for ban in page_bans:
geo = geo_map.get(ban.ip)
if geo is not None:
enriched_page.append(ban.model_copy(update={"country": geo.country_code}))
else:
enriched_page.append(ban)
page_bans = enriched_page
log.info(
"jail_banned_ips_fetched",
jail=jail_name,
total=total,
page=page,
page_size=page_size,
)
return JailBannedIpsResponse(
items=page_bans,
total=total,
page=page,
page_size=page_size,
)
async def _enrich_bans(
bans: list[ActiveBan],
geo_enricher: Any,

View File

@@ -4,14 +4,25 @@ Registers an APScheduler job that probes the fail2ban socket every 30 seconds
and stores the result on ``app.state.server_status``. The dashboard endpoint
reads from this cache, keeping HTTP responses fast and the daemon connection
decoupled from user-facing requests.
Crash detection (Task 3)
------------------------
When a jail activation is performed, the router stores a timestamp on
``app.state.last_activation`` (a ``dict`` with ``jail_name`` and ``at``
keys). If the health probe subsequently detects an online→offline transition
within 60 seconds of that activation, a
:class:`~app.models.config.PendingRecovery` record is written to
``app.state.pending_recovery`` so the UI can offer a one-click rollback.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any
import structlog
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.services import health_service
@@ -23,10 +34,19 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
#: How often the probe fires (seconds).
HEALTH_CHECK_INTERVAL: int = 30
#: Maximum seconds since an activation for a subsequent crash to be attributed
#: to that activation.
_ACTIVATION_CRASH_WINDOW: int = 60
async def _run_probe(app: Any) -> None:
"""Probe fail2ban and cache the result on *app.state*.
Detects online/offline state transitions. When fail2ban goes offline
within :data:`_ACTIVATION_CRASH_WINDOW` seconds of the last jail
activation, writes a :class:`~app.models.config.PendingRecovery` record to
``app.state.pending_recovery``.
This is the APScheduler job callback. It reads ``fail2ban_socket`` from
``app.state.settings``, runs the health probe, and writes the result to
``app.state.server_status``.
@@ -42,11 +62,54 @@ async def _run_probe(app: Any) -> None:
status: ServerStatus = await health_service.probe(socket_path)
app.state.server_status = status
now = datetime.datetime.now(tz=datetime.UTC)
# Log transitions between online and offline states.
if status.online and not prev_status.online:
log.info("fail2ban_came_online", version=status.version)
# Clear any pending recovery once fail2ban is back online.
existing: PendingRecovery | None = getattr(
app.state, "pending_recovery", None
)
if existing is not None and not existing.recovered:
app.state.pending_recovery = PendingRecovery(
jail_name=existing.jail_name,
activated_at=existing.activated_at,
detected_at=existing.detected_at,
recovered=True,
)
log.info(
"pending_recovery_resolved",
jail=existing.jail_name,
)
elif not status.online and prev_status.online:
log.warning("fail2ban_went_offline")
# Check whether this crash happened shortly after a jail activation.
last_activation: dict[str, Any] | None = getattr(
app.state, "last_activation", None
)
if last_activation is not None:
activated_at: datetime.datetime = last_activation["at"]
seconds_since = (now - activated_at).total_seconds()
if seconds_since <= _ACTIVATION_CRASH_WINDOW:
jail_name: str = last_activation["jail_name"]
# Only create a new record when there is not already an
# unresolved one for the same jail.
current: PendingRecovery | None = getattr(
app.state, "pending_recovery", None
)
if current is None or current.recovered:
app.state.pending_recovery = PendingRecovery(
jail_name=jail_name,
activated_at=activated_at,
detected_at=now,
)
log.warning(
"activation_crash_detected",
jail=jail_name,
seconds_since_activation=seconds_since,
)
log.debug(
"health_check_complete",
@@ -71,6 +134,10 @@ def register(app: FastAPI) -> None:
# first probe fires.
app.state.server_status = ServerStatus(online=False)
# Initialise activation tracking state.
app.state.last_activation = None
app.state.pending_recovery = None
app.state.scheduler.add_job(
_run_probe,
trigger="interval",

View File

@@ -13,12 +13,14 @@ from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.models.config import (
Fail2BanLogResponse,
FilterConfig,
GlobalConfigResponse,
JailConfig,
JailConfigListResponse,
JailConfigResponse,
RegexTestResponse,
ServiceStatusResponse,
)
# ---------------------------------------------------------------------------
@@ -740,6 +742,32 @@ class TestActivateJail:
).post("/api/config/jails/sshd/activate", json={})
assert resp.status_code == 401
async def test_200_with_active_false_on_missing_logpath(self, config_client: AsyncClient) -> None:
"""POST .../activate returns 200 with active=False when the service blocks due to missing logpath."""
from app.models.config import JailActivationResponse
blocked_response = JailActivationResponse(
name="airsonic-auth",
active=False,
fail2ban_running=True,
validation_warnings=["logpath: log file '/var/log/airsonic/airsonic.log' not found"],
message="Jail 'airsonic-auth' cannot be activated: log file '/var/log/airsonic/airsonic.log' not found",
)
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(return_value=blocked_response),
):
resp = await config_client.post(
"/api/config/jails/airsonic-auth/activate", json={}
)
assert resp.status_code == 200
data = resp.json()
assert data["active"] is False
assert data["fail2ban_running"] is True
assert "cannot be activated" in data["message"]
assert len(data["validation_warnings"]) == 1
# ---------------------------------------------------------------------------
# POST /api/config/jails/{name}/deactivate
@@ -819,6 +847,30 @@ class TestDeactivateJail:
).post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 401
async def test_deactivate_triggers_health_probe(self, config_client: AsyncClient) -> None:
"""POST .../deactivate triggers an immediate health probe after success."""
from app.models.config import JailActivationResponse
mock_response = JailActivationResponse(
name="sshd",
active=False,
message="Jail 'sshd' deactivated successfully.",
)
with (
patch(
"app.routers.config.config_file_service.deactivate_jail",
AsyncMock(return_value=mock_response),
),
patch(
"app.routers.config._run_probe",
AsyncMock(),
) as mock_probe,
):
resp = await config_client.post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 200
mock_probe.assert_awaited_once()
# ---------------------------------------------------------------------------
# GET /api/config/filters
@@ -1711,3 +1763,378 @@ class TestRemoveActionFromJailRouter:
).delete("/api/config/jails/sshd/action/iptables")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config/fail2ban-log
# ---------------------------------------------------------------------------
class TestGetFail2BanLog:
"""Tests for ``GET /api/config/fail2ban-log``."""
def _mock_log_response(self) -> Fail2BanLogResponse:
return Fail2BanLogResponse(
log_path="/var/log/fail2ban.log",
lines=["2025-01-01 INFO sshd Found 1.2.3.4", "2025-01-01 ERROR oops"],
total_lines=100,
log_level="INFO",
log_target="/var/log/fail2ban.log",
)
async def test_200_returns_log_response(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 200 with Fail2BanLogResponse."""
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(return_value=self._mock_log_response()),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 200
data = resp.json()
assert data["log_path"] == "/var/log/fail2ban.log"
assert isinstance(data["lines"], list)
assert data["total_lines"] == 100
assert data["log_level"] == "INFO"
async def test_200_passes_lines_query_param(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log passes the lines query param to the service."""
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(return_value=self._mock_log_response()),
) as mock_fn:
resp = await config_client.get("/api/config/fail2ban-log?lines=500")
assert resp.status_code == 200
_socket, lines_arg, _filter = mock_fn.call_args.args
assert lines_arg == 500
async def test_200_passes_filter_query_param(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log passes the filter query param to the service."""
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(return_value=self._mock_log_response()),
) as mock_fn:
resp = await config_client.get("/api/config/fail2ban-log?filter=ERROR")
assert resp.status_code == 200
_socket, _lines, filter_arg = mock_fn.call_args.args
assert filter_arg == "ERROR"
async def test_400_when_non_file_target(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 400 when log target is not a file."""
from app.services.config_service import ConfigOperationError
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(side_effect=ConfigOperationError("fail2ban is logging to 'STDOUT'")),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 400
async def test_400_when_path_traversal_detected(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 400 when the path is outside safe dirs."""
from app.services.config_service import ConfigOperationError
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(side_effect=ConfigOperationError("outside the allowed directory")),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 400
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 502 when fail2ban is down."""
from app.utils.fail2ban_client import Fail2BanConnectionError
with patch(
"app.routers.config.config_service.read_fail2ban_log",
AsyncMock(side_effect=Fail2BanConnectionError("socket error", "/tmp/f.sock")),
):
resp = await config_client.get("/api/config/fail2ban-log")
assert resp.status_code == 502
async def test_422_for_lines_exceeding_max(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log returns 422 for lines > 2000."""
resp = await config_client.get("/api/config/fail2ban-log?lines=9999")
assert resp.status_code == 422
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/fail2ban-log requires authentication."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/fail2ban-log")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/config/service-status
# ---------------------------------------------------------------------------
class TestGetServiceStatus:
"""Tests for ``GET /api/config/service-status``."""
def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
return ServiceStatusResponse(
online=online,
version="1.0.0" if online else None,
jail_count=2 if online else 0,
total_bans=10 if online else 0,
total_failures=3 if online else 0,
log_level="INFO" if online else "UNKNOWN",
log_target="/var/log/fail2ban.log" if online else "UNKNOWN",
)
async def test_200_when_online(self, config_client: AsyncClient) -> None:
"""GET /api/config/service-status returns 200 with full status when online."""
with patch(
"app.routers.config.config_service.get_service_status",
AsyncMock(return_value=self._mock_status(online=True)),
):
resp = await config_client.get("/api/config/service-status")
assert resp.status_code == 200
data = resp.json()
assert data["online"] is True
assert data["jail_count"] == 2
assert data["log_level"] == "INFO"
async def test_200_when_offline(self, config_client: AsyncClient) -> None:
"""GET /api/config/service-status returns 200 with offline=False when daemon is down."""
with patch(
"app.routers.config.config_service.get_service_status",
AsyncMock(return_value=self._mock_status(online=False)),
):
resp = await config_client.get("/api/config/service-status")
assert resp.status_code == 200
data = resp.json()
assert data["online"] is False
assert data["log_level"] == "UNKNOWN"
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/service-status requires authentication."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/service-status")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Task 3 endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestValidateJailEndpoint:
"""Tests for ``POST /api/config/jails/{name}/validate``."""
async def test_200_valid_config(self, config_client: AsyncClient) -> None:
"""Returns 200 with valid=True when the jail config has no issues."""
from app.models.config import JailValidationResult
mock_result = JailValidationResult(
jail_name="sshd", valid=True, issues=[]
)
with patch(
"app.routers.config.config_file_service.validate_jail_config",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/validate")
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert data["jail_name"] == "sshd"
assert data["issues"] == []
async def test_200_invalid_config(self, config_client: AsyncClient) -> None:
"""Returns 200 with valid=False and issues when there are errors."""
from app.models.config import JailValidationIssue, JailValidationResult
issue = JailValidationIssue(field="filter", message="Filter file not found: filter.d/bad.conf (or .local)")
mock_result = JailValidationResult(
jail_name="sshd", valid=False, issues=[issue]
)
with patch(
"app.routers.config.config_file_service.validate_jail_config",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/validate")
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert len(data["issues"]) == 1
assert data["issues"][0]["field"] == "filter"
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/bad-name/validate returns 400 on JailNameError."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.validate_jail_config",
AsyncMock(side_effect=JailNameError("bad name")),
):
resp = await config_client.post("/api/config/jails/bad-name/validate")
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/validate returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/validate")
assert resp.status_code == 401
@pytest.mark.asyncio
class TestPendingRecovery:
"""Tests for ``GET /api/config/pending-recovery``."""
async def test_returns_null_when_no_pending_recovery(
self, config_client: AsyncClient
) -> None:
"""Returns null body (204-like 200) when pending_recovery is not set."""
app = config_client._transport.app # type: ignore[attr-defined]
app.state.pending_recovery = None
resp = await config_client.get("/api/config/pending-recovery")
assert resp.status_code == 200
assert resp.json() is None
async def test_returns_record_when_set(self, config_client: AsyncClient) -> None:
"""Returns the PendingRecovery model when one is stored on app.state."""
import datetime
from app.models.config import PendingRecovery
now = datetime.datetime.now(tz=datetime.UTC)
record = PendingRecovery(
jail_name="sshd",
activated_at=now - datetime.timedelta(seconds=20),
detected_at=now,
)
app = config_client._transport.app # type: ignore[attr-defined]
app.state.pending_recovery = record
resp = await config_client.get("/api/config/pending-recovery")
assert resp.status_code == 200
data = resp.json()
assert data["jail_name"] == "sshd"
assert data["recovered"] is False
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/pending-recovery returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/pending-recovery")
assert resp.status_code == 401
@pytest.mark.asyncio
class TestRollbackEndpoint:
"""Tests for ``POST /api/config/jails/{name}/rollback``."""
async def test_200_success_clears_pending_recovery(
self, config_client: AsyncClient
) -> None:
"""A successful rollback returns 200 and clears app.state.pending_recovery."""
import datetime
from app.models.config import PendingRecovery, RollbackResponse
# Set up a pending recovery record on the app.
app = config_client._transport.app # type: ignore[attr-defined]
now = datetime.datetime.now(tz=datetime.UTC)
app.state.pending_recovery = PendingRecovery(
jail_name="sshd",
activated_at=now - datetime.timedelta(seconds=10),
detected_at=now,
)
mock_result = RollbackResponse(
jail_name="sshd",
disabled=True,
fail2ban_running=True,
active_jails=0,
message="Jail 'sshd' disabled and fail2ban restarted.",
)
with patch(
"app.routers.config.config_file_service.rollback_jail",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/rollback")
assert resp.status_code == 200
data = resp.json()
assert data["disabled"] is True
assert data["fail2ban_running"] is True
# Successful rollback must clear the pending record.
assert app.state.pending_recovery is None
async def test_200_fail_preserves_pending_recovery(
self, config_client: AsyncClient
) -> None:
"""When fail2ban is still down after rollback, pending_recovery is retained."""
import datetime
from app.models.config import PendingRecovery, RollbackResponse
app = config_client._transport.app # type: ignore[attr-defined]
now = datetime.datetime.now(tz=datetime.UTC)
record = PendingRecovery(
jail_name="sshd",
activated_at=now - datetime.timedelta(seconds=10),
detected_at=now,
)
app.state.pending_recovery = record
mock_result = RollbackResponse(
jail_name="sshd",
disabled=True,
fail2ban_running=False,
active_jails=0,
message="fail2ban did not come back online.",
)
with patch(
"app.routers.config.config_file_service.rollback_jail",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/rollback")
assert resp.status_code == 200
data = resp.json()
assert data["fail2ban_running"] is False
# Pending record should NOT be cleared when rollback didn't fully succeed.
assert app.state.pending_recovery is not None
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/bad/rollback returns 400 on JailNameError."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.rollback_jail",
AsyncMock(side_effect=JailNameError("bad")),
):
resp = await config_client.post("/api/config/jails/bad/rollback")
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/rollback returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/rollback")
assert resp.status_code == 401

View File

@@ -327,41 +327,54 @@ class TestCreateFilterFile:
# ---------------------------------------------------------------------------
# GET /api/config/actions (smoke test — same logic as filters)
# Note: GET /api/config/actions is handled by config.router (registered first);
# file_config.router's "/actions" endpoint is shadowed by it.
# ---------------------------------------------------------------------------
class TestListActionFiles:
async def test_200_returns_files(self, file_config_client: AsyncClient) -> None:
action_entry = ConfFileEntry(name="iptables", filename="iptables.conf")
resp_data = ConfFilesResponse(files=[action_entry], total=1)
from app.models.config import ActionListResponse
mock_action = ActionConfig(
name="iptables",
filename="iptables.conf",
)
resp_data = ActionListResponse(actions=[mock_action], total=1)
with patch(
"app.routers.file_config.file_config_service.list_action_files",
"app.routers.config.config_file_service.list_actions",
AsyncMock(return_value=resp_data),
):
resp = await file_config_client.get("/api/config/actions")
assert resp.status_code == 200
assert resp.json()["files"][0]["filename"] == "iptables.conf"
assert resp.json()["actions"][0]["name"] == "iptables"
# ---------------------------------------------------------------------------
# POST /api/config/actions
# Note: POST /api/config/actions is also handled by config.router.
# ---------------------------------------------------------------------------
class TestCreateActionFile:
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
created = ActionConfig(
name="myaction",
filename="myaction.local",
actionban="echo ban <ip>",
)
with patch(
"app.routers.file_config.file_config_service.create_action_file",
AsyncMock(return_value="myaction.conf"),
"app.routers.config.config_file_service.create_action",
AsyncMock(return_value=created),
):
resp = await file_config_client.post(
"/api/config/actions",
json={"name": "myaction", "content": "[Definition]\n"},
json={"name": "myaction", "actionban": "echo ban <ip>"},
)
assert resp.status_code == 201
assert resp.json()["filename"] == "myaction.conf"
assert resp.json()["name"] == "myaction"
# ---------------------------------------------------------------------------

View File

@@ -13,10 +13,11 @@ async def test_health_check_returns_200(client: AsyncClient) -> None:
@pytest.mark.asyncio
async def test_health_check_returns_ok_status(client: AsyncClient) -> None:
"""``GET /api/health`` must return ``{"status": "ok"}``."""
"""``GET /api/health`` must contain ``status: ok`` and a ``fail2ban`` field."""
response = await client.get("/api/health")
data: dict[str, str] = response.json()
assert data == {"status": "ok"}
assert data["status"] == "ok"
assert data["fail2ban"] in ("online", "offline")
@pytest.mark.asyncio

View File

@@ -788,3 +788,146 @@ class TestFail2BanConnectionErrors:
resp = await jails_client.post("/api/jails/sshd/reload")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/jails/{name}/banned
# ---------------------------------------------------------------------------
class TestGetJailBannedIps:
"""Tests for ``GET /api/jails/{name}/banned``."""
def _mock_response(
self,
*,
items: list[dict] | None = None,
total: int = 2,
page: int = 1,
page_size: int = 25,
) -> "JailBannedIpsResponse": # type: ignore[name-defined]
from app.models.ban import ActiveBan, JailBannedIpsResponse
ban_items = (
[
ActiveBan(
ip=item.get("ip", "1.2.3.4"),
jail="sshd",
banned_at=item.get("banned_at", "2025-01-01T10:00:00+00:00"),
expires_at=item.get("expires_at", "2025-01-01T10:10:00+00:00"),
ban_count=1,
country=item.get("country", None),
)
for item in (items or [{"ip": "1.2.3.4"}, {"ip": "5.6.7.8"}])
]
)
return JailBannedIpsResponse(
items=ban_items, total=total, page=page, page_size=page_size
)
async def test_200_returns_paginated_bans(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned returns 200 with a JailBannedIpsResponse."""
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(return_value=self._mock_response()),
):
resp = await jails_client.get("/api/jails/sshd/banned")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert data["total"] == 2
async def test_200_with_search_parameter(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?search=1.2.3 passes search to service."""
mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1))
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3")
assert resp.status_code == 200
_args, call_kwargs = mock_fn.call_args
assert call_kwargs.get("search") == "1.2.3"
async def test_200_with_page_and_page_size(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page=2&page_size=10 passes params to service."""
mock_fn = AsyncMock(
return_value=self._mock_response(page=2, page_size=10, total=0, items=[])
)
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10")
assert resp.status_code == 200
_args, call_kwargs = mock_fn.call_args
assert call_kwargs.get("page") == 2
assert call_kwargs.get("page_size") == 10
async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page=0 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page=0")
assert resp.status_code == 400
async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page_size=200 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page_size=200")
assert resp.status_code == 400
async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page_size=0 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page_size=0")
assert resp.status_code == 400
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/ghost/banned returns 404 when jail is unknown."""
from app.services.jail_service import JailNotFoundError
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.get("/api/jails/ghost/banned")
assert resp.status_code == 404
async def test_502_when_fail2ban_unreachable(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned returns 502 when fail2ban is unreachable."""
from app.utils.fail2ban_client import Fail2BanConnectionError
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(
side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")
),
):
resp = await jails_client.get("/api/jails/sshd/banned")
assert resp.status_code == 502
async def test_response_items_have_expected_fields(
self, jails_client: AsyncClient
) -> None:
"""Response items contain ip, jail, banned_at, expires_at, ban_count, country."""
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(return_value=self._mock_response()),
):
resp = await jails_client.get("/api/jails/sshd/banned")
item = resp.json()["items"][0]
assert "ip" in item
assert "jail" in item
assert "banned_at" in item
assert "expires_at" in item
assert "ban_count" in item
assert "country" in item
async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned returns 401 without a session cookie."""
resp = await AsyncClient(
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/jails/sshd/banned")
assert resp.status_code == 401

View File

@@ -434,7 +434,7 @@ class TestListInactiveJails:
class TestActivateJail:
async def test_activates_known_inactive_jail(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
from app.models.config import ActivateJailRequest, JailValidationResult
req = ActivateJailRequest()
with (
@@ -443,6 +443,14 @@ class TestActivateJail:
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(jail_name="apache-auth", valid=True),
),
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
@@ -488,15 +496,23 @@ class TestActivateJail:
async def test_writes_overrides_to_local_file(self, tmp_path: Path) -> None:
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
from app.models.config import ActivateJailRequest, JailValidationResult
req = ActivateJailRequest(bantime="2h", maxretry=3)
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
new=AsyncMock(side_effect=[set(), set()]),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(jail_name="apache-auth", valid=True),
),
):
mock_js.reload_all = AsyncMock()
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
@@ -2504,7 +2520,7 @@ class TestActivateJailReloadArgs:
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
from app.models.config import ActivateJailRequest, JailValidationResult
req = ActivateJailRequest()
with (
@@ -2513,6 +2529,14 @@ class TestActivateJailReloadArgs:
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(jail_name="apache-auth", valid=True),
),
):
mock_js.reload_all = AsyncMock()
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
@@ -2526,7 +2550,7 @@ class TestActivateJailReloadArgs:
) -> 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
from app.models.config import ActivateJailRequest, JailValidationResult
req = ActivateJailRequest()
with (
@@ -2535,6 +2559,14 @@ class TestActivateJailReloadArgs:
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(jail_name="apache-auth", valid=True),
),
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(
@@ -2554,16 +2586,25 @@ class TestActivateJailReloadArgs:
start the jail even though the reload command succeeded.
"""
_write(tmp_path / "jail.conf", JAIL_CONF)
from app.models.config import ActivateJailRequest
from app.models.config import ActivateJailRequest, JailValidationResult
req = ActivateJailRequest()
# Pre-reload: jail not running. Post-reload: still not running (boot failed).
# fail2ban is up (probe succeeds) but the jail didn't appear.
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,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(jail_name="apache-auth", valid=True),
),
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(
@@ -2600,3 +2641,309 @@ class TestDeactivateJailReloadArgs:
"/fake.sock", exclude_jails=["sshd"]
)
# ---------------------------------------------------------------------------
# _validate_jail_config_sync (Task 3)
# ---------------------------------------------------------------------------
from app.services.config_file_service import ( # noqa: E402 (added after block)
_validate_jail_config_sync,
_extract_filter_base_name,
_extract_action_base_name,
validate_jail_config,
rollback_jail,
)
class TestExtractFilterBaseName:
def test_plain_name(self) -> None:
assert _extract_filter_base_name("sshd") == "sshd"
def test_strips_mode_suffix(self) -> None:
assert _extract_filter_base_name("sshd[mode=aggressive]") == "sshd"
def test_strips_whitespace(self) -> None:
assert _extract_filter_base_name(" nginx ") == "nginx"
class TestExtractActionBaseName:
def test_plain_name(self) -> None:
assert _extract_action_base_name("iptables-multiport") == "iptables-multiport"
def test_strips_option_suffix(self) -> None:
assert _extract_action_base_name("iptables[name=SSH]") == "iptables"
def test_returns_none_for_variable_interpolation(self) -> None:
assert _extract_action_base_name("%(action_)s") is None
def test_returns_none_for_dollar_variable(self) -> None:
assert _extract_action_base_name("${action}") is None
class TestValidateJailConfigSync:
"""Tests for _validate_jail_config_sync — the sync validation core."""
def _setup_config(self, config_dir: Path, jail_cfg: str) -> None:
"""Write a minimal fail2ban directory layout with *jail_cfg* content."""
_write(config_dir / "jail.d" / "test.conf", jail_cfg)
def test_valid_config_no_issues(self, tmp_path: Path) -> None:
"""A jail whose filter exists and has a valid regex should pass."""
# Create a real filter file so the existence check passes.
filter_d = tmp_path / "filter.d"
filter_d.mkdir(parents=True, exist_ok=True)
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = Host .* <HOST>\n")
self._setup_config(
tmp_path,
"[sshd]\nenabled = false\nfilter = sshd\nlogpath = /no/such/log\n",
)
result = _validate_jail_config_sync(tmp_path, "sshd")
# logpath advisory warning is OK; no blocking errors expected.
blocking = [i for i in result.issues if i.field != "logpath"]
assert blocking == [], blocking
def test_missing_filter_reported(self, tmp_path: Path) -> None:
"""A jail whose filter file does not exist must report a filter issue."""
self._setup_config(
tmp_path,
"[bad-jail]\nenabled = false\nfilter = nonexistent-filter\n",
)
result = _validate_jail_config_sync(tmp_path, "bad-jail")
assert not result.valid
fields = [i.field for i in result.issues]
assert "filter" in fields
def test_bad_failregex_reported(self, tmp_path: Path) -> None:
"""A jail with an un-compilable failregex must report a failregex issue."""
self._setup_config(
tmp_path,
"[broken]\nenabled = false\nfailregex = [invalid regex(\n",
)
result = _validate_jail_config_sync(tmp_path, "broken")
assert not result.valid
fields = [i.field for i in result.issues]
assert "failregex" in fields
def test_missing_log_path_is_advisory(self, tmp_path: Path) -> None:
"""A missing log path should be reported in the logpath field."""
self._setup_config(
tmp_path,
"[myjail]\nenabled = false\nlogpath = /no/such/path.log\n",
)
result = _validate_jail_config_sync(tmp_path, "myjail")
fields = [i.field for i in result.issues]
assert "logpath" in fields
def test_missing_action_reported(self, tmp_path: Path) -> None:
"""A jail referencing a non-existent action file must report an action issue."""
self._setup_config(
tmp_path,
"[myjail]\nenabled = false\naction = nonexistent-action\n",
)
result = _validate_jail_config_sync(tmp_path, "myjail")
fields = [i.field for i in result.issues]
assert "action" in fields
def test_unknown_jail_name(self, tmp_path: Path) -> None:
"""Requesting validation for a jail not in any config returns an invalid result."""
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
result = _validate_jail_config_sync(tmp_path, "ghost")
assert not result.valid
assert any(i.field == "name" for i in result.issues)
def test_variable_action_not_flagged(self, tmp_path: Path) -> None:
"""An action like ``%(action_)s`` should not be checked for file existence."""
self._setup_config(
tmp_path,
"[myjail]\nenabled = false\naction = %(action_)s\n",
)
result = _validate_jail_config_sync(tmp_path, "myjail")
# Ensure no action file-missing error (the variable expression is skipped).
action_errors = [i for i in result.issues if i.field == "action"]
assert action_errors == []
@pytest.mark.asyncio
class TestValidateJailConfigAsync:
"""Tests for the public async wrapper validate_jail_config."""
async def test_returns_jail_validation_result(self, tmp_path: Path) -> None:
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
_write(
tmp_path / "jail.d" / "test.conf",
"[testjail]\nenabled = false\n",
)
result = await validate_jail_config(str(tmp_path), "testjail")
assert result.jail_name == "testjail"
async def test_rejects_unsafe_name(self, tmp_path: Path) -> None:
with pytest.raises(JailNameError):
await validate_jail_config(str(tmp_path), "../evil")
@pytest.mark.asyncio
class TestRollbackJail:
"""Tests for rollback_jail (Task 3)."""
async def test_rollback_success(self, tmp_path: Path) -> None:
"""When fail2ban comes back online, rollback returns fail2ban_running=True."""
_write(tmp_path / "jail.d" / "sshd.conf", "[sshd]\nenabled = true\n")
with (
patch(
"app.services.config_file_service._start_daemon",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._wait_for_fail2ban",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
):
result = await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
assert result.disabled is True
assert result.fail2ban_running is True
assert result.jail_name == "sshd"
# .local file must have enabled=false
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
assert "enabled = false" in local.read_text()
async def test_rollback_fail2ban_still_down(self, tmp_path: Path) -> None:
"""When fail2ban does not come back, rollback returns fail2ban_running=False."""
_write(tmp_path / "jail.d" / "sshd.conf", "[sshd]\nenabled = true\n")
with (
patch(
"app.services.config_file_service._start_daemon",
new=AsyncMock(return_value=False),
),
patch(
"app.services.config_file_service._wait_for_fail2ban",
new=AsyncMock(return_value=False),
),
):
result = await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
assert result.fail2ban_running is False
assert result.disabled is True
async def test_rollback_rejects_unsafe_name(self, tmp_path: Path) -> None:
with pytest.raises(JailNameError):
await rollback_jail(
str(tmp_path), "/fake.sock", "../evil", ["fail2ban-client", "start"]
)
# ---------------------------------------------------------------------------
# activate_jail — blocking on missing filter / logpath (Task 5)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestActivateJailBlocking:
"""activate_jail must refuse to proceed when validation finds critical issues."""
async def test_activate_jail_blocked_when_logpath_missing(self, tmp_path: Path) -> None:
"""activate_jail returns active=False if _validate_jail_config_sync reports a missing logpath."""
from app.models.config import ActivateJailRequest, JailValidationIssue, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
req = ActivateJailRequest()
missing_issue = JailValidationIssue(field="logpath", message="log file '/var/log/missing.log' not found")
validation = JailValidationResult(jail_name="apache-auth", valid=False, issues=[missing_issue])
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=validation,
),
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 result.fail2ban_running is True
assert "cannot be activated" in result.message
mock_js.reload_all.assert_not_awaited()
async def test_activate_jail_blocked_when_filter_missing(self, tmp_path: Path) -> None:
"""activate_jail returns active=False if a filter file is missing."""
from app.models.config import ActivateJailRequest, JailValidationIssue, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
req = ActivateJailRequest()
filter_issue = JailValidationIssue(field="filter", message="filter file 'sshd.conf' not found")
validation = JailValidationResult(jail_name="sshd", valid=False, issues=[filter_issue])
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=validation,
),
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", "sshd", req)
assert result.active is False
assert result.fail2ban_running is True
assert "cannot be activated" in result.message
mock_js.reload_all.assert_not_awaited()
async def test_activate_jail_proceeds_when_only_regex_warnings(self, tmp_path: Path) -> None:
"""activate_jail proceeds normally when only non-blocking (failregex) warnings exist."""
from app.models.config import ActivateJailRequest, JailValidationIssue, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
req = ActivateJailRequest()
advisory_issue = JailValidationIssue(field="failregex", message="no failregex defined")
# valid=True but with a non-blocking advisory issue
validation = JailValidationResult(jail_name="apache-auth", valid=True, issues=[advisory_issue])
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=validation,
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
):
mock_js.reload_all = AsyncMock()
result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
assert result.active is True
mock_js.reload_all.assert_awaited_once()

View File

@@ -604,3 +604,145 @@ class TestPreviewLog:
result = await config_service.preview_log(req)
assert result.total_lines <= 50
# ---------------------------------------------------------------------------
# read_fail2ban_log
# ---------------------------------------------------------------------------
class TestReadFail2BanLog:
"""Tests for :func:`config_service.read_fail2ban_log`."""
def _patch_client(self, log_level: str = "INFO", log_target: str = "/var/log/fail2ban.log") -> Any:
"""Build a patched Fail2BanClient that returns *log_level* and *log_target*."""
async def _send(command: list[Any]) -> Any:
key = "|".join(str(c) for c in command)
if key == "get|loglevel":
return (0, log_level)
if key == "get|logtarget":
return (0, log_target)
return (0, None)
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
return patch("app.services.config_service.Fail2BanClient", _FakeClient)
async def test_returns_log_lines_from_file(self, tmp_path: Any) -> None:
"""read_fail2ban_log returns lines from the file and counts totals."""
log_file = tmp_path / "fail2ban.log"
log_file.write_text("line1\nline2\nline3\n")
log_dir = str(tmp_path)
# Patch _SAFE_LOG_PREFIXES to allow tmp_path
with self._patch_client(log_target=str(log_file)), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)):
result = await config_service.read_fail2ban_log(_SOCKET, 200)
assert result.log_path == str(log_file.resolve())
assert result.total_lines >= 3
assert any("line1" in ln for ln in result.lines)
assert result.log_level == "INFO"
async def test_filter_narrows_returned_lines(self, tmp_path: Any) -> None:
"""read_fail2ban_log filters lines by substring."""
log_file = tmp_path / "fail2ban.log"
log_file.write_text("INFO sshd Found 1.2.3.4\nERROR something else\nINFO sshd Found 5.6.7.8\n")
log_dir = str(tmp_path)
with self._patch_client(log_target=str(log_file)), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)):
result = await config_service.read_fail2ban_log(_SOCKET, 200, "Found")
assert all("Found" in ln for ln in result.lines)
assert result.total_lines >= 3 # total is unfiltered
async def test_non_file_target_raises_operation_error(self) -> None:
"""read_fail2ban_log raises ConfigOperationError for STDOUT target."""
with self._patch_client(log_target="STDOUT"), \
pytest.raises(config_service.ConfigOperationError, match="STDOUT"):
await config_service.read_fail2ban_log(_SOCKET, 200)
async def test_syslog_target_raises_operation_error(self) -> None:
"""read_fail2ban_log raises ConfigOperationError for SYSLOG target."""
with self._patch_client(log_target="SYSLOG"), \
pytest.raises(config_service.ConfigOperationError, match="SYSLOG"):
await config_service.read_fail2ban_log(_SOCKET, 200)
async def test_path_outside_safe_dir_raises_operation_error(self, tmp_path: Any) -> None:
"""read_fail2ban_log rejects a log_target outside allowed directories."""
log_file = tmp_path / "secret.log"
log_file.write_text("secret data\n")
# Allow only /var/log — tmp_path is deliberately not in the safe list.
with self._patch_client(log_target=str(log_file)), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", ("/var/log",)), \
pytest.raises(config_service.ConfigOperationError, match="outside the allowed"):
await config_service.read_fail2ban_log(_SOCKET, 200)
async def test_missing_log_file_raises_operation_error(self, tmp_path: Any) -> None:
"""read_fail2ban_log raises ConfigOperationError when the file does not exist."""
missing = str(tmp_path / "nonexistent.log")
log_dir = str(tmp_path)
with self._patch_client(log_target=missing), \
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)), \
pytest.raises(config_service.ConfigOperationError, match="not found"):
await config_service.read_fail2ban_log(_SOCKET, 200)
# ---------------------------------------------------------------------------
# get_service_status
# ---------------------------------------------------------------------------
class TestGetServiceStatus:
"""Tests for :func:`config_service.get_service_status`."""
async def test_online_status_includes_log_config(self) -> None:
"""get_service_status returns correct fields when fail2ban is online."""
from app.models.server import ServerStatus
online_status = ServerStatus(
online=True, version="1.0.0", active_jails=2, total_bans=5, total_failures=3
)
async def _send(command: list[Any]) -> Any:
key = "|".join(str(c) for c in command)
if key == "get|loglevel":
return (0, "DEBUG")
if key == "get|logtarget":
return (0, "/var/log/fail2ban.log")
return (0, None)
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(side_effect=_send)
with patch("app.services.config_service.Fail2BanClient", _FakeClient), \
patch("app.services.health_service.probe", AsyncMock(return_value=online_status)):
result = await config_service.get_service_status(_SOCKET)
assert result.online is True
assert result.version == "1.0.0"
assert result.jail_count == 2
assert result.total_bans == 5
assert result.total_failures == 3
assert result.log_level == "DEBUG"
assert result.log_target == "/var/log/fail2ban.log"
async def test_offline_status_returns_unknown_log_fields(self) -> None:
"""get_service_status returns 'UNKNOWN' log fields when fail2ban is offline."""
from app.models.server import ServerStatus
offline_status = ServerStatus(online=False)
with patch("app.services.health_service.probe", AsyncMock(return_value=offline_status)):
result = await config_service.get_service_status(_SOCKET)
assert result.online is False
assert result.jail_count == 0
assert result.log_level == "UNKNOWN"
assert result.log_target == "UNKNOWN"

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from app.models.ban import ActiveBanListResponse
from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse
from app.models.jail import JailDetailResponse, JailListResponse
from app.services import jail_service
from app.services.jail_service import JailNotFoundError, JailOperationError
@@ -700,3 +700,201 @@ class TestUnbanAllIps:
pytest.raises(Fail2BanConnectionError),
):
await jail_service.unban_all_ips(_SOCKET)
# ---------------------------------------------------------------------------
# get_jail_banned_ips
# ---------------------------------------------------------------------------
#: A raw ban entry string in the format produced by fail2ban --with-time.
_BAN_ENTRY_1 = "1.2.3.4\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"
_BAN_ENTRY_2 = "5.6.7.8\t2025-01-01 11:00:00 + 600 = 2025-01-01 11:10:00"
_BAN_ENTRY_3 = "9.10.11.12\t2025-01-01 12:00:00 + 600 = 2025-01-01 12:10:00"
def _banned_ips_responses(jail: str = "sshd", entries: list[str] | None = None) -> dict[str, Any]:
"""Build mock responses for get_jail_banned_ips tests."""
if entries is None:
entries = [_BAN_ENTRY_1, _BAN_ENTRY_2]
return {
f"status|{jail}|short": _make_short_status(),
f"get|{jail}|banip|--with-time": (0, entries),
}
class TestGetJailBannedIps:
"""Unit tests for :func:`~app.services.jail_service.get_jail_banned_ips`."""
async def test_returns_jail_banned_ips_response(self) -> None:
"""get_jail_banned_ips returns a JailBannedIpsResponse."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert isinstance(result, JailBannedIpsResponse)
async def test_total_reflects_all_entries(self) -> None:
"""total equals the number of parsed ban entries."""
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert result.total == 3
async def test_page_1_returns_first_n_items(self) -> None:
"""page=1 with page_size=2 returns the first two entries."""
with _patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=1, page_size=2
)
assert len(result.items) == 2
assert result.items[0].ip == "1.2.3.4"
assert result.items[1].ip == "5.6.7.8"
assert result.total == 3
async def test_page_2_returns_remaining_items(self) -> None:
"""page=2 with page_size=2 returns the third entry."""
with _patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=2, page_size=2
)
assert len(result.items) == 1
assert result.items[0].ip == "9.10.11.12"
async def test_page_beyond_last_returns_empty_items(self) -> None:
"""Requesting a page past the end returns an empty items list."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=99, page_size=25
)
assert result.items == []
assert result.total == 2
async def test_search_filter_narrows_results(self) -> None:
"""search parameter filters entries by IP substring."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="1.2.3"
)
assert result.total == 1
assert result.items[0].ip == "1.2.3.4"
async def test_search_filter_case_insensitive(self) -> None:
"""search filter is case-insensitive."""
entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"]
with _patch_client(_banned_ips_responses(entries=entries)):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="192.168"
)
assert result.total == 1
async def test_search_no_match_returns_empty(self) -> None:
"""search that matches nothing returns empty items and total=0."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="999.999"
)
assert result.total == 0
assert result.items == []
async def test_empty_ban_list_returns_total_zero(self) -> None:
"""get_jail_banned_ips handles an empty ban list gracefully."""
responses = {
"status|sshd|short": _make_short_status(),
"get|sshd|banip|--with-time": (0, []),
}
with _patch_client(responses):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert result.total == 0
assert result.items == []
async def test_page_size_clamped_to_max(self) -> None:
"""page_size values above 100 are silently clamped to 100."""
entries = [f"10.0.0.{i}\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" for i in range(1, 101)]
responses = {
"status|sshd|short": _make_short_status(),
"get|sshd|banip|--with-time": (0, entries),
}
with _patch_client(responses):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=1, page_size=200
)
assert len(result.items) <= 100
async def test_geo_enrichment_called_for_page_slice_only(self) -> None:
"""Geo enrichment is requested only for IPs in the current page."""
from unittest.mock import MagicMock
from app.services import geo_service
http_session = MagicMock()
geo_enrichment_ips: list[list[str]] = []
async def _mock_lookup_batch(
ips: list[str], _session: Any, **_kw: Any
) -> dict[str, Any]:
geo_enrichment_ips.append(list(ips))
return {}
with (
_patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
),
patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch),
):
result = await jail_service.get_jail_banned_ips(
_SOCKET,
"sshd",
page=1,
page_size=2,
http_session=http_session,
)
# Only the 2-IP page slice should be passed to geo enrichment.
assert len(geo_enrichment_ips) == 1
assert len(geo_enrichment_ips[0]) == 2
assert result.total == 3
async def test_unknown_jail_raises_jail_not_found_error(self) -> None:
"""get_jail_banned_ips raises JailNotFoundError for unknown jail."""
responses = {
"status|ghost|short": (0, pytest.raises), # will be overridden
}
# Simulate fail2ban returning an "unknown jail" error.
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
pass
async def send(self, command: list[Any]) -> Any:
raise ValueError("Unknown jail: ghost")
with (
patch("app.services.jail_service.Fail2BanClient", _FakeClient),
pytest.raises(JailNotFoundError),
):
await jail_service.get_jail_banned_ips(_SOCKET, "ghost")
async def test_connection_error_propagates(self) -> None:
"""get_jail_banned_ips propagates Fail2BanConnectionError."""
class _FailClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
)
with (
patch("app.services.jail_service.Fail2BanClient", _FailClient),
pytest.raises(Fail2BanConnectionError),
):
await jail_service.get_jail_banned_ips(_SOCKET, "sshd")

View File

@@ -8,10 +8,12 @@ the scheduler and primes the initial status.
from __future__ import annotations
import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.tasks.health_check import HEALTH_CHECK_INTERVAL, _run_probe, register
@@ -33,6 +35,8 @@ def _make_app(prev_online: bool = False) -> MagicMock:
app.state.settings.fail2ban_socket = "/var/run/fail2ban/fail2ban.sock"
app.state.server_status = ServerStatus(online=prev_online)
app.state.scheduler = MagicMock()
app.state.last_activation = None
app.state.pending_recovery = None
return app
@@ -236,3 +240,111 @@ class TestRegister:
_, kwargs = app.state.scheduler.add_job.call_args
assert kwargs["kwargs"] == {"app": app}
def test_register_initialises_last_activation_none(self) -> None:
"""``register`` must set ``app.state.last_activation = None``."""
app = _make_app()
register(app)
assert app.state.last_activation is None
def test_register_initialises_pending_recovery_none(self) -> None:
"""``register`` must set ``app.state.pending_recovery = None``."""
app = _make_app()
register(app)
assert app.state.pending_recovery is None
# ---------------------------------------------------------------------------
# Crash detection (Task 3)
# ---------------------------------------------------------------------------
class TestCrashDetection:
"""Tests for activation-crash detection in _run_probe."""
@pytest.mark.asyncio
async def test_crash_within_window_creates_pending_recovery(self) -> None:
"""An online→offline transition within 60 s of activation must set pending_recovery."""
app = _make_app(prev_online=True)
now = datetime.datetime.now(tz=datetime.timezone.utc)
app.state.last_activation = {
"jail_name": "sshd",
"at": now - datetime.timedelta(seconds=10),
}
app.state.pending_recovery = None
offline_status = ServerStatus(online=False)
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=offline_status,
):
await _run_probe(app)
assert app.state.pending_recovery is not None
assert isinstance(app.state.pending_recovery, PendingRecovery)
assert app.state.pending_recovery.jail_name == "sshd"
assert app.state.pending_recovery.recovered is False
@pytest.mark.asyncio
async def test_crash_outside_window_does_not_create_pending_recovery(self) -> None:
"""A crash more than 60 s after activation must NOT set pending_recovery."""
app = _make_app(prev_online=True)
app.state.last_activation = {
"jail_name": "sshd",
"at": datetime.datetime.now(tz=datetime.timezone.utc)
- datetime.timedelta(seconds=120),
}
app.state.pending_recovery = None
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=ServerStatus(online=False),
):
await _run_probe(app)
assert app.state.pending_recovery is None
@pytest.mark.asyncio
async def test_came_online_marks_pending_recovery_resolved(self) -> None:
"""An offline→online transition must mark an existing pending_recovery as recovered."""
app = _make_app(prev_online=False)
activated_at = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(seconds=30)
detected_at = datetime.datetime.now(tz=datetime.timezone.utc)
app.state.pending_recovery = PendingRecovery(
jail_name="sshd",
activated_at=activated_at,
detected_at=detected_at,
recovered=False,
)
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=ServerStatus(online=True),
):
await _run_probe(app)
assert app.state.pending_recovery.recovered is True
@pytest.mark.asyncio
async def test_crash_without_recent_activation_does_nothing(self) -> None:
"""A crash when last_activation is None must not create a pending_recovery."""
app = _make_app(prev_online=True)
app.state.last_activation = None
app.state.pending_recovery = None
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=ServerStatus(online=False),
):
await _run_probe(app)
assert app.state.pending_recovery is None

View File

@@ -18,6 +18,7 @@ import type {
ConfFileCreateRequest,
ConfFilesResponse,
ConfFileUpdateRequest,
Fail2BanLogResponse,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
@@ -33,16 +34,20 @@ import type {
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
JailValidationResult,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
PendingRecovery,
RegexTestRequest,
RegexTestResponse,
RollbackResponse,
ServerSettingsResponse,
ServerSettingsUpdate,
JailFileConfig,
JailFileConfigUpdate,
ServiceStatusResponse,
} from "../types/config";
// ---------------------------------------------------------------------------
@@ -541,3 +546,63 @@ export async function deactivateJail(
undefined
);
}
// ---------------------------------------------------------------------------
// fail2ban log viewer (Task 2)
// ---------------------------------------------------------------------------
/**
* Fetch the tail of the fail2ban daemon log file.
*
* @param lines - Number of tail lines to return (12000, default 200).
* @param filter - Optional plain-text substring; only matching lines returned.
*/
export async function fetchFail2BanLog(
lines?: number,
filter?: string,
): Promise<Fail2BanLogResponse> {
const params = new URLSearchParams();
if (lines !== undefined) params.set("lines", String(lines));
if (filter !== undefined && filter !== "") params.set("filter", filter);
const query = params.toString() ? `?${params.toString()}` : "";
return get<Fail2BanLogResponse>(`${ENDPOINTS.configFail2BanLog}${query}`);
}
/** Fetch fail2ban service health status with current log configuration. */
export async function fetchServiceStatus(): Promise<ServiceStatusResponse> {
return get<ServiceStatusResponse>(ENDPOINTS.configServiceStatus);
}
// ---------------------------------------------------------------------------
// Jail config recovery (Task 3)
// ---------------------------------------------------------------------------
/**
* Run pre-activation validation on a jail's config.
*
* Checks that referenced filter/action files exist, that all regex patterns
* compile, and that log paths are accessible on the server.
*/
export async function validateJailConfig(
name: string,
): Promise<JailValidationResult> {
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
}
/**
* Fetch the pending crash-recovery record, if any.
*
* Returns null when fail2ban is healthy and no recovery is pending.
*/
export async function fetchPendingRecovery(): Promise<PendingRecovery | null> {
return get<PendingRecovery | null>(ENDPOINTS.configPendingRecovery);
}
/**
* Rollback a bad jail — disables it and attempts to restart fail2ban.
*
* @param name - Name of the jail to disable.
*/
export async function rollbackJail(name: string): Promise<RollbackResponse> {
return post<RollbackResponse>(ENDPOINTS.configJailRollback(name), undefined);
}

View File

@@ -38,12 +38,14 @@ export const ENDPOINTS = {
// -------------------------------------------------------------------------
jails: "/jails",
jail: (name: string): string => `/jails/${encodeURIComponent(name)}`,
jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`,
jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`,
jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`,
jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`,
jailReload: (name: string): string => `/jails/${encodeURIComponent(name)}/reload`,
jailsReloadAll: "/jails/reload-all",
jailIgnoreIp: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreip`,
jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`,
// -------------------------------------------------------------------------
// Bans
@@ -69,6 +71,11 @@ export const ENDPOINTS = {
`/config/jails/${encodeURIComponent(name)}/activate`,
configJailDeactivate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/deactivate`,
configJailValidate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/validate`,
configJailRollback: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/rollback`,
configPendingRecovery: "/config/pending-recovery" as string,
configGlobal: "/config/global",
configReload: "/config/reload",
configRegexTest: "/config/regex-test",
@@ -100,6 +107,10 @@ export const ENDPOINTS = {
configActionParsed: (name: string): string =>
`/config/actions/${encodeURIComponent(name)}/parsed`,
// fail2ban log viewer (Task 2)
configFail2BanLog: "/config/fail2ban-log",
configServiceStatus: "/config/service-status",
// -------------------------------------------------------------------------
// Server settings
// -------------------------------------------------------------------------

View File

@@ -10,6 +10,7 @@ import { ENDPOINTS } from "./endpoints";
import type {
ActiveBanListResponse,
IpLookupResponse,
JailBannedIpsResponse,
JailCommandResponse,
JailDetailResponse,
JailListResponse,
@@ -148,6 +149,24 @@ export async function delIgnoreIp(
return del<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
}
/**
* Enable or disable the `ignoreself` flag for a jail.
*
* When enabled, fail2ban automatically adds the server's own IP addresses to
* the ignore list so the host can never ban itself.
*
* @param name - Jail name.
* @param on - `true` to enable, `false` to disable.
* @returns A {@link JailCommandResponse} confirming the change.
* @throws {ApiError} On non-2xx responses.
*/
export async function toggleIgnoreSelf(
name: string,
on: boolean,
): Promise<JailCommandResponse> {
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreSelf(name), on);
}
// ---------------------------------------------------------------------------
// Ban / unban
// ---------------------------------------------------------------------------
@@ -224,3 +243,37 @@ export async function unbanAllBans(): Promise<UnbanAllResponse> {
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
}
// ---------------------------------------------------------------------------
// Jail-specific paginated bans
// ---------------------------------------------------------------------------
/**
* Fetch the currently banned IPs for a specific jail, paginated.
*
* Only the requested page is geo-enriched on the backend, so this call
* remains fast even when a jail has thousands of banned IPs.
*
* @param jailName - Jail name (e.g. `"sshd"`).
* @param page - 1-based page number (default 1).
* @param pageSize - Items per page; max 100 (default 25).
* @param search - Optional case-insensitive IP substring filter.
* @returns A {@link JailBannedIpsResponse} with paginated ban entries.
* @throws {ApiError} On non-2xx responses (404 if jail unknown, 502 if fail2ban down).
*/
export async function fetchJailBannedIps(
jailName: string,
page = 1,
pageSize = 25,
search?: string,
): Promise<JailBannedIpsResponse> {
const params: Record<string, string> = {
page: String(page),
page_size: String(pageSize),
};
if (search !== undefined && search !== "") {
params.search = search;
}
const query = new URLSearchParams(params).toString();
return get<JailBannedIpsResponse>(`${ENDPOINTS.jailBanned(jailName)}?${query}`);
}

View File

@@ -4,6 +4,7 @@
*/
import {
Cell,
Legend,
Pie,
PieChart,
@@ -177,7 +178,12 @@ export function TopCountriesPieChart({
return `${name}: ${(percent * 100).toFixed(0)}%`;
}}
labelLine={false}
/>
>
{slices.map((slice, index) => (
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Cell key={index} fill={slice.fill} />
))}
</Pie>
<Tooltip content={PieTooltip} />
<Legend formatter={legendFormatter} />
</PieChart>

View File

@@ -174,8 +174,8 @@ describe("ConfigPage — Add Log Path", () => {
renderConfigPage();
await openSshdAccordion(user);
// Existing path from fixture
expect(screen.getByText("/var/log/auth.log")).toBeInTheDocument();
// Existing path from fixture — rendered as an <input> value
expect(screen.getByDisplayValue("/var/log/auth.log")).toBeInTheDocument();
// Add-log-path input placeholder
expect(
@@ -222,8 +222,8 @@ describe("ConfigPage — Add Log Path", () => {
});
});
// New path should appear in the list
expect(screen.getByText("/var/log/nginx/access.log")).toBeInTheDocument();
// New path should appear in the list as an <input> value
expect(screen.getByDisplayValue("/var/log/nginx/access.log")).toBeInTheDocument();
// Input should be cleared
expect(input).toHaveValue("");

View File

@@ -0,0 +1,136 @@
/**
* RecoveryBanner — full-width warning shown when fail2ban stopped responding
* shortly after a jail was activated (indicating the new jail config may be
* invalid).
*
* Polls ``GET /api/config/pending-recovery`` every 10 seconds and renders a
* dismissible ``MessageBar`` when an unresolved crash record is present.
* The "Disable & Restart" button calls the rollback endpoint to disable the
* offending jail and attempt to restart fail2ban.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
MessageBar,
MessageBarActions,
MessageBarBody,
MessageBarTitle,
Spinner,
tokens,
} from "@fluentui/react-components";
import { useNavigate } from "react-router-dom";
import { fetchPendingRecovery, rollbackJail } from "../../api/config";
import type { PendingRecovery } from "../../types/config";
const POLL_INTERVAL_MS = 10_000;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Recovery banner that polls for pending crash-recovery records.
*
* Mount this once at the layout level so it is visible across all pages
* while a recovery is pending.
*
* @returns A MessageBar element, or null when nothing is pending.
*/
export function RecoveryBanner(): React.JSX.Element | null {
const navigate = useNavigate();
const [pending, setPending] = useState<PendingRecovery | null>(null);
const [rolling, setRolling] = useState(false);
const [rollbackError, setRollbackError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const poll = useCallback((): void => {
fetchPendingRecovery()
.then((record) => {
// Hide the banner once fail2ban has recovered on its own.
if (record?.recovered) {
setPending(null);
} else {
setPending(record);
}
})
.catch(() => { /* ignore network errors — will retry */ });
}, []);
// Start polling on mount.
useEffect(() => {
poll();
timerRef.current = setInterval(poll, POLL_INTERVAL_MS);
return (): void => {
if (timerRef.current !== null) clearInterval(timerRef.current);
};
}, [poll]);
const handleRollback = useCallback((): void => {
if (!pending || rolling) return;
setRolling(true);
setRollbackError(null);
rollbackJail(pending.jail_name)
.then(() => {
setPending(null);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setRollbackError(msg);
})
.finally(() => {
setRolling(false);
});
}, [pending, rolling]);
const handleViewDetails = useCallback((): void => {
navigate("/config");
}, [navigate]);
if (pending === null) return null;
return (
<div
style={{
flexShrink: 0,
paddingLeft: tokens.spacingHorizontalM,
paddingRight: tokens.spacingHorizontalM,
paddingTop: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalXS,
}}
role="alert"
>
<MessageBar intent="error">
<MessageBarBody>
<MessageBarTitle>fail2ban Stopped After Jail Activation</MessageBarTitle>
fail2ban stopped responding after activating jail{" "}
<strong>{pending.jail_name}</strong>. The jail&apos;s configuration
may be invalid.
{rollbackError && (
<div style={{ marginTop: tokens.spacingVerticalXS, color: tokens.colorStatusDangerForeground1 }}>
Rollback failed: {rollbackError}
</div>
)}
</MessageBarBody>
<MessageBarActions>
<Button
appearance="primary"
size="small"
icon={rolling ? <Spinner size="tiny" /> : undefined}
disabled={rolling}
onClick={handleRollback}
>
{rolling ? "Disabling…" : "Disable & Restart"}
</Button>
<Button
appearance="secondary"
size="small"
onClick={handleViewDetails}
>
View Logs
</Button>
</MessageBarActions>
</MessageBar>
</div>
);
}

View File

@@ -0,0 +1,141 @@
/**
* Tests for RecoveryBanner (Task 3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter } from "react-router-dom";
import { RecoveryBanner } from "../RecoveryBanner";
import type { PendingRecovery } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchPendingRecovery: vi.fn(),
rollbackJail: vi.fn(),
}));
import { fetchPendingRecovery, rollbackJail } from "../../../api/config";
const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery);
const mockRollbackJail = vi.mocked(rollbackJail);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const pendingRecord: PendingRecovery = {
jail_name: "sshd",
activated_at: "2024-01-01T12:00:00Z",
detected_at: "2024-01-01T12:00:30Z",
recovered: false,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderBanner() {
return render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter>
<RecoveryBanner />
</MemoryRouter>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("RecoveryBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders nothing when pending recovery is null", async () => {
mockFetchPendingRecovery.mockResolvedValue(null);
renderBanner();
await waitFor(() => {
expect(mockFetchPendingRecovery).toHaveBeenCalled();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("renders warning when there is an unresolved pending recovery", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
renderBanner();
await waitFor(() => {
expect(screen.getByText(/fail2ban stopped responding after activating jail/i)).toBeInTheDocument();
});
expect(screen.getByText(/sshd/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /view logs/i })).toBeInTheDocument();
});
it("hides the banner when recovery is marked as recovered", async () => {
const recoveredRecord: PendingRecovery = { ...pendingRecord, recovered: true };
mockFetchPendingRecovery.mockResolvedValue(recoveredRecord);
renderBanner();
await waitFor(() => {
expect(mockFetchPendingRecovery).toHaveBeenCalled();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("calls rollbackJail and hides banner on successful rollback", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
mockRollbackJail.mockResolvedValue({
jail_name: "sshd",
disabled: true,
fail2ban_running: true,
active_jails: 0,
message: "Rolled back.",
});
renderBanner();
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
});
await userEvent.click(
screen.getByRole("button", { name: /disable & restart/i }),
);
expect(mockRollbackJail).toHaveBeenCalledWith("sshd");
});
it("shows rollback error when rollbackJail fails", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
mockRollbackJail.mockRejectedValue(new Error("Connection refused"));
renderBanner();
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
});
await userEvent.click(
screen.getByRole("button", { name: /disable & restart/i }),
);
await waitFor(() => {
expect(screen.getByText(/rollback failed/i)).toBeInTheDocument();
});
});
});

View File

@@ -4,9 +4,16 @@
* Displays the jail name and provides optional override fields for bantime,
* findtime, maxretry, port and logpath. Calls the activate endpoint on
* confirmation and propagates the result via callbacks.
*
* Task 3 additions:
* - Runs pre-activation validation when the dialog opens and displays any
* warnings or blocking errors before the user confirms.
* - Extended spinner text during the post-reload probe phase.
* - Calls `onCrashDetected` when the activation response signals that
* fail2ban stopped responding after the reload.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
@@ -23,8 +30,12 @@ import {
Text,
tokens,
} from "@fluentui/react-components";
import { activateJail } from "../../api/config";
import type { ActivateJailRequest, InactiveJail } from "../../types/config";
import { activateJail, validateJailConfig } from "../../api/config";
import type {
ActivateJailRequest,
InactiveJail,
JailValidationIssue,
} from "../../types/config";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
@@ -40,6 +51,11 @@ export interface ActivateJailDialogProps {
onClose: () => void;
/** Called after the jail has been successfully activated. */
onActivated: () => void;
/**
* Called when fail2ban stopped responding after the jail was activated.
* The recovery banner will surface this to the user.
*/
onCrashDetected?: () => void;
}
// ---------------------------------------------------------------------------
@@ -60,6 +76,7 @@ export function ActivateJailDialog({
open,
onClose,
onActivated,
onCrashDetected,
}: ActivateJailDialogProps): React.JSX.Element {
const [bantime, setBantime] = useState("");
const [findtime, setFindtime] = useState("");
@@ -69,6 +86,11 @@ export function ActivateJailDialog({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Pre-activation validation state
const [validating, setValidating] = useState(false);
const [validationIssues, setValidationIssues] = useState<JailValidationIssue[]>([]);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
const resetForm = (): void => {
setBantime("");
setFindtime("");
@@ -76,8 +98,31 @@ export function ActivateJailDialog({
setPort("");
setLogpath("");
setError(null);
setValidationIssues([]);
setValidationWarnings([]);
};
// Run pre-validation whenever the dialog opens for a jail.
useEffect(() => {
if (!open || !jail) return;
setValidating(true);
setValidationIssues([]);
setValidationWarnings([]);
validateJailConfig(jail.name)
.then((result) => {
setValidationIssues(result.issues);
})
.catch(() => {
// Validation failure is non-blocking — proceed to allow the user to
// attempt activation and let the server decide.
})
.finally(() => {
setValidating(false);
});
}, [open, jail]);
const handleClose = (): void => {
if (submitting) return;
resetForm();
@@ -106,8 +151,21 @@ export function ActivateJailDialog({
setError(null);
activateJail(jail.name, overrides)
.then(() => {
.then((result) => {
if (!result.active) {
// Backend rejected the activation (e.g. missing logpath or filter).
// Show the server's message and keep the dialog open so the user
// can read the explanation without the dialog disappearing.
setError(result.message);
return;
}
if (result.validation_warnings.length > 0) {
setValidationWarnings(result.validation_warnings);
}
resetForm();
if (!result.fail2ban_running) {
onCrashDetected?.();
}
onActivated();
})
.catch((err: unknown) => {
@@ -126,6 +184,10 @@ export function ActivateJailDialog({
if (!jail) return <></>;
// All validation issues block activation — logpath errors are now critical.
const blockingIssues = validationIssues;
const advisoryIssues: JailValidationIssue[] = [];
return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
@@ -137,6 +199,60 @@ export function ActivateJailDialog({
<code>jail.d/{jail.name}.local</code> and reload fail2ban. The
jail will start monitoring immediately.
</Text>
{/* Pre-validation results */}
{validating && (
<div style={{ marginBottom: tokens.spacingVerticalS, display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
<Spinner size="tiny" />
<Text size={200}>Validating configuration</Text>
</div>
)}
{!validating && blockingIssues.length > 0 && (
<MessageBar
intent="error"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>
<strong>Configuration errors detected:</strong>
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
{blockingIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{!validating && advisoryIssues.length > 0 && (
<MessageBar
intent="warning"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>
<strong>Advisory warnings:</strong>
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
{advisoryIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{validationWarnings.length > 0 && (
<MessageBar
intent="warning"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>
<strong>Post-activation warnings:</strong>
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
{validationWarnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
<Text block weight="semibold" style={{ marginBottom: tokens.spacingVerticalS }}>
Override values (leave blank to use config defaults)
</Text>
@@ -227,10 +343,10 @@ export function ActivateJailDialog({
<Button
appearance="primary"
onClick={handleConfirm}
disabled={submitting}
disabled={submitting || validating || blockingIssues.length > 0}
icon={submitting ? <Spinner size="tiny" /> : undefined}
>
{submitting ? "Activating…" : "Activate"}
{submitting ? "Activating and verifying…" : "Activate"}
</Button>
</DialogActions>
</DialogBody>

View File

@@ -19,6 +19,7 @@ import {
Select,
Skeleton,
SkeletonItem,
Spinner,
Switch,
Text,
tokens,
@@ -38,6 +39,7 @@ import {
fetchInactiveJails,
fetchJailConfigFileContent,
updateJailConfigFile,
validateJailConfig,
} from "../../api/config";
import type {
AddLogPathRequest,
@@ -45,6 +47,8 @@ import type {
InactiveJail,
JailConfig,
JailConfigUpdate,
JailValidationIssue,
JailValidationResult,
} from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
@@ -99,6 +103,10 @@ interface JailConfigDetailProps {
readOnly?: boolean;
/** When provided (and readOnly=true) shows an Activate Jail button. */
onActivate?: () => void;
/** When provided (and readOnly=true) shows a Validate Config button. */
onValidate?: () => void;
/** Whether validation is currently running (shows spinner on Validate button). */
validating?: boolean;
}
/**
@@ -116,6 +124,8 @@ function JailConfigDetail({
onDeactivate,
readOnly = false,
onActivate,
onValidate,
validating = false,
}: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time));
@@ -563,15 +573,27 @@ function JailConfigDetail({
</div>
)}
{readOnly && onActivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
{readOnly && (onActivate !== undefined || onValidate !== undefined) && (
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
{onValidate !== undefined && (
<Button
appearance="secondary"
icon={validating ? <Spinner size="tiny" /> : undefined}
onClick={onValidate}
disabled={validating}
>
{validating ? "Validating…" : "Validate Config"}
</Button>
)}
{onActivate !== undefined && (
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
)}
</div>
)}
@@ -596,6 +618,8 @@ function JailConfigDetail({
interface InactiveJailDetailProps {
jail: InactiveJail;
onActivate: () => void;
/** Whether to show and call onCrashDetected on activation crash. */
onCrashDetected?: () => void;
}
/**
@@ -614,6 +638,22 @@ function InactiveJailDetail({
onActivate,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null);
const handleValidate = useCallback((): void => {
setValidating(true);
setValidationResult(null);
validateJailConfig(jail.name)
.then((result) => { setValidationResult(result); })
.catch(() => { /* validation call failed — ignore */ })
.finally(() => { setValidating(false); });
}, [jail.name]);
const blockingIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field !== "logpath") ?? [];
const advisoryIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field === "logpath") ?? [];
const jailConfig = useMemo<JailConfig>(
() => ({
@@ -648,11 +688,49 @@ function InactiveJailDetail({
<Field label="Source file" style={{ marginBottom: tokens.spacingVerticalM }}>
<Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field>
{/* Validation result panel */}
{validationResult !== null && (
<div style={{ marginBottom: tokens.spacingVerticalM }}>
{blockingIssues.length === 0 && advisoryIssues.length === 0 ? (
<MessageBar intent="success">
<MessageBarBody>Configuration is valid.</MessageBarBody>
</MessageBar>
) : null}
{blockingIssues.length > 0 && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalXS }}>
<MessageBarBody>
<strong>Errors:</strong>
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
{blockingIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{advisoryIssues.length > 0 && (
<MessageBar intent="warning">
<MessageBarBody>
<strong>Warnings:</strong>
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
{advisoryIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
</div>
)}
<JailConfigDetail
jail={jailConfig}
onSave={async () => { /* read-only — never called */ }}
readOnly
onActivate={onActivate}
onValidate={handleValidate}
validating={validating}
/>
</div>
);
@@ -668,7 +746,12 @@ function InactiveJailDetail({
*
* @returns JSX element.
*/
export function JailsTab(): React.JSX.Element {
export interface JailsTabProps {
/** Called when fail2ban stopped responding after a jail was activated. */
onCrashDetected?: () => void;
}
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
@@ -807,6 +890,7 @@ export function JailsTab(): React.JSX.Element {
<InactiveJailDetail
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
onCrashDetected={onCrashDetected}
/>
) : null}
</ConfigListDetail>
@@ -817,6 +901,7 @@ export function JailsTab(): React.JSX.Element {
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
onCrashDetected={onCrashDetected}
/>
<CreateJailDialog

View File

@@ -0,0 +1,518 @@
/**
* LogTab — fail2ban log viewer and service health panel.
*
* Renders two sections:
* 1. **Service Health panel** — shows online/offline state, version, active
* jail count, total bans, total failures, log level, and log target.
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
* toolbar controls for line count, substring filter, manual refresh, and
* optional auto-refresh. Log lines are color-coded by severity.
*/
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
Badge,
Button,
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
Switch,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import {
ArrowClockwise24Regular,
DocumentBulletList24Regular,
Filter24Regular,
} from "@fluentui/react-icons";
import { fetchFail2BanLog, fetchServiceStatus } from "../../api/config";
import { useConfigStyles } from "./configStyles";
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../types/config";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Available line-count options for the log tail. */
const LINE_COUNT_OPTIONS: number[] = [100, 200, 500, 1000];
/** Auto-refresh interval options in seconds. */
const AUTO_REFRESH_INTERVALS: { label: string; value: number }[] = [
{ label: "5 s", value: 5 },
{ label: "10 s", value: 10 },
{ label: "30 s", value: 30 },
];
/** Debounce delay for the filter input in milliseconds. */
const FILTER_DEBOUNCE_MS = 300;
/** Log targets that are not file paths — file-based viewing is unavailable. */
const NON_FILE_TARGETS = new Set(["STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"]);
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
healthGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
gap: tokens.spacingHorizontalM,
marginTop: tokens.spacingVerticalM,
},
statCard: {
backgroundColor: tokens.colorNeutralBackground3,
borderRadius: tokens.borderRadiusMedium,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalXS,
},
statLabel: {
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200,
},
statValue: {
fontWeight: tokens.fontWeightSemibold,
fontSize: tokens.fontSizeBase300,
},
metaRow: {
display: "flex",
gap: tokens.spacingHorizontalL,
marginTop: tokens.spacingVerticalS,
flexWrap: "wrap",
},
metaItem: {
display: "flex",
flexDirection: "column",
gap: "2px",
},
toolbar: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
flexWrap: "wrap",
marginBottom: tokens.spacingVerticalM,
},
filterInput: {
width: "200px",
},
logContainer: {
backgroundColor: tokens.colorNeutralBackground4,
borderRadius: tokens.borderRadiusMedium,
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
maxHeight: "560px",
overflowY: "auto",
fontFamily: "monospace",
fontSize: tokens.fontSizeBase200,
lineHeight: "1.6",
border: `1px solid ${tokens.colorNeutralStroke2}`,
},
logLineError: {
color: tokens.colorPaletteRedForeground1,
},
logLineWarning: {
color: tokens.colorPaletteYellowForeground1,
},
logLineDebug: {
color: tokens.colorNeutralForeground4,
},
logLineDefault: {
color: tokens.colorNeutralForeground1,
},
truncatedBanner: {
marginBottom: tokens.spacingVerticalS,
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase100,
},
emptyLog: {
color: tokens.colorNeutralForeground3,
fontStyle: "italic",
padding: tokens.spacingVerticalS,
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Determine the CSS class key for a log line based on its severity.
*
* fail2ban formats log lines as:
* ``2025-… fail2ban.filter [PID]: LEVEL message``
*
* @param line - A single log line string.
* @returns The severity key: "error" | "warning" | "debug" | "default".
*/
function detectSeverity(line: string): "error" | "warning" | "debug" | "default" {
const upper = line.toUpperCase();
if (upper.includes(" ERROR ") || upper.includes(" CRITICAL ")) return "error";
if (upper.includes(" WARNING ") || upper.includes(": WARNING") || upper.includes(" WARN ")) return "warning";
if (upper.includes(" DEBUG ")) return "debug";
return "default";
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Log tab component for the Configuration page.
*
* Shows fail2ban service health and a live log viewer with refresh controls.
*
* @returns JSX element.
*/
export function LogTab(): React.JSX.Element {
const configStyles = useConfigStyles();
const styles = useStyles();
// ---- data state ----------------------------------------------------------
const [status, setStatus] = useState<ServiceStatusResponse | null>(null);
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// ---- toolbar state -------------------------------------------------------
const [linesCount, setLinesCount] = useState<number>(200);
const [filterRaw, setFilterRaw] = useState<string>("");
const [filterValue, setFilterValue] = useState<string>("");
const [autoRefresh, setAutoRefresh] = useState(false);
const [refreshInterval, setRefreshInterval] = useState(10);
// ---- refs ----------------------------------------------------------------
const logContainerRef = useRef<HTMLDivElement>(null);
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ---- scroll helper -------------------------------------------------------
const scrollToBottom = useCallback((): void => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, []);
// ---- fetch logic ---------------------------------------------------------
const fetchData = useCallback(
async (showSpinner: boolean): Promise<void> => {
if (showSpinner) setIsRefreshing(true);
try {
// Use allSettled so a log-read failure doesn't hide the service status.
const [svcResult, logResult] = await Promise.allSettled([
fetchServiceStatus(),
fetchFail2BanLog(linesCount, filterValue || undefined),
]);
if (svcResult.status === "fulfilled") {
setStatus(svcResult.value);
} else {
setStatus(null);
}
if (logResult.status === "fulfilled") {
setLogData(logResult.value);
setError(null);
} else {
const reason: unknown = logResult.reason;
setError(reason instanceof Error ? reason.message : "Failed to load log data.");
}
} finally {
if (showSpinner) setIsRefreshing(false);
setLoading(false);
}
},
[linesCount, filterValue],
);
// ---- initial load --------------------------------------------------------
useEffect(() => {
setLoading(true);
void fetchData(false);
}, [fetchData]);
// ---- scroll to bottom when new log data arrives -------------------------
useEffect(() => {
if (logData) {
// Tiny timeout lets the DOM paint before scrolling.
const t = setTimeout(scrollToBottom, 50);
return (): void => { clearTimeout(t); };
}
}, [logData, scrollToBottom]);
// ---- auto-refresh interval ----------------------------------------------
useEffect(() => {
if (autoRefreshTimerRef.current) {
clearInterval(autoRefreshTimerRef.current);
autoRefreshTimerRef.current = null;
}
if (autoRefresh) {
autoRefreshTimerRef.current = setInterval(() => {
void fetchData(true);
}, refreshInterval * 1000);
}
return (): void => {
if (autoRefreshTimerRef.current) {
clearInterval(autoRefreshTimerRef.current);
}
};
}, [autoRefresh, refreshInterval, fetchData]);
// ---- filter debounce ----------------------------------------------------
const handleFilterChange = useCallback((value: string): void => {
setFilterRaw(value);
if (filterDebounceRef.current) clearTimeout(filterDebounceRef.current);
filterDebounceRef.current = setTimeout(() => {
setFilterValue(value);
}, FILTER_DEBOUNCE_MS);
}, []);
// ---- render helpers ------------------------------------------------------
const renderLogLine = (line: string, idx: number): React.JSX.Element => {
const severity = detectSeverity(line);
const className =
severity === "error"
? styles.logLineError
: severity === "warning"
? styles.logLineWarning
: severity === "debug"
? styles.logLineDebug
: styles.logLineDefault;
return (
<div key={idx} className={className}>
{line}
</div>
);
};
// ---- loading state -------------------------------------------------------
if (loading) {
return <Spinner label="Loading log viewer…" />;
}
// ---- error state ---------------------------------------------------------
if (error && !status && !logData) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
const isNonFileTarget =
logData?.log_target != null &&
NON_FILE_TARGETS.has(logData.log_target.toUpperCase());
const isTruncated =
logData != null && logData.total_lines > logData.lines.length;
return (
<div>
{/* ------------------------------------------------------------------ */}
{/* Service Health Panel */}
{/* ------------------------------------------------------------------ */}
<div className={configStyles.sectionCard}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
<DocumentBulletList24Regular />
<Text weight="semibold" size={400}>
Service Health
</Text>
{status?.online ? (
<Badge appearance="filled" color="success">
Running
</Badge>
) : (
<Badge appearance="filled" color="danger">
Offline
</Badge>
)}
</div>
{status && !status.online && (
<MessageBar intent="warning" style={{ marginTop: tokens.spacingVerticalM }}>
<MessageBarBody>
fail2ban is not running or unreachable. Check the server and socket
configuration.
</MessageBarBody>
</MessageBar>
)}
{status?.online && (
<>
<div className={styles.healthGrid}>
{status.version && (
<div className={styles.statCard}>
<Text className={styles.statLabel}>Version</Text>
<Text className={styles.statValue}>{status.version}</Text>
</div>
)}
<div className={styles.statCard}>
<Text className={styles.statLabel}>Active Jails</Text>
<Text className={styles.statValue}>{status.jail_count}</Text>
</div>
<div className={styles.statCard}>
<Text className={styles.statLabel}>Currently Banned</Text>
<Text className={styles.statValue}>{status.total_bans}</Text>
</div>
<div className={styles.statCard}>
<Text className={styles.statLabel}>Currently Failed</Text>
<Text className={styles.statValue}>{status.total_failures}</Text>
</div>
</div>
<div className={styles.metaRow}>
<div className={styles.metaItem}>
<Text className={styles.statLabel}>Log Level</Text>
<Text size={300}>{status.log_level}</Text>
</div>
<div className={styles.metaItem}>
<Text className={styles.statLabel}>Log Target</Text>
<Text size={300}>{status.log_target}</Text>
</div>
</div>
</>
)}
</div>
{/* ------------------------------------------------------------------ */}
{/* Log Viewer */}
{/* ------------------------------------------------------------------ */}
<div className={configStyles.sectionCard}>
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
<Text weight="semibold" size={400}>
Log Viewer
</Text>
{logData && (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
{logData.log_path}
</Text>
)}
</div>
{/* Non-file target info banner */}
{isNonFileTarget && (
<MessageBar intent="info" style={{ marginBottom: tokens.spacingVerticalM }}>
<MessageBarBody>
fail2ban is logging to <strong>{logData.log_target}</strong>.
File-based log viewing is not available.
</MessageBarBody>
</MessageBar>
)}
{/* Toolbar — only shown when log data is available */}
{!isNonFileTarget && (
<>
<div className={styles.toolbar}>
{/* Filter input */}
<Field label="Filter">
<Input
className={styles.filterInput}
type="text"
value={filterRaw}
contentBefore={<Filter24Regular />}
placeholder="Substring filter…"
onChange={(_e, d) => { handleFilterChange(d.value); }}
/>
</Field>
{/* Lines count selector */}
<Field label="Lines">
<Select
value={String(linesCount)}
onChange={(_e, d) => { setLinesCount(Number(d.value)); }}
>
{LINE_COUNT_OPTIONS.map((n) => (
<option key={n} value={String(n)}>
{n}
</option>
))}
</Select>
</Field>
{/* Manual refresh */}
<div style={{ alignSelf: "flex-end" }}>
<Button
icon={<ArrowClockwise24Regular />}
appearance="secondary"
onClick={() => void fetchData(true)}
disabled={isRefreshing}
>
{isRefreshing ? "Refreshing…" : "Refresh"}
</Button>
</div>
{/* Auto-refresh toggle */}
<div style={{ alignSelf: "flex-end" }}>
<Switch
label="Auto-refresh"
checked={autoRefresh}
onChange={(_e, d) => { setAutoRefresh(d.checked); }}
/>
</div>
{/* Auto-refresh interval selector */}
{autoRefresh && (
<Field label="Interval">
<Select
value={String(refreshInterval)}
onChange={(_e, d) => { setRefreshInterval(Number(d.value)); }}
>
{AUTO_REFRESH_INTERVALS.map((opt) => (
<option key={opt.value} value={String(opt.value)}>
{opt.label}
</option>
))}
</Select>
</Field>
)}
</div>
{/* Truncation notice */}
{isTruncated && (
<Text className={styles.truncatedBanner} block>
Showing last {logData.lines.length} of {logData.total_lines} lines.
Increase the line count or use the filter to narrow results.
</Text>
)}
{/* Log lines container */}
<div className={styles.logContainer} ref={logContainerRef}>
{isRefreshing && (
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<Spinner size="tiny" label="Refreshing…" />
</div>
)}
{logData && logData.lines.length === 0 ? (
<div className={styles.emptyLog}>
{filterValue
? `No lines match the filter "${filterValue}".`
: "No log entries found."}
</div>
) : (
logData?.lines.map((line, idx) => renderLogLine(line, idx))
)}
</div>
{/* General fetch error */}
{error && (
<MessageBar intent="error" style={{ marginTop: tokens.spacingVerticalM }}>
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,229 @@
/**
* Tests for ActivateJailDialog (Task 7).
*
* Covers:
* - "Activate" button is disabled when pre-validation returns blocking issues.
* - "Activate" button is enabled when validation passes.
* - Dialog stays open and shows an error when the backend returns active=false.
* - `onActivated` is called and dialog closes when backend returns active=true.
* - `onCrashDetected` is called when fail2ban_running is false after activation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ActivateJailDialog } from "../ActivateJailDialog";
import type { InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
activateJail: vi.fn(),
validateJailConfig: vi.fn(),
}));
import { activateJail, validateJailConfig } from "../../../api/config";
const mockActivateJail = vi.mocked(activateJail);
const mockValidateJailConfig = vi.mocked(validateJailConfig);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const baseJail: InactiveJail = {
name: "airsonic-auth",
filter: "airsonic-auth",
actions: ["iptables-multiport"],
port: "8080",
logpath: ["/var/log/airsonic/airsonic.log"],
bantime: "10m",
findtime: "10m",
maxretry: 5,
ban_time_seconds: 600,
find_time_seconds: 600,
log_encoding: "auto",
backend: "auto",
date_pattern: null,
use_dns: "warn",
prefregex: "",
fail_regex: ["Failed login.*from <HOST>"],
ignore_regex: [],
bantime_escalation: null,
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
enabled: false,
};
/** Successful activation response. */
const successResponse: JailActivationResponse = {
name: "airsonic-auth",
active: true,
message: "Jail activated successfully.",
fail2ban_running: true,
validation_warnings: [],
};
/** Response when backend blocks activation (e.g. missing logpath). */
const blockedResponse: JailActivationResponse = {
name: "airsonic-auth",
active: false,
message: "Jail 'airsonic-auth' cannot be activated: logpath does not exist.",
fail2ban_running: true,
validation_warnings: ["logpath: /var/log/airsonic/airsonic.log does not exist"],
};
/** Validation result with a logpath issue (should block the button). */
const validationWithLogpathIssue: JailValidationResult = {
jail_name: "airsonic-auth",
valid: false,
issues: [{ field: "logpath", message: "/var/log/airsonic/airsonic.log does not exist" }],
};
/** Validation result with no issues. */
const validationPassed: JailValidationResult = {
jail_name: "airsonic-auth",
valid: true,
issues: [],
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface DialogProps {
jail?: InactiveJail | null;
open?: boolean;
onClose?: () => void;
onActivated?: () => void;
onCrashDetected?: () => void;
}
function renderDialog({
jail = baseJail,
open = true,
onClose = vi.fn(),
onActivated = vi.fn(),
onCrashDetected = vi.fn(),
}: DialogProps = {}) {
return render(
<FluentProvider theme={webLightTheme}>
<ActivateJailDialog
jail={jail}
open={open}
onClose={onClose}
onActivated={onActivated}
onCrashDetected={onCrashDetected}
/>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ActivateJailDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("disables the Activate button when pre-validation returns blocking issues", async () => {
mockValidateJailConfig.mockResolvedValue(validationWithLogpathIssue);
renderDialog();
// Wait for validation to complete and the error message to appear.
await waitFor(() => {
expect(screen.getByText(/configuration errors detected/i)).toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
expect(activateBtn).toBeDisabled();
});
it("enables the Activate button when validation passes", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
renderDialog();
// Wait for validation spinner to disappear.
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
expect(activateBtn).not.toBeDisabled();
});
it("keeps the dialog open and shows an error when backend returns active=false", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
mockActivateJail.mockResolvedValue(blockedResponse);
const onActivated = vi.fn();
renderDialog({ onActivated });
// Wait for validation to finish.
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
await userEvent.click(activateBtn);
// The server's error message should appear.
await waitFor(() => {
expect(
screen.getByText(/cannot be activated/i),
).toBeInTheDocument();
});
// onActivated must NOT have been called.
expect(onActivated).not.toHaveBeenCalled();
});
it("calls onActivated when backend returns active=true", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
mockActivateJail.mockResolvedValue(successResponse);
const onActivated = vi.fn();
renderDialog({ onActivated });
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
await userEvent.click(activateBtn);
await waitFor(() => {
expect(onActivated).toHaveBeenCalledOnce();
});
});
it("calls onCrashDetected when fail2ban_running is false after activation", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
mockActivateJail.mockResolvedValue({
...successResponse,
fail2ban_running: false,
});
const onActivated = vi.fn();
const onCrashDetected = vi.fn();
renderDialog({ onActivated, onCrashDetected });
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
await userEvent.click(activateBtn);
await waitFor(() => {
expect(onCrashDetected).toHaveBeenCalledOnce();
});
expect(onActivated).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,189 @@
/**
* Tests for the LogTab component (Task 2).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { LogTab } from "../LogTab";
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchFail2BanLog: vi.fn(),
fetchServiceStatus: vi.fn(),
}));
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
const mockFetchLog = vi.mocked(fetchFail2BanLog);
const mockFetchStatus = vi.mocked(fetchServiceStatus);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const onlineStatus: ServiceStatusResponse = {
online: true,
version: "1.0.2",
jail_count: 3,
total_bans: 12,
total_failures: 5,
log_level: "INFO",
log_target: "/var/log/fail2ban.log",
};
const offlineStatus: ServiceStatusResponse = {
online: false,
version: null,
jail_count: 0,
total_bans: 0,
total_failures: 0,
log_level: "UNKNOWN",
log_target: "UNKNOWN",
};
const logResponse: Fail2BanLogResponse = {
log_path: "/var/log/fail2ban.log",
lines: [
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
"2025-01-01 12:00:01 WARNING sshd Too many failures",
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
],
total_lines: 1000,
log_level: "INFO",
log_target: "/var/log/fail2ban.log",
};
const nonFileLogResponse: Fail2BanLogResponse = {
...logResponse,
log_target: "STDOUT",
lines: [],
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTab() {
return render(
<FluentProvider theme={webLightTheme}>
<LogTab />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("LogTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while loading", () => {
// Never resolves during this test.
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
mockFetchLog.mockReturnValue(new Promise(() => undefined));
renderTab();
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
});
it("renders the health panel with Running badge when online", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
renderTab();
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
expect(screen.getByText("Running")).toBeInTheDocument();
expect(screen.getByText("1.0.2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
});
it("renders the Offline badge and warning when fail2ban is down", async () => {
mockFetchStatus.mockResolvedValue(offlineStatus);
mockFetchLog.mockRejectedValue(new Error("not running"));
renderTab();
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
expect(screen.getByText("Offline")).toBeInTheDocument();
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
});
it("renders log lines in the log viewer", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument();
});
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
});
it("shows a non-file target info banner when log_target is STDOUT", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(nonFileLogResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument();
});
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
expect(screen.queryByText(/Refresh/)).toBeNull();
});
it("shows empty state when no lines match the filter", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
renderTab();
await waitFor(() => {
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument();
});
});
it("shows truncation notice when total_lines > lines.length", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
renderTab();
await waitFor(() => {
expect(screen.getByText(/showing last/i)).toBeInTheDocument();
});
});
it("calls fetchFail2BanLog again on Refresh button click", async () => {
mockFetchStatus.mockResolvedValue(onlineStatus);
mockFetchLog.mockResolvedValue(logResponse);
const user = userEvent.setup();
renderTab();
await waitFor(() => { expect(screen.getByText(/Refresh/)).toBeInTheDocument(); });
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
await user.click(refreshBtn);
await waitFor(() => { expect(mockFetchLog).toHaveBeenCalledTimes(2); });
});
});

View File

@@ -34,6 +34,7 @@ export { GlobalTab } from "./GlobalTab";
export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab";
export { LogTab } from "./LogTab";
export { MapTab } from "./MapTab";
export { RawConfigSection } from "./RawConfigSection";
export type { RawConfigSectionProps } from "./RawConfigSection";

View File

@@ -0,0 +1,466 @@
/**
* `BannedIpsSection` component.
*
* Displays a paginated table of IPs currently banned in a specific fail2ban
* jail. Supports server-side search filtering (debounced), page navigation,
* page-size selection, and per-row unban actions.
*
* Only the current page is geo-enriched by the backend, so the component
* remains fast even when a jail contains thousands of banned IPs.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Dropdown,
Field,
Input,
MessageBar,
MessageBarBody,
Option,
Spinner,
Text,
Tooltip,
makeStyles,
tokens,
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import {
ArrowClockwiseRegular,
ChevronLeftRegular,
ChevronRightRegular,
DismissRegular,
SearchRegular,
} from "@fluentui/react-icons";
import { fetchJailBannedIps, unbanIp } from "../../api/jails";
import type { ActiveBan } from "../../types/jail";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Debounce delay in milliseconds for the search input. */
const SEARCH_DEBOUNCE_MS = 300;
/** Available page-size options. */
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
headerLeft: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
},
toolbar: {
display: "flex",
alignItems: "flex-end",
gap: tokens.spacingHorizontalS,
flexWrap: "wrap",
},
searchField: {
minWidth: "200px",
flexGrow: 1,
},
tableWrapper: {
overflowX: "auto",
},
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalS,
flexWrap: "wrap",
},
pageSizeWrapper: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for compact display.
*
* @param iso - ISO 8601 string or `null`.
* @returns A locale time string, or `"—"` when `null`.
*/
function fmtTime(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** A row item augmented with an `onUnban` callback for the row action. */
interface BanRow {
ban: ActiveBan;
onUnban: (ip: string) => void;
}
const columns: TableColumnDefinition<BanRow>[] = [
createTableColumn<BanRow>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: ({ ban }) => (
<Text
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
}}
>
{ban.ip}
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: ({ ban }) =>
ban.country ? (
<Text size={200}>{ban.country}</Text>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.banned_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "expires_at",
renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.expires_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: ({ ban, onUnban }) => (
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(ban.ip);
}}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
),
}),
];
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
/** Props for {@link BannedIpsSection}. */
export interface BannedIpsSectionProps {
/** The jail name whose banned IPs are displayed. */
jailName: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Paginated section showing currently banned IPs for a single jail.
*
* @param props - {@link BannedIpsSectionProps}
*/
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
const styles = useStyles();
const [items, setItems] = useState<ActiveBan[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<number>(25);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounce the search input so we don't spam the backend on every keystroke.
useEffect(() => {
if (debounceRef.current !== null) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout((): void => {
setDebouncedSearch(search);
setPage(1);
}, SEARCH_DEBOUNCE_MS);
return (): void => {
if (debounceRef.current !== null) clearTimeout(debounceRef.current);
};
}, [search]);
const load = useCallback(() => {
setLoading(true);
setError(null);
fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined)
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setError(msg);
})
.finally(() => {
setLoading(false);
});
}, [jailName, page, pageSize, debouncedSearch]);
useEffect(() => {
load();
}, [load]);
const handleUnban = (ip: string): void => {
setOpError(null);
unbanIp(ip, jailName)
.then(() => {
load();
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
};
const rows: BanRow[] = items.map((ban) => ({
ban,
onUnban: handleUnban,
}));
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
return (
<div className={styles.root}>
{/* Section header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<Text as="h2" size={500} weight="semibold">
Currently Banned IPs
</Text>
<Badge appearance="tint">{String(total)}</Badge>
</div>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={load}
aria-label="Refresh banned IPs"
/>
</div>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.searchField}>
<Field label="Search by IP">
<Input
aria-label="Search by IP"
contentBefore={<SearchRegular />}
placeholder="e.g. 192.168"
value={search}
onChange={(_, d) => {
setSearch(d.value);
}}
/>
</Field>
</div>
</div>
{/* Error bars */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{/* Table */}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading banned IPs…" />
</div>
) : items.length === 0 ? (
<div className={styles.centred}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
No IPs currently banned in this jail.
</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<DataGrid
items={rows}
columns={columns}
getRowId={(row: BanRow) => row.ban.ip}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<BanRow>>
{({ item, rowId }) => (
<DataGridRow<BanRow> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
{/* Pagination */}
{total > 0 && (
<div className={styles.pagination}>
<div className={styles.pageSizeWrapper}>
<Text size={200}>Rows per page:</Text>
<Dropdown
aria-label="Rows per page"
value={String(pageSize)}
selectedOptions={[String(pageSize)]}
onOptionSelect={(_, d) => {
const newSize = Number(d.optionValue);
if (!Number.isNaN(newSize)) {
setPageSize(newSize);
setPage(1);
}
}}
style={{ minWidth: "80px" }}
>
{PAGE_SIZE_OPTIONS.map((n) => (
<Option key={n} value={String(n)}>
{String(n)}
</Option>
))}
</Dropdown>
</div>
<Text size={200}>
{String((page - 1) * pageSize + 1)}
{String(Math.min(page * pageSize, total))} of {String(total)}
</Text>
<Button
size="small"
appearance="subtle"
icon={<ChevronLeftRegular />}
disabled={page <= 1}
onClick={() => {
setPage((p) => Math.max(1, p - 1));
}}
aria-label="Previous page"
/>
<Button
size="small"
appearance="subtle"
icon={<ChevronRightRegular />}
disabled={page >= totalPages}
onClick={() => {
setPage((p) => p + 1);
}}
aria-label="Next page"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,251 @@
/**
* Tests for the `BannedIpsSection` component.
*
* Verifies:
* - Renders the section header and total count badge.
* - Shows a spinner while loading.
* - Renders a table with IP rows on success.
* - Shows an empty-state message when there are no banned IPs.
* - Displays an error message bar when the API call fails.
* - Search input re-fetches with the search parameter after debounce.
* - Unban button calls `unbanIp` and refreshes the list.
* - Pagination buttons are shown and change the page.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BannedIpsSection } from "../BannedIpsSection";
import type { JailBannedIpsResponse } from "../../../types/jail";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({
mockFetchJailBannedIps: vi.fn<
(
jailName: string,
page?: number,
pageSize?: number,
search?: string,
) => Promise<JailBannedIpsResponse>
>(),
mockUnbanIp: vi.fn<
(ip: string, jail?: string) => Promise<{ message: string; jail: string }>
>(),
}));
vi.mock("../../../api/jails", () => ({
fetchJailBannedIps: mockFetchJailBannedIps,
unbanIp: mockUnbanIp,
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeBan(ip: string) {
return {
ip,
jail: "sshd",
banned_at: "2025-01-01T10:00:00+00:00",
expires_at: "2025-01-01T10:10:00+00:00",
ban_count: 1,
country: "US",
};
}
function makeResponse(
ips: string[] = ["1.2.3.4", "5.6.7.8"],
total = 2,
): JailBannedIpsResponse {
return {
items: ips.map(makeBan),
total,
page: 1,
page_size: 25,
};
}
const EMPTY_RESPONSE: JailBannedIpsResponse = {
items: [],
total: 0,
page: 1,
page_size: 25,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderSection(jailName = "sshd") {
return render(
<FluentProvider theme={webLightTheme}>
<BannedIpsSection jailName={jailName} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("BannedIpsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
});
it("renders section header with 'Currently Banned IPs' title", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection();
await waitFor(() => {
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
});
});
it("shows the total count badge", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"], 2));
renderSection();
await waitFor(() => {
expect(screen.getByText("2")).toBeTruthy();
});
});
it("shows a spinner while loading", () => {
// Never resolves during this test so we see the spinner.
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
renderSection();
expect(screen.getByText("Loading banned IPs…")).toBeTruthy();
});
it("renders IP rows when banned IPs exist", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"]));
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
expect(screen.getByText("5.6.7.8")).toBeTruthy();
});
});
it("shows empty-state message when no IPs are banned", async () => {
mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE);
renderSection();
await waitFor(() => {
expect(
screen.getByText("No IPs currently banned in this jail."),
).toBeTruthy();
});
});
it("shows an error message bar on API failure", async () => {
mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead"));
renderSection();
await waitFor(() => {
expect(screen.getByText(/socket dead/i)).toBeTruthy();
});
});
it("calls fetchJailBannedIps with the jail name", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection("nginx");
await waitFor(() => {
expect(mockFetchJailBannedIps).toHaveBeenCalledWith(
"nginx",
expect.any(Number),
expect.any(Number),
undefined,
);
});
});
it("search input re-fetches after debounce with the search term", async () => {
vi.useFakeTimers();
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection();
// Flush pending async work from the initial render (no timer advancement needed).
await act(async () => {});
mockFetchJailBannedIps.mockClear();
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4"], 1),
);
// fireEvent is synchronous — avoids hanging with fake timers.
const input = screen.getByPlaceholderText("e.g. 192.168");
act(() => {
fireEvent.change(input, { target: { value: "1.2.3" } });
});
// Advance just past the 300ms debounce delay and flush promises.
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(mockFetchJailBannedIps).toHaveBeenLastCalledWith(
"sshd",
expect.any(Number),
expect.any(Number),
"1.2.3",
);
vi.useRealTimers();
});
it("calls unbanIp when the unban button is clicked", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4"]));
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
});
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
expect(mockUnbanIp).toHaveBeenCalledWith("1.2.3.4", "sshd");
});
it("refreshes list after successful unban", async () => {
mockFetchJailBannedIps
.mockResolvedValueOnce(makeResponse(["1.2.3.4"]))
.mockResolvedValue(EMPTY_RESPONSE);
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
});
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
await waitFor(() => {
expect(mockFetchJailBannedIps).toHaveBeenCalledTimes(2);
});
});
it("shows pagination controls when total > 0", async () => {
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4", "5.6.7.8"], 50),
);
renderSection();
await waitFor(() => {
expect(screen.getByLabelText("Next page")).toBeTruthy();
expect(screen.getByLabelText("Previous page")).toBeTruthy();
});
});
it("previous page button is disabled on page 1", async () => {
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4"], 50),
);
renderSection();
await waitFor(() => {
const prevBtn = screen.getByLabelText("Previous page");
expect(prevBtn).toHaveAttribute("disabled");
});
});
});

View File

@@ -20,6 +20,7 @@ import {
setJailIdle,
startJail,
stopJail,
toggleIgnoreSelf as toggleIgnoreSelfApi,
unbanAllBans,
unbanIp,
} from "../api/jails";
@@ -150,6 +151,8 @@ export interface UseJailDetailResult {
addIp: (ip: string) => Promise<void>;
/** Remove an IP from the ignore list. */
removeIp: (ip: string) => Promise<void>;
/** Enable or disable the ignoreself option for this jail. */
toggleIgnoreSelf: (on: boolean) => Promise<void>;
}
/**
@@ -208,6 +211,11 @@ export function useJailDetail(name: string): UseJailDetailResult {
load();
};
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
};
return {
jail,
ignoreList,
@@ -217,6 +225,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
refresh: load,
addIp,
removeIp,
toggleIgnoreSelf,
};
}

View File

@@ -33,6 +33,7 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
import { useServerStatus } from "../hooks/useServerStatus";
import { useBlocklistStatus } from "../hooks/useBlocklist";
import { RecoveryBanner } from "../components/common/RecoveryBanner";
// ---------------------------------------------------------------------------
// Styles
@@ -335,6 +336,8 @@ export function MainLayout(): React.JSX.Element {
</MessageBar>
</div>
)}
{/* Recovery banner — shown when fail2ban crashed after a jail activation */}
<RecoveryBanner />
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
{blocklistHasErrors && (
<div className={styles.warningBar} role="alert">

View File

@@ -22,6 +22,7 @@ import {
FiltersTab,
GlobalTab,
JailsTab,
LogTab,
MapTab,
RegexTesterTab,
ServerTab,
@@ -60,7 +61,8 @@ type TabValue =
| "global"
| "server"
| "map"
| "regex";
| "regex"
| "log";
export function ConfigPage(): React.JSX.Element {
const styles = useStyles();
@@ -91,6 +93,7 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
<Tab value="regex">Regex Tester</Tab>
<Tab value="log">Log</Tab>
</TabList>
<div className={styles.tabContent} key={tab}>
@@ -101,6 +104,7 @@ export function ConfigPage(): React.JSX.Element {
{tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}
{tab === "regex" && <RegexTesterTab />}
{tab === "log" && <LogTab />}
</div>
</div>
);

View File

@@ -12,7 +12,6 @@ import { BanTable } from "../components/BanTable";
import { BanTrendChart } from "../components/BanTrendChart";
import { ChartStateWrapper } from "../components/ChartStateWrapper";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { JailDistributionChart } from "../components/JailDistributionChart";
import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
@@ -160,20 +159,6 @@ export function DashboardPage(): React.JSX.Element {
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Jail Distribution section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Jail Distribution
</Text>
</div>
<div className={styles.tabContent}>
<JailDistributionChart timeRange={timeRange} origin={originFilter} />
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Ban list section */}
{/* ------------------------------------------------------------------ */}

View File

@@ -17,6 +17,7 @@ import {
MessageBar,
MessageBarBody,
Spinner,
Switch,
Text,
Tooltip,
makeStyles,
@@ -41,6 +42,7 @@ import {
import { useJailDetail } from "../hooks/useJails";
import type { Jail } from "../types/jail";
import { ApiError } from "../api/client";
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
// ---------------------------------------------------------------------------
// Styles
@@ -442,6 +444,7 @@ interface IgnoreListSectionProps {
ignoreSelf: boolean;
onAdd: (ip: string) => Promise<void>;
onRemove: (ip: string) => Promise<void>;
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
}
function IgnoreListSection({
@@ -450,6 +453,7 @@ function IgnoreListSection({
ignoreSelf,
onAdd,
onRemove,
onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element {
const styles = useStyles();
const [inputVal, setInputVal] = useState("");
@@ -493,17 +497,27 @@ function IgnoreListSection({
<Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist)
</Text>
{ignoreSelf && (
<Tooltip content="This jail ignores the server's own IP addresses" relationship="label">
<Badge appearance="tint" color="informative">
ignore self
</Badge>
</Tooltip>
)}
</div>
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
</div>
{/* Ignore-self toggle */}
<Switch
label="Ignore self — exclude this server's own IP addresses from banning"
checked={ignoreSelf}
onChange={(_e, data): void => {
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
}}
/>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
@@ -578,7 +592,7 @@ function IgnoreListSection({
export function JailDetailPage(): React.JSX.Element {
const styles = useStyles();
const { name = "" } = useParams<{ name: string }>();
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp } =
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf } =
useJailDetail(name);
if (loading && !jail) {
@@ -624,6 +638,7 @@ export function JailDetailPage(): React.JSX.Element {
</div>
<JailInfoSection jail={jail} onRefresh={refresh} />
<BannedIpsSection jailName={name} />
<PatternsSection jail={jail} />
<BantimeEscalationSection jail={jail} />
<IgnoreListSection
@@ -632,6 +647,7 @@ export function JailDetailPage(): React.JSX.Element {
ignoreSelf={ignoreSelf}
onAdd={addIp}
onRemove={removeIp}
onToggleIgnoreSelf={toggleIgnoreSelf}
/>
</div>
);

View File

@@ -1,12 +1,11 @@
/**
* Jails management page.
*
* Provides four sections in a vertically-stacked layout:
* Provides three sections in a vertically-stacked layout:
* 1. **Jail Overview** — table of all jails with quick status badges and
* per-row start/stop/idle/reload controls.
* 2. **Ban / Unban IP** — form to manually ban or unban an IP address.
* 3. **Currently Banned IPs** — live table of all active bans.
* 4. **IP Lookup** — check whether an IP is currently banned and view its
* 3. **IP Lookup** — check whether an IP is currently banned and view its
* geo-location details.
*/
@@ -20,12 +19,6 @@ import {
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
@@ -42,8 +35,6 @@ import {
import {
ArrowClockwiseRegular,
ArrowSyncRegular,
DeleteRegular,
DismissRegular,
LockClosedRegular,
LockOpenRegular,
PauseRegular,
@@ -53,7 +44,7 @@ import {
} from "@fluentui/react-icons";
import { Link } from "react-router-dom";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { ActiveBan, JailSummary } from "../types/jail";
import type { JailSummary } from "../types/jail";
import { ApiError } from "../api/client";
// ---------------------------------------------------------------------------
@@ -160,21 +151,6 @@ function fmtSeconds(s: number): string {
return `${String(Math.round(s / 3600))}h`;
}
function fmtTimestamp(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Jail overview columns
// ---------------------------------------------------------------------------
@@ -236,80 +212,6 @@ const jailColumns: TableColumnDefinition<JailSummary>[] = [
}),
];
// ---------------------------------------------------------------------------
// Active bans columns
// ---------------------------------------------------------------------------
function buildBanColumns(
onUnban: (ip: string, jail: string) => void,
): TableColumnDefinition<ActiveBan>[] {
return [
createTableColumn<ActiveBan>({
columnId: "ip",
renderHeaderCell: () => "IP",
renderCell: (b) => (
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
{b.ip}
</Text>
),
}),
createTableColumn<ActiveBan>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (b) => <Text size={200}>{b.jail}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (b) => <Text size={200}>{b.country ?? "—"}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "bannedAt",
renderHeaderCell: () => "Banned At",
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.banned_at)}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "expiresAt",
renderHeaderCell: () => "Expires At",
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.expires_at)}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "count",
renderHeaderCell: () => "Count",
renderCell: (b) => (
<Tooltip
content={`Banned ${String(b.ban_count)} time${b.ban_count === 1 ? "" : "s"}`}
relationship="label"
>
<Badge
appearance="filled"
color={b.ban_count > 3 ? "danger" : b.ban_count > 1 ? "warning" : "informative"}
>
{String(b.ban_count)}
</Badge>
</Tooltip>
),
}),
createTableColumn<ActiveBan>({
columnId: "unban",
renderHeaderCell: () => "",
renderCell: (b) => (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(b.ip, b.jail);
}}
aria-label={`Unban ${b.ip} from ${b.jail}`}
>
Unban
</Button>
),
}),
];
}
// ---------------------------------------------------------------------------
// Sub-component: Jail overview section
// ---------------------------------------------------------------------------
@@ -646,177 +548,6 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
);
}
// ---------------------------------------------------------------------------
// Sub-component: Active bans section
// ---------------------------------------------------------------------------
function ActiveBansSection(): React.JSX.Element {
const styles = useStyles();
const { bans, total, loading, error, refresh, unbanIp, unbanAll } = useActiveBans();
const [opError, setOpError] = useState<string | null>(null);
const [opSuccess, setOpSuccess] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [clearing, setClearing] = useState(false);
const handleUnban = (ip: string, jail: string): void => {
setOpError(null);
setOpSuccess(null);
unbanIp(ip, jail).catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
});
};
const handleClearAll = (): void => {
setClearing(true);
setOpError(null);
setOpSuccess(null);
unbanAll()
.then((res) => {
setOpSuccess(res.message);
setConfirmOpen(false);
})
.catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
setConfirmOpen(false);
})
.finally(() => {
setClearing(false);
});
};
const banColumns = buildBanColumns(handleUnban);
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Currently Banned IPs
{total > 0 && (
<Badge appearance="filled" color="danger" style={{ marginLeft: "8px" }}>
{String(total)}
</Badge>
)}
</Text>
<div style={{ display: "flex", gap: tokens.spacingHorizontalS }}>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={refresh}
>
Refresh
</Button>
{total > 0 && (
<Button
size="small"
appearance="outline"
icon={<DeleteRegular />}
onClick={() => {
setConfirmOpen(true);
}}
>
Clear All Bans
</Button>
)}
</div>
</div>
{/* Confirmation dialog */}
<Dialog
open={confirmOpen}
onOpenChange={(_ev, data) => {
if (!data.open) setConfirmOpen(false);
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>Clear All Bans</DialogTitle>
<DialogContent>
<Text>
This will immediately unban <strong>all {String(total)} IP
{total !== 1 ? "s" : ""}</strong> across every jail. This
action cannot be undone fail2ban will no longer block any
of those addresses until they trigger the rate-limit again.
</Text>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={() => {
setConfirmOpen(false);
}}
disabled={clearing}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleClearAll}
disabled={clearing}
icon={clearing ? <Spinner size="tiny" /> : <DeleteRegular />}
>
{clearing ? "Clearing…" : "Clear All Bans"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{opSuccess && (
<MessageBar intent="success">
<MessageBarBody>{opSuccess}</MessageBarBody>
</MessageBar>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>
</MessageBar>
)}
{loading && bans.length === 0 ? (
<div className={styles.centred}>
<Spinner label="Loading active bans…" />
</div>
) : bans.length === 0 ? (
<div className={styles.centred}>
<Text size={300}>No IPs are currently banned.</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<DataGrid
items={bans}
columns={banColumns}
getRowId={(b: ActiveBan) => `${b.jail}:${b.ip}`}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<ActiveBan>>
{({ item }) => (
<DataGridRow<ActiveBan> key={`${item.jail}:${item.ip}`}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-component: IP Lookup section
// ---------------------------------------------------------------------------
@@ -935,8 +666,7 @@ function IpLookupSection(): React.JSX.Element {
/**
* Jails management page.
*
* Renders four sections: Jail Overview, Ban/Unban IP, Currently Banned IPs,
* and IP Lookup.
* Renders three sections: Jail Overview, Ban/Unban IP, and IP Lookup.
*/
export function JailsPage(): React.JSX.Element {
const styles = useStyles();
@@ -955,8 +685,6 @@ export function JailsPage(): React.JSX.Element {
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} />
<ActiveBansSection />
<IpLookupSection />
</div>
);

View File

@@ -0,0 +1,188 @@
/**
* Tests for the "Ignore self" toggle in `JailDetailPage`.
*
* Verifies that:
* - The switch is checked when `ignoreSelf` is `true`.
* - The switch is unchecked when `ignoreSelf` is `false`.
* - Toggling the switch calls `toggleIgnoreSelf` with the correct boolean.
* - A failed toggle shows an error message bar.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { JailDetailPage } from "../JailDetailPage";
import type { Jail } from "../../types/jail";
import type { UseJailDetailResult } from "../../hooks/useJails";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
/**
* Stable mock function refs created before vi.mock() is hoisted.
* We need `mockToggleIgnoreSelf` to be a vi.fn() that tests can inspect
* and the rest to be no-ops that prevent real network calls.
*/
const {
mockToggleIgnoreSelf,
mockAddIp,
mockRemoveIp,
mockRefresh,
} = vi.hoisted(() => ({
mockToggleIgnoreSelf: vi.fn<(on: boolean) => Promise<void>>(),
mockAddIp: vi.fn<(ip: string) => Promise<void>>().mockResolvedValue(undefined),
mockRemoveIp: vi.fn<(ip: string) => Promise<void>>().mockResolvedValue(undefined),
mockRefresh: vi.fn(),
}));
// Mock the jail detail hook — tests control the returned state directly.
vi.mock("../../hooks/useJails", () => ({
useJailDetail: vi.fn(),
}));
// Mock API functions used by JailInfoSection control buttons to avoid side effects.
vi.mock("../../api/jails", () => ({
startJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
stopJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
reloadJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
setJailIdle: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
toggleIgnoreSelf: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
}));
// Stub BannedIpsSection to prevent its own fetchJailBannedIps calls.
vi.mock("../../components/jail/BannedIpsSection", () => ({
BannedIpsSection: () => <div data-testid="banned-ips-stub" />,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { useJailDetail } from "../../hooks/useJails";
/** Minimal `Jail` fixture. */
function makeJail(): Jail {
return {
name: "sshd",
running: true,
idle: false,
backend: "systemd",
log_paths: ["/var/log/auth.log"],
fail_regex: ["^Failed .+ from <HOST>"],
ignore_regex: [],
date_pattern: "",
log_encoding: "UTF-8",
actions: ["iptables-multiport"],
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: {
currently_banned: 2,
total_banned: 10,
currently_failed: 0,
total_failed: 50,
},
bantime_escalation: null,
};
}
/** Wire `useJailDetail` to return the given `ignoreSelf` value. */
function mockHook(ignoreSelf: boolean): void {
const result: UseJailDetailResult = {
jail: makeJail(),
ignoreList: ["10.0.0.0/8"],
ignoreSelf,
loading: false,
error: null,
refresh: mockRefresh,
addIp: mockAddIp,
removeIp: mockRemoveIp,
toggleIgnoreSelf: mockToggleIgnoreSelf,
};
vi.mocked(useJailDetail).mockReturnValue(result);
}
/** Render the JailDetailPage with a fake `/jails/sshd` route. */
function renderPage() {
return render(
<MemoryRouter initialEntries={["/jails/sshd"]}>
<FluentProvider theme={webLightTheme}>
<Routes>
<Route path="/jails/:name" element={<JailDetailPage />} />
</Routes>
</FluentProvider>
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("JailDetailPage — ignore self toggle", () => {
beforeEach(() => {
vi.clearAllMocks();
mockToggleIgnoreSelf.mockResolvedValue(undefined);
});
it("renders the switch checked when ignoreSelf is true", async () => {
mockHook(true);
renderPage();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
expect(switchEl).toBeChecked();
});
it("renders the switch unchecked when ignoreSelf is false", async () => {
mockHook(false);
renderPage();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
expect(switchEl).not.toBeChecked();
});
it("calls toggleIgnoreSelf(false) when switch is toggled off", async () => {
mockHook(true);
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce();
expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(false);
});
});
it("calls toggleIgnoreSelf(true) when switch is toggled on", async () => {
mockHook(false);
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce();
expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(true);
});
});
it("shows an error message bar when toggleIgnoreSelf rejects", async () => {
mockHook(false);
mockToggleIgnoreSelf.mockRejectedValue(new Error("Connection refused"));
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(screen.getByText(/Connection refused/i)).toBeInTheDocument();
});
});
});

View File

@@ -549,6 +549,52 @@ export interface JailActivationResponse {
active: boolean;
/** Human-readable result message. */
message: string;
/** Whether fail2ban was still running after the reload. Defaults to true. */
fail2ban_running: boolean;
/** Non-fatal pre-activation validation warnings (e.g. missing log path). */
validation_warnings: string[];
}
// ---------------------------------------------------------------------------
// Jail config recovery models (Task 3)
// ---------------------------------------------------------------------------
/** A single validation issue found in a jail's config. */
export interface JailValidationIssue {
/** Config field that has the issue, e.g. "filter", "failregex". */
field: string;
/** Human-readable description of the issue. */
message: string;
}
/** Full result of pre-activation validation for a single jail. */
export interface JailValidationResult {
jail_name: string;
valid: boolean;
issues: JailValidationIssue[];
}
/**
* Recorded when fail2ban stops responding shortly after a jail activation.
* Surfaced by `GET /api/config/pending-recovery`.
*/
export interface PendingRecovery {
jail_name: string;
/** ISO-8601 datetime string. */
activated_at: string;
/** ISO-8601 datetime string. */
detected_at: string;
/** True once fail2ban comes back online after the crash. */
recovered: boolean;
}
/** Response from `POST /api/config/jails/{name}/rollback`. */
export interface RollbackResponse {
jail_name: string;
disabled: boolean;
fail2ban_running: boolean;
active_jails: number;
message: string;
}
// ---------------------------------------------------------------------------
@@ -592,3 +638,39 @@ export interface FilterCreateRequest {
export interface AssignFilterRequest {
filter_name: string;
}
// ---------------------------------------------------------------------------
// fail2ban log viewer types (Task 2)
// ---------------------------------------------------------------------------
/** Response for ``GET /api/config/fail2ban-log``. */
export interface Fail2BanLogResponse {
/** Resolved absolute path of the log file being read. */
log_path: string;
/** Log lines (tail of file, optionally filtered by substring). */
lines: string[];
/** Total number of lines in the file before any filtering. */
total_lines: number;
/** Current fail2ban log level, e.g. "INFO". */
log_level: string;
/** Current fail2ban log target (file path or special value like "STDOUT"). */
log_target: string;
}
/** Response for ``GET /api/config/service-status``. */
export interface ServiceStatusResponse {
/** Whether fail2ban is reachable via its socket. */
online: boolean;
/** fail2ban version string, or null when offline. */
version: string | null;
/** Number of currently active jails. */
jail_count: number;
/** Aggregated current ban count across all jails. */
total_bans: number;
/** Aggregated current failure count across all jails. */
total_failures: number;
/** Current fail2ban log level. */
log_level: string;
/** Current fail2ban log target. */
log_target: string;
}

View File

@@ -198,6 +198,26 @@ export interface UnbanAllResponse {
count: number;
}
// ---------------------------------------------------------------------------
// Jail-specific paginated bans
// ---------------------------------------------------------------------------
/**
* Paginated response from `GET /api/jails/{name}/banned`.
*
* Mirrors `JailBannedIpsResponse` from `backend/app/models/ban.py`.
*/
export interface JailBannedIpsResponse {
/** Active ban entries for the current page. */
items: ActiveBan[];
/** Total matching entries (after applying any search filter). */
total: number;
/** Current page number (1-based). */
page: number;
/** Number of items per page. */
page_size: number;
}
export interface GeoDetail {
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
country_code: string | null;