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
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
131
Docs/Tasks.md
131
Docs/Tasks.md
@@ -41,9 +41,17 @@ Remove all inactive-jail display and activation UI from the Jail management page
|
||||
|
||||
## Task 2 — Configuration Subpage: fail2ban Log Viewer & Service Health
|
||||
|
||||
**Status:** not started
|
||||
**Status:** done
|
||||
**References:** [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
|
||||
|
||||
**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.
|
||||
|
||||
### Problem
|
||||
|
||||
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.
|
||||
@@ -255,3 +263,124 @@ Build a multi-layered safety net that:
|
||||
- All new endpoints have tests covering success, failure, and edge cases.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Jail Detail Page: Paginated "Currently Banned IPs" List
|
||||
|
||||
**Status:** not started
|
||||
**References:** [Features.md § 5 — Jail Management](Features.md), [Architekture.md § 2](Architekture.md)
|
||||
|
||||
### Problem
|
||||
|
||||
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.
|
||||
|
||||
### Goal
|
||||
|
||||
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.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
#### New Endpoint: Jail-Specific Active Bans (Paginated)
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user