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
This commit is contained in:
557
Docs/Tasks.md
557
Docs/Tasks.md
@@ -4,383 +4,254 @@ 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
|
||||
**Page:** `/jails` — rendered by `frontend/src/pages/JailsPage.tsx`
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
### Goal
|
||||
### What to do
|
||||
|
||||
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.
|
||||
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".
|
||||
|
||||
### Backend Changes
|
||||
### Verification
|
||||
|
||||
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.
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
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:** done
|
||||
**References:** [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
|
||||
**Page:** `/` (Dashboard) — rendered by `frontend/src/pages/DashboardPage.tsx`
|
||||
|
||||
**Implementation summary:**
|
||||
- Added `Fail2BanLogResponse` and `ServiceStatusResponse` Pydantic models to `backend/app/models/config.py`.
|
||||
- Added `read_fail2ban_log()` and `get_service_status()` service methods to `backend/app/services/config_service.py`. The log method queries the fail2ban socket for the log target/level, validates the resolved path against a safe-prefix allowlist (`/var/log`), then reads the tail of the file. Uses `Promise.allSettled` on the frontend so a log-read failure never hides the service-health panel.
|
||||
- Added `GET /api/config/fail2ban-log` and `GET /api/config/service-status` endpoints to `backend/app/routers/config.py`.
|
||||
- Created `frontend/src/components/config/LogTab.tsx` with service-health panel and scrollable log viewer, color-coded by severity, with filter/lines/refresh/auto-refresh controls.
|
||||
- Updated `ConfigPage.tsx` to register the new "Log" tab.
|
||||
- Added backend service tests (8 in 2 classes) and router tests (11 in 2 classes). Added frontend component tests (8 tests). All pass. ruff, mypy (modified files), tsc, eslint, and vitest all green.
|
||||
The Dashboard currently shows: Server Status Bar, Filter Bar, Ban Trend, Top Countries, **Jail Distribution**, and Ban List. Remove the "Jail Distribution" section.
|
||||
|
||||
### Problem
|
||||
### What to do
|
||||
|
||||
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.
|
||||
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 164–177).
|
||||
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.
|
||||
|
||||
### Goal
|
||||
### Verification
|
||||
|
||||
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.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
#### New Endpoint: Read fail2ban Log
|
||||
|
||||
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:** done
|
||||
**References:** [Features.md § 5 — Jail Management](Features.md), [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
|
||||
**Page:** `/` (Dashboard) — pie chart rendered by `frontend/src/components/TopCountriesPieChart.tsx`
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
### What to do
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Goal
|
||||
### Verification
|
||||
|
||||
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.
|
||||
|
||||
### Plan
|
||||
|
||||
#### 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 — Jail Detail Page: Paginated "Currently Banned IPs" List
|
||||
## Task 4 — Fix log viewer rejecting fail2ban log path under `/config/log`
|
||||
|
||||
**Status:** done
|
||||
**References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md)
|
||||
**Error:** `API error 400: {"detail":"Log path '/config/log/fail2ban/fail2ban.log' is outside the allowed directory. Only paths under /var/log are permitted."}`
|
||||
|
||||
### Problem
|
||||
**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:
|
||||
|
||||
The Jail detail page (`JailDetailPage.tsx`) currently shows "Currently banned: N" as a single number inside the stats grid. There is no way to see **which** IPs are banned in the jail — only the count. The global `GET /api/bans/active` endpoint fetches banned IPs across all jails at once without pagination, which is both wasteful (queries every jail) and slow when thousands of IPs are banned. There is no jail-specific endpoint to retrieve just the banned IPs for a single jail.
|
||||
```python
|
||||
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)
|
||||
```
|
||||
|
||||
### Goal
|
||||
This causes any log target under `/config/log/` to be rejected with a 400 error.
|
||||
|
||||
Add a **"Currently Banned IPs"** section to the Jail detail page that displays the banned IPs for that specific jail in a paginated table. The implementation must be fast: the backend paginates on the server side so only one page of data is sent over the wire at a time, and geo enrichment is performed only for the IPs in the current page.
|
||||
### What to do
|
||||
|
||||
### Backend Changes
|
||||
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.
|
||||
|
||||
#### New Endpoint: Jail-Specific Active Bans (Paginated)
|
||||
### Verification
|
||||
|
||||
1. **Create `GET /api/jails/{name}/banned`** in `backend/app/routers/jails.py`.
|
||||
- **Path parameter:** `name` (str) — jail name.
|
||||
- **Query parameters:**
|
||||
- `page` (int, default 1, min 1) — current page number.
|
||||
- `page_size` (int, default 25, min 1, max 100) — items per page.
|
||||
- `search` (optional str) — plain-text substring filter on the IP address (for searching).
|
||||
- **Response model:** `JailBannedIpsResponse` (new model, see below).
|
||||
- **Behaviour:**
|
||||
- Query the fail2ban socket with `get <jail> banip --with-time` to get the full list of banned IPs for this single jail.
|
||||
- Parse each entry using the existing `_parse_ban_entry()` helper.
|
||||
- If a `search` parameter is provided, filter the parsed list to entries where the IP contains the search substring.
|
||||
- Compute `total` (length of the filtered list).
|
||||
- Slice the list to extract only the requested page: `items[(page-1)*page_size : page*page_size]`.
|
||||
- Geo-enrich **only** the IPs in the current page slice using `geo_service.lookup_batch()` — this is the key performance optimisation (never enrich thousands of IPs at once).
|
||||
- Return the paginated response.
|
||||
- **Error handling:** Return 404 if the jail does not exist. Return 502 if fail2ban is unreachable.
|
||||
|
||||
2. **Create the service method** `get_jail_banned_ips()` in `backend/app/services/jail_service.py`.
|
||||
- Accept parameters: `socket_path`, `jail_name`, `page`, `page_size`, `search`, `http_session`, `app_db`.
|
||||
- Open a `Fail2BanClient` connection and send `["get", jail_name, "banip", "--with-time"]`.
|
||||
- Parse the result with `_parse_ban_entry()`.
|
||||
- Apply optional search filter (case-insensitive substring match on `ip`).
|
||||
- Slice the list for the requested page.
|
||||
- Run `geo_service.lookup_batch()` on only the page slice.
|
||||
- Return the `JailBannedIpsResponse`.
|
||||
|
||||
3. **Create Pydantic models** in `backend/app/models/ban.py`:
|
||||
- `JailBannedIpsResponse`:
|
||||
```python
|
||||
class JailBannedIpsResponse(BaseModel):
|
||||
items: list[ActiveBan]
|
||||
total: int # total matching entries (after search filter)
|
||||
page: int # current page number
|
||||
page_size: int # items per page
|
||||
```
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
#### New Section: Currently Banned IPs
|
||||
|
||||
4. **Create `frontend/src/components/jail/BannedIpsSection.tsx`** — a self-contained component that receives the jail name as a prop and manages its own data fetching and pagination state.
|
||||
- **Table columns:** IP Address, Country (flag + code), Banned At, Expires At, Actions (unban button).
|
||||
- **Pagination controls** below the table: page number, previous/next buttons, page size selector (10, 25, 50, 100). Use FluentUI `DataGrid` or a simple custom table — keep it lightweight.
|
||||
- **Search input** above the table: a text field that debounces input (300ms) and re-fetches with the `search` query parameter. Debounce to avoid spamming the backend on each keystroke.
|
||||
- **Loading state:** Show a spinner inside the table area while fetching. Do not block the rest of the page.
|
||||
- **Empty state:** When no IPs are banned, show a muted "No IPs currently banned" message.
|
||||
- **Unban action:** Each row has an unban button. On click, call the existing `DELETE /api/bans` endpoint with the IP and jail name, then re-fetch the current page.
|
||||
- **Auto-refresh:** Do not auto-refresh. The user can click a manual refresh button in the section header.
|
||||
|
||||
5. **Mount the section** in `JailDetailPage.tsx`.
|
||||
- Add `<BannedIpsSection jailName={jail.name} />` after the `JailInfoSection` (stats grid) and before the `PatternsSection`.
|
||||
- The section should have a header: "Currently Banned IPs" with the total count as a badge next to it.
|
||||
|
||||
6. **Create API function** in `frontend/src/api/jails.ts`:
|
||||
- `fetchJailBannedIps(jailName: string, page?: number, pageSize?: number, search?: string): Promise<JailBannedIpsResponse>`
|
||||
- Calls `GET /api/jails/{name}/banned?page=...&page_size=...&search=...`.
|
||||
|
||||
7. **Create TypeScript types** in `frontend/src/types/jail.ts`:
|
||||
- `JailBannedIpsResponse { items: ActiveBan[]; total: number; page: number; page_size: number; }`
|
||||
- Reuse the existing `ActiveBan` type from `frontend/src/types/jail.ts`.
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- The fail2ban socket command `get <jail> banip --with-time` returns the full list; there is no socket-level pagination. Pagination is applied **after** parsing the socket response. This is acceptable because parsing string entries is fast even for 10,000+ IPs — the expensive part is geo enrichment and network transfer, both of which are limited to the page size.
|
||||
- Geo enrichment (`lookup_batch`) is called **only** for the page slice (max 100 IPs). This avoids hitting ip-api.com rate limits and keeps response times low.
|
||||
- The `search` filter runs server-side on the already-parsed list (simple substring match) before slicing, so the `total` count reflects the filtered result.
|
||||
- Frontend debounces the search input to avoid redundant API calls.
|
||||
|
||||
### Backend File Map
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `routers/jails.py` | Add `GET /api/jails/{name}/banned` endpoint. |
|
||||
| `services/jail_service.py` | Add `get_jail_banned_ips()` with pagination, search, and page-only geo enrichment. |
|
||||
| `models/ban.py` | Add `JailBannedIpsResponse`. |
|
||||
|
||||
### Frontend File Map
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `components/jail/BannedIpsSection.tsx` (new) | Paginated table with search, unban action, refresh button. |
|
||||
| `pages/JailDetailPage.tsx` | Mount `BannedIpsSection` after the stats grid. |
|
||||
| `api/jails.ts` | Add `fetchJailBannedIps()`. |
|
||||
| `types/jail.ts` | Add `JailBannedIpsResponse`. |
|
||||
|
||||
### Tests
|
||||
|
||||
8. **Backend:** Test `get_jail_banned_ips()` — mock the socket response, verify pagination slicing (page 1 returns first N items, page 2 returns the next N), verify total count, verify search filter narrows results, verify geo enrichment is called with only the page slice.
|
||||
9. **Backend:** Test `GET /api/jails/{name}/banned` endpoint — 200 with paginated data, 404 for unknown jail, 502 when fail2ban is down, search parameter works, page_size clamped to max 100.
|
||||
10. **Frontend:** Test `BannedIpsSection` — renders table with IP rows, pagination buttons navigate pages, search input triggers re-fetch with debounce, unban button calls the API and refreshes, empty state shown when no bans.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- The Jail detail page shows a "Currently Banned IPs" section with a paginated table below the stats grid.
|
||||
- Only one page of IPs is fetched from the backend at a time; geo enrichment runs only for that page.
|
||||
- Users can paginate through the list and change the page size (10, 25, 50, 100).
|
||||
- Users can search/filter by IP address substring; results update after a debounce delay.
|
||||
- Each row has an unban button that removes the ban and refreshes the current page.
|
||||
- Response times stay fast (<500ms) even when a jail has thousands of banned IPs (since only one page is geo-enriched).
|
||||
- The section shows a clear empty state when no IPs are banned.
|
||||
- All new backend endpoints and frontend components have test coverage.
|
||||
- `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
|
||||
|
||||
### 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 715–723), 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 1130–1138) 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 640–660)
|
||||
|
||||
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.
|
||||
|
||||
@@ -97,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"])
|
||||
@@ -654,6 +655,10 @@ async def activate_jail(
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -1136,6 +1136,25 @@ async def activate_jail(
|
||||
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,
|
||||
|
||||
@@ -768,7 +768,7 @@ _NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
# Only allow reading log files under these base directories (security).
|
||||
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)
|
||||
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
|
||||
|
||||
|
||||
def _count_file_lines(file_path: str) -> int:
|
||||
@@ -847,7 +847,7 @@ async def read_fail2ban_log(
|
||||
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 are permitted."
|
||||
"Only paths under /var/log or /config/log are permitted."
|
||||
)
|
||||
|
||||
if not resolved.is_file():
|
||||
|
||||
@@ -742,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
|
||||
|
||||
@@ -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 (
|
||||
@@ -447,6 +447,10 @@ class TestActivateJail:
|
||||
"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)
|
||||
@@ -492,7 +496,7 @@ 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 (
|
||||
@@ -505,6 +509,10 @@ class TestActivateJail:
|
||||
"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)
|
||||
@@ -2512,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 (
|
||||
@@ -2525,6 +2533,10 @@ class TestActivateJailReloadArgs:
|
||||
"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)
|
||||
@@ -2538,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 (
|
||||
@@ -2551,6 +2563,10 @@ class TestActivateJailReloadArgs:
|
||||
"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(
|
||||
@@ -2570,7 +2586,7 @@ 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).
|
||||
@@ -2585,6 +2601,10 @@ class TestActivateJailReloadArgs:
|
||||
"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(
|
||||
@@ -2830,3 +2850,100 @@ class TestRollbackJail:
|
||||
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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -177,13 +177,9 @@ export function ActivateJailDialog({
|
||||
|
||||
if (!jail) return <></>;
|
||||
|
||||
// Errors block activation; warnings are advisory only.
|
||||
const blockingIssues = validationIssues.filter(
|
||||
(i) => i.field !== "logpath",
|
||||
);
|
||||
const advisoryIssues = validationIssues.filter(
|
||||
(i) => i.field === "logpath",
|
||||
);
|
||||
// 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(); }}>
|
||||
|
||||
@@ -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 */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user