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.
|
||||
|
||||
---
|
||||
|
||||
@@ -860,3 +860,34 @@ class JailActivationResponse(BaseModel):
|
||||
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
|
||||
)
|
||||
message: str = Field(..., description="Human-readable result message.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fail2ban log viewer models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Fail2BanLogResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/fail2ban-log``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
log_path: str = Field(..., description="Resolved absolute path of the log file being read.")
|
||||
lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).")
|
||||
total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.")
|
||||
log_level: str = Field(..., description="Current fail2ban log level.")
|
||||
log_target: str = Field(..., description="Current fail2ban log target (file path or special value).")
|
||||
|
||||
|
||||
class ServiceStatusResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/service-status``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
||||
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
|
||||
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
||||
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
|
||||
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
||||
log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.")
|
||||
log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.")
|
||||
|
||||
@@ -28,6 +28,8 @@ global settings, test regex patterns, add log paths, and preview log files.
|
||||
* ``PUT /api/config/actions/{name}`` — update an action's .local override
|
||||
* ``POST /api/config/actions`` — create a new user-defined action
|
||||
* ``DELETE /api/config/actions/{name}`` — delete an action's .local file
|
||||
* ``GET /api/config/fail2ban-log`` — read the tail of the fail2ban log file
|
||||
* ``GET /api/config/service-status`` — fail2ban health + log configuration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,6 +48,7 @@ from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
AssignActionRequest,
|
||||
AssignFilterRequest,
|
||||
Fail2BanLogResponse,
|
||||
FilterConfig,
|
||||
FilterCreateRequest,
|
||||
FilterListResponse,
|
||||
@@ -63,6 +66,7 @@ from app.models.config import (
|
||||
MapColorThresholdsUpdate,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
ServiceStatusResponse,
|
||||
)
|
||||
from app.services import config_file_service, config_service, jail_service
|
||||
from app.services.config_file_service import (
|
||||
@@ -1319,3 +1323,83 @@ async def remove_action_from_jail(
|
||||
detail=f"Failed to write jail override: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fail2ban log viewer endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/fail2ban-log",
|
||||
response_model=Fail2BanLogResponse,
|
||||
summary="Read the tail of the fail2ban daemon log file",
|
||||
)
|
||||
async def get_fail2ban_log(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200,
|
||||
filter: Annotated[ # noqa: A002
|
||||
str | None,
|
||||
Query(description="Plain-text substring filter; only matching lines are returned."),
|
||||
] = None,
|
||||
) -> Fail2BanLogResponse:
|
||||
"""Return the tail of the fail2ban daemon log file.
|
||||
|
||||
Queries the fail2ban socket for the current log target and log level,
|
||||
reads the last *lines* entries from the file, and optionally filters
|
||||
them by *filter*. Only file-based log targets are supported.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session — enforces authentication.
|
||||
lines: Number of tail lines to return (1–2000, default 200).
|
||||
filter: Optional plain-text substring — only matching lines returned.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.Fail2BanLogResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when the log target is not a file or path is outside
|
||||
the allowed directory.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_service.read_fail2ban_log(socket_path, lines, filter)
|
||||
except config_service.ConfigOperationError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/service-status",
|
||||
response_model=ServiceStatusResponse,
|
||||
summary="Return fail2ban service health status with log configuration",
|
||||
)
|
||||
async def get_service_status(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> ServiceStatusResponse:
|
||||
"""Return fail2ban service health and current log configuration.
|
||||
|
||||
Probes the fail2ban daemon to determine online/offline state, then
|
||||
augments the result with the current log level and log target values.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ServiceStatusResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable (the service itself
|
||||
handles this gracefully and returns ``online=False``).
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_service.get_service_status(socket_path)
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
from app.models.config import (
|
||||
AddLogPathRequest,
|
||||
BantimeEscalation,
|
||||
Fail2BanLogResponse,
|
||||
GlobalConfigResponse,
|
||||
GlobalConfigUpdate,
|
||||
JailConfig,
|
||||
@@ -39,6 +40,7 @@ from app.models.config import (
|
||||
MapColorThresholdsUpdate,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
ServiceStatusResponse,
|
||||
)
|
||||
from app.services import setup_service
|
||||
from app.utils.fail2ban_client import Fail2BanClient
|
||||
@@ -754,3 +756,174 @@ async def update_map_color_thresholds(
|
||||
threshold_medium=update.threshold_medium,
|
||||
threshold_low=update.threshold_low,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fail2ban log file reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Log targets that are not file paths — log viewing is unavailable for these.
|
||||
_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
|
||||
{"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"}
|
||||
)
|
||||
|
||||
# Only allow reading log files under these base directories (security).
|
||||
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log",)
|
||||
|
||||
|
||||
def _count_file_lines(file_path: str) -> int:
|
||||
"""Count the total number of lines in *file_path* synchronously.
|
||||
|
||||
Uses a memory-efficient buffered read to avoid loading the whole file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file.
|
||||
|
||||
Returns:
|
||||
Total number of lines in the file.
|
||||
"""
|
||||
count = 0
|
||||
with open(file_path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
count += chunk.count(b"\n")
|
||||
return count
|
||||
|
||||
|
||||
async def read_fail2ban_log(
|
||||
socket_path: str,
|
||||
lines: int,
|
||||
filter_text: str | None = None,
|
||||
) -> Fail2BanLogResponse:
|
||||
"""Read the tail of the fail2ban daemon log file.
|
||||
|
||||
Queries the fail2ban socket for the current log target and log level,
|
||||
validates that the target is a readable file, then returns the last
|
||||
*lines* entries optionally filtered by *filter_text*.
|
||||
|
||||
Security: the resolved log path is rejected unless it starts with one of
|
||||
the paths in :data:`_SAFE_LOG_PREFIXES`, preventing path traversal.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
lines: Number of lines to return from the tail of the file (1–2000).
|
||||
filter_text: Optional plain-text substring — only matching lines are
|
||||
returned. Applied server-side; does not affect *total_lines*.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.Fail2BanLogResponse`.
|
||||
|
||||
Raises:
|
||||
ConfigOperationError: When the log target is not a file, when the
|
||||
resolved path is outside the allowed directories, or when the
|
||||
file cannot be read.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
log_level_raw, log_target_raw = await asyncio.gather(
|
||||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||||
)
|
||||
|
||||
log_level = str(log_level_raw or "INFO").upper()
|
||||
log_target = str(log_target_raw or "STDOUT")
|
||||
|
||||
# Reject non-file targets up front.
|
||||
if log_target.upper() in _NON_FILE_LOG_TARGETS:
|
||||
raise ConfigOperationError(
|
||||
f"fail2ban is logging to {log_target!r}. "
|
||||
"File-based log viewing is only available when fail2ban logs to a file path."
|
||||
)
|
||||
|
||||
# Resolve and validate (security: no path traversal outside safe dirs).
|
||||
try:
|
||||
resolved = Path(log_target).resolve()
|
||||
except (ValueError, OSError) as exc:
|
||||
raise ConfigOperationError(
|
||||
f"Cannot resolve log target path {log_target!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
resolved_str = str(resolved)
|
||||
if not any(resolved_str.startswith(safe) for safe in _SAFE_LOG_PREFIXES):
|
||||
raise ConfigOperationError(
|
||||
f"Log path {resolved_str!r} is outside the allowed directory. "
|
||||
"Only paths under /var/log are permitted."
|
||||
)
|
||||
|
||||
if not resolved.is_file():
|
||||
raise ConfigOperationError(f"Log file not found: {resolved_str!r}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
total_lines, raw_lines = await asyncio.gather(
|
||||
loop.run_in_executor(None, _count_file_lines, resolved_str),
|
||||
loop.run_in_executor(None, _read_tail_lines, resolved_str, lines),
|
||||
)
|
||||
|
||||
filtered = (
|
||||
[ln for ln in raw_lines if filter_text in ln]
|
||||
if filter_text
|
||||
else raw_lines
|
||||
)
|
||||
|
||||
log.info(
|
||||
"fail2ban_log_read",
|
||||
log_path=resolved_str,
|
||||
lines_requested=lines,
|
||||
lines_returned=len(filtered),
|
||||
filter_active=filter_text is not None,
|
||||
)
|
||||
|
||||
return Fail2BanLogResponse(
|
||||
log_path=resolved_str,
|
||||
lines=filtered,
|
||||
total_lines=total_lines,
|
||||
log_level=log_level,
|
||||
log_target=log_target,
|
||||
)
|
||||
|
||||
|
||||
async def get_service_status(socket_path: str) -> ServiceStatusResponse:
|
||||
"""Return fail2ban service health status with log configuration.
|
||||
|
||||
Delegates to :func:`~app.services.health_service.probe` for the core
|
||||
health snapshot and augments it with the current log-level and log-target
|
||||
values from the socket.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ServiceStatusResponse`.
|
||||
"""
|
||||
from app.services.health_service import probe # lazy import avoids circular dep
|
||||
|
||||
server_status = await probe(socket_path)
|
||||
|
||||
if server_status.online:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
log_level_raw, log_target_raw = await asyncio.gather(
|
||||
_safe_get(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
||||
)
|
||||
log_level = str(log_level_raw or "INFO").upper()
|
||||
log_target = str(log_target_raw or "STDOUT")
|
||||
else:
|
||||
log_level = "UNKNOWN"
|
||||
log_target = "UNKNOWN"
|
||||
|
||||
log.info(
|
||||
"service_status_fetched",
|
||||
online=server_status.online,
|
||||
jail_count=server_status.active_jails,
|
||||
)
|
||||
|
||||
return ServiceStatusResponse(
|
||||
online=server_status.online,
|
||||
version=server_status.version,
|
||||
jail_count=server_status.active_jails,
|
||||
total_bans=server_status.total_bans,
|
||||
total_failures=server_status.total_failures,
|
||||
log_level=log_level,
|
||||
log_target=log_target,
|
||||
)
|
||||
|
||||
@@ -13,12 +13,14 @@ from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.main import create_app
|
||||
from app.models.config import (
|
||||
Fail2BanLogResponse,
|
||||
FilterConfig,
|
||||
GlobalConfigResponse,
|
||||
JailConfig,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
RegexTestResponse,
|
||||
ServiceStatusResponse,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1711,3 +1713,164 @@ class TestRemoveActionFromJailRouter:
|
||||
).delete("/api/config/jails/sshd/action/iptables")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/fail2ban-log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFail2BanLog:
|
||||
"""Tests for ``GET /api/config/fail2ban-log``."""
|
||||
|
||||
def _mock_log_response(self) -> Fail2BanLogResponse:
|
||||
return Fail2BanLogResponse(
|
||||
log_path="/var/log/fail2ban.log",
|
||||
lines=["2025-01-01 INFO sshd Found 1.2.3.4", "2025-01-01 ERROR oops"],
|
||||
total_lines=100,
|
||||
log_level="INFO",
|
||||
log_target="/var/log/fail2ban.log",
|
||||
)
|
||||
|
||||
async def test_200_returns_log_response(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log returns 200 with Fail2BanLogResponse."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.read_fail2ban_log",
|
||||
AsyncMock(return_value=self._mock_log_response()),
|
||||
):
|
||||
resp = await config_client.get("/api/config/fail2ban-log")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["log_path"] == "/var/log/fail2ban.log"
|
||||
assert isinstance(data["lines"], list)
|
||||
assert data["total_lines"] == 100
|
||||
assert data["log_level"] == "INFO"
|
||||
|
||||
async def test_200_passes_lines_query_param(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log passes the lines query param to the service."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.read_fail2ban_log",
|
||||
AsyncMock(return_value=self._mock_log_response()),
|
||||
) as mock_fn:
|
||||
resp = await config_client.get("/api/config/fail2ban-log?lines=500")
|
||||
|
||||
assert resp.status_code == 200
|
||||
_socket, lines_arg, _filter = mock_fn.call_args.args
|
||||
assert lines_arg == 500
|
||||
|
||||
async def test_200_passes_filter_query_param(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log passes the filter query param to the service."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.read_fail2ban_log",
|
||||
AsyncMock(return_value=self._mock_log_response()),
|
||||
) as mock_fn:
|
||||
resp = await config_client.get("/api/config/fail2ban-log?filter=ERROR")
|
||||
|
||||
assert resp.status_code == 200
|
||||
_socket, _lines, filter_arg = mock_fn.call_args.args
|
||||
assert filter_arg == "ERROR"
|
||||
|
||||
async def test_400_when_non_file_target(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log returns 400 when log target is not a file."""
|
||||
from app.services.config_service import ConfigOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.read_fail2ban_log",
|
||||
AsyncMock(side_effect=ConfigOperationError("fail2ban is logging to 'STDOUT'")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/fail2ban-log")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_400_when_path_traversal_detected(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log returns 400 when the path is outside safe dirs."""
|
||||
from app.services.config_service import ConfigOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.read_fail2ban_log",
|
||||
AsyncMock(side_effect=ConfigOperationError("outside the allowed directory")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/fail2ban-log")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log returns 502 when fail2ban is down."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_service.read_fail2ban_log",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("socket error", "/tmp/f.sock")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/fail2ban-log")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
async def test_422_for_lines_exceeding_max(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log returns 422 for lines > 2000."""
|
||||
resp = await config_client.get("/api/config/fail2ban-log?lines=9999")
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/fail2ban-log requires authentication."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/fail2ban-log")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/service-status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetServiceStatus:
|
||||
"""Tests for ``GET /api/config/service-status``."""
|
||||
|
||||
def _mock_status(self, online: bool = True) -> ServiceStatusResponse:
|
||||
return ServiceStatusResponse(
|
||||
online=online,
|
||||
version="1.0.0" if online else None,
|
||||
jail_count=2 if online else 0,
|
||||
total_bans=10 if online else 0,
|
||||
total_failures=3 if online else 0,
|
||||
log_level="INFO" if online else "UNKNOWN",
|
||||
log_target="/var/log/fail2ban.log" if online else "UNKNOWN",
|
||||
)
|
||||
|
||||
async def test_200_when_online(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/service-status returns 200 with full status when online."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.get_service_status",
|
||||
AsyncMock(return_value=self._mock_status(online=True)),
|
||||
):
|
||||
resp = await config_client.get("/api/config/service-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["online"] is True
|
||||
assert data["jail_count"] == 2
|
||||
assert data["log_level"] == "INFO"
|
||||
|
||||
async def test_200_when_offline(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/service-status returns 200 with offline=False when daemon is down."""
|
||||
with patch(
|
||||
"app.routers.config.config_service.get_service_status",
|
||||
AsyncMock(return_value=self._mock_status(online=False)),
|
||||
):
|
||||
resp = await config_client.get("/api/config/service-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["online"] is False
|
||||
assert data["log_level"] == "UNKNOWN"
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/service-status requires authentication."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/service-status")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@@ -604,3 +604,145 @@ class TestPreviewLog:
|
||||
result = await config_service.preview_log(req)
|
||||
|
||||
assert result.total_lines <= 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_fail2ban_log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReadFail2BanLog:
|
||||
"""Tests for :func:`config_service.read_fail2ban_log`."""
|
||||
|
||||
def _patch_client(self, log_level: str = "INFO", log_target: str = "/var/log/fail2ban.log") -> Any:
|
||||
"""Build a patched Fail2BanClient that returns *log_level* and *log_target*."""
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
if key == "get|loglevel":
|
||||
return (0, log_level)
|
||||
if key == "get|logtarget":
|
||||
return (0, log_target)
|
||||
return (0, None)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
return patch("app.services.config_service.Fail2BanClient", _FakeClient)
|
||||
|
||||
async def test_returns_log_lines_from_file(self, tmp_path: Any) -> None:
|
||||
"""read_fail2ban_log returns lines from the file and counts totals."""
|
||||
log_file = tmp_path / "fail2ban.log"
|
||||
log_file.write_text("line1\nline2\nline3\n")
|
||||
log_dir = str(tmp_path)
|
||||
|
||||
# Patch _SAFE_LOG_PREFIXES to allow tmp_path
|
||||
with self._patch_client(log_target=str(log_file)), \
|
||||
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)):
|
||||
result = await config_service.read_fail2ban_log(_SOCKET, 200)
|
||||
|
||||
assert result.log_path == str(log_file.resolve())
|
||||
assert result.total_lines >= 3
|
||||
assert any("line1" in ln for ln in result.lines)
|
||||
assert result.log_level == "INFO"
|
||||
|
||||
async def test_filter_narrows_returned_lines(self, tmp_path: Any) -> None:
|
||||
"""read_fail2ban_log filters lines by substring."""
|
||||
log_file = tmp_path / "fail2ban.log"
|
||||
log_file.write_text("INFO sshd Found 1.2.3.4\nERROR something else\nINFO sshd Found 5.6.7.8\n")
|
||||
log_dir = str(tmp_path)
|
||||
|
||||
with self._patch_client(log_target=str(log_file)), \
|
||||
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)):
|
||||
result = await config_service.read_fail2ban_log(_SOCKET, 200, "Found")
|
||||
|
||||
assert all("Found" in ln for ln in result.lines)
|
||||
assert result.total_lines >= 3 # total is unfiltered
|
||||
|
||||
async def test_non_file_target_raises_operation_error(self) -> None:
|
||||
"""read_fail2ban_log raises ConfigOperationError for STDOUT target."""
|
||||
with self._patch_client(log_target="STDOUT"), \
|
||||
pytest.raises(config_service.ConfigOperationError, match="STDOUT"):
|
||||
await config_service.read_fail2ban_log(_SOCKET, 200)
|
||||
|
||||
async def test_syslog_target_raises_operation_error(self) -> None:
|
||||
"""read_fail2ban_log raises ConfigOperationError for SYSLOG target."""
|
||||
with self._patch_client(log_target="SYSLOG"), \
|
||||
pytest.raises(config_service.ConfigOperationError, match="SYSLOG"):
|
||||
await config_service.read_fail2ban_log(_SOCKET, 200)
|
||||
|
||||
async def test_path_outside_safe_dir_raises_operation_error(self, tmp_path: Any) -> None:
|
||||
"""read_fail2ban_log rejects a log_target outside allowed directories."""
|
||||
log_file = tmp_path / "secret.log"
|
||||
log_file.write_text("secret data\n")
|
||||
|
||||
# Allow only /var/log — tmp_path is deliberately not in the safe list.
|
||||
with self._patch_client(log_target=str(log_file)), \
|
||||
patch("app.services.config_service._SAFE_LOG_PREFIXES", ("/var/log",)), \
|
||||
pytest.raises(config_service.ConfigOperationError, match="outside the allowed"):
|
||||
await config_service.read_fail2ban_log(_SOCKET, 200)
|
||||
|
||||
async def test_missing_log_file_raises_operation_error(self, tmp_path: Any) -> None:
|
||||
"""read_fail2ban_log raises ConfigOperationError when the file does not exist."""
|
||||
missing = str(tmp_path / "nonexistent.log")
|
||||
log_dir = str(tmp_path)
|
||||
|
||||
with self._patch_client(log_target=missing), \
|
||||
patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)), \
|
||||
pytest.raises(config_service.ConfigOperationError, match="not found"):
|
||||
await config_service.read_fail2ban_log(_SOCKET, 200)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_service_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetServiceStatus:
|
||||
"""Tests for :func:`config_service.get_service_status`."""
|
||||
|
||||
async def test_online_status_includes_log_config(self) -> None:
|
||||
"""get_service_status returns correct fields when fail2ban is online."""
|
||||
from app.models.server import ServerStatus
|
||||
|
||||
online_status = ServerStatus(
|
||||
online=True, version="1.0.0", active_jails=2, total_bans=5, total_failures=3
|
||||
)
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
key = "|".join(str(c) for c in command)
|
||||
if key == "get|loglevel":
|
||||
return (0, "DEBUG")
|
||||
if key == "get|logtarget":
|
||||
return (0, "/var/log/fail2ban.log")
|
||||
return (0, None)
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient), \
|
||||
patch("app.services.health_service.probe", AsyncMock(return_value=online_status)):
|
||||
result = await config_service.get_service_status(_SOCKET)
|
||||
|
||||
assert result.online is True
|
||||
assert result.version == "1.0.0"
|
||||
assert result.jail_count == 2
|
||||
assert result.total_bans == 5
|
||||
assert result.total_failures == 3
|
||||
assert result.log_level == "DEBUG"
|
||||
assert result.log_target == "/var/log/fail2ban.log"
|
||||
|
||||
async def test_offline_status_returns_unknown_log_fields(self) -> None:
|
||||
"""get_service_status returns 'UNKNOWN' log fields when fail2ban is offline."""
|
||||
from app.models.server import ServerStatus
|
||||
|
||||
offline_status = ServerStatus(online=False)
|
||||
|
||||
with patch("app.services.health_service.probe", AsyncMock(return_value=offline_status)):
|
||||
result = await config_service.get_service_status(_SOCKET)
|
||||
|
||||
assert result.online is False
|
||||
assert result.jail_count == 0
|
||||
assert result.log_level == "UNKNOWN"
|
||||
assert result.log_target == "UNKNOWN"
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ConfFileCreateRequest,
|
||||
ConfFilesResponse,
|
||||
ConfFileUpdateRequest,
|
||||
Fail2BanLogResponse,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
FilterCreateRequest,
|
||||
@@ -43,6 +44,7 @@ import type {
|
||||
ServerSettingsUpdate,
|
||||
JailFileConfig,
|
||||
JailFileConfigUpdate,
|
||||
ServiceStatusResponse,
|
||||
} from "../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -541,3 +543,29 @@ export async function deactivateJail(
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fail2ban log viewer (Task 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the tail of the fail2ban daemon log file.
|
||||
*
|
||||
* @param lines - Number of tail lines to return (1–2000, default 200).
|
||||
* @param filter - Optional plain-text substring; only matching lines returned.
|
||||
*/
|
||||
export async function fetchFail2BanLog(
|
||||
lines?: number,
|
||||
filter?: string,
|
||||
): Promise<Fail2BanLogResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (lines !== undefined) params.set("lines", String(lines));
|
||||
if (filter !== undefined && filter !== "") params.set("filter", filter);
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return get<Fail2BanLogResponse>(`${ENDPOINTS.configFail2BanLog}${query}`);
|
||||
}
|
||||
|
||||
/** Fetch fail2ban service health status with current log configuration. */
|
||||
export async function fetchServiceStatus(): Promise<ServiceStatusResponse> {
|
||||
return get<ServiceStatusResponse>(ENDPOINTS.configServiceStatus);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ export const ENDPOINTS = {
|
||||
configActionParsed: (name: string): string =>
|
||||
`/config/actions/${encodeURIComponent(name)}/parsed`,
|
||||
|
||||
// fail2ban log viewer (Task 2)
|
||||
configFail2BanLog: "/config/fail2ban-log",
|
||||
configServiceStatus: "/config/service-status",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Server settings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
518
frontend/src/components/config/LogTab.tsx
Normal file
518
frontend/src/components/config/LogTab.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* LogTab — fail2ban log viewer and service health panel.
|
||||
*
|
||||
* Renders two sections:
|
||||
* 1. **Service Health panel** — shows online/offline state, version, active
|
||||
* jail count, total bans, total failures, log level, and log target.
|
||||
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
||||
* toolbar controls for line count, substring filter, manual refresh, and
|
||||
* optional auto-refresh. Log lines are color-coded by severity.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowClockwise24Regular,
|
||||
DocumentBulletList24Regular,
|
||||
Filter24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../api/config";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Available line-count options for the log tail. */
|
||||
const LINE_COUNT_OPTIONS: number[] = [100, 200, 500, 1000];
|
||||
|
||||
/** Auto-refresh interval options in seconds. */
|
||||
const AUTO_REFRESH_INTERVALS: { label: string; value: number }[] = [
|
||||
{ label: "5 s", value: 5 },
|
||||
{ label: "10 s", value: 10 },
|
||||
{ label: "30 s", value: 30 },
|
||||
];
|
||||
|
||||
/** Debounce delay for the filter input in milliseconds. */
|
||||
const FILTER_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Log targets that are not file paths — file-based viewing is unavailable. */
|
||||
const NON_FILE_TARGETS = new Set(["STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
healthGrid: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
marginTop: tokens.spacingVerticalM,
|
||||
},
|
||||
statCard: {
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalXS,
|
||||
},
|
||||
statLabel: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
},
|
||||
statValue: {
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
fontSize: tokens.fontSizeBase300,
|
||||
},
|
||||
metaRow: {
|
||||
display: "flex",
|
||||
gap: tokens.spacingHorizontalL,
|
||||
marginTop: tokens.spacingVerticalS,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
metaItem: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
},
|
||||
toolbar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
flexWrap: "wrap",
|
||||
marginBottom: tokens.spacingVerticalM,
|
||||
},
|
||||
filterInput: {
|
||||
width: "200px",
|
||||
},
|
||||
logContainer: {
|
||||
backgroundColor: tokens.colorNeutralBackground4,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
|
||||
maxHeight: "560px",
|
||||
overflowY: "auto",
|
||||
fontFamily: "monospace",
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
lineHeight: "1.6",
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
logLineError: {
|
||||
color: tokens.colorPaletteRedForeground1,
|
||||
},
|
||||
logLineWarning: {
|
||||
color: tokens.colorPaletteYellowForeground1,
|
||||
},
|
||||
logLineDebug: {
|
||||
color: tokens.colorNeutralForeground4,
|
||||
},
|
||||
logLineDefault: {
|
||||
color: tokens.colorNeutralForeground1,
|
||||
},
|
||||
truncatedBanner: {
|
||||
marginBottom: tokens.spacingVerticalS,
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontSize: tokens.fontSizeBase100,
|
||||
},
|
||||
emptyLog: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontStyle: "italic",
|
||||
padding: tokens.spacingVerticalS,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Determine the CSS class key for a log line based on its severity.
|
||||
*
|
||||
* fail2ban formats log lines as:
|
||||
* ``2025-… fail2ban.filter [PID]: LEVEL message``
|
||||
*
|
||||
* @param line - A single log line string.
|
||||
* @returns The severity key: "error" | "warning" | "debug" | "default".
|
||||
*/
|
||||
function detectSeverity(line: string): "error" | "warning" | "debug" | "default" {
|
||||
const upper = line.toUpperCase();
|
||||
if (upper.includes(" ERROR ") || upper.includes(" CRITICAL ")) return "error";
|
||||
if (upper.includes(" WARNING ") || upper.includes(": WARNING") || upper.includes(" WARN ")) return "warning";
|
||||
if (upper.includes(" DEBUG ")) return "debug";
|
||||
return "default";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Log tab component for the Configuration page.
|
||||
*
|
||||
* Shows fail2ban service health and a live log viewer with refresh controls.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function LogTab(): React.JSX.Element {
|
||||
const configStyles = useConfigStyles();
|
||||
const styles = useStyles();
|
||||
|
||||
// ---- data state ----------------------------------------------------------
|
||||
const [status, setStatus] = useState<ServiceStatusResponse | null>(null);
|
||||
const [logData, setLogData] = useState<Fail2BanLogResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// ---- toolbar state -------------------------------------------------------
|
||||
const [linesCount, setLinesCount] = useState<number>(200);
|
||||
const [filterRaw, setFilterRaw] = useState<string>("");
|
||||
const [filterValue, setFilterValue] = useState<string>("");
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [refreshInterval, setRefreshInterval] = useState(10);
|
||||
|
||||
// ---- refs ----------------------------------------------------------------
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ---- scroll helper -------------------------------------------------------
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- fetch logic ---------------------------------------------------------
|
||||
const fetchData = useCallback(
|
||||
async (showSpinner: boolean): Promise<void> => {
|
||||
if (showSpinner) setIsRefreshing(true);
|
||||
try {
|
||||
// Use allSettled so a log-read failure doesn't hide the service status.
|
||||
const [svcResult, logResult] = await Promise.allSettled([
|
||||
fetchServiceStatus(),
|
||||
fetchFail2BanLog(linesCount, filterValue || undefined),
|
||||
]);
|
||||
|
||||
if (svcResult.status === "fulfilled") {
|
||||
setStatus(svcResult.value);
|
||||
} else {
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
if (logResult.status === "fulfilled") {
|
||||
setLogData(logResult.value);
|
||||
setError(null);
|
||||
} else {
|
||||
const reason: unknown = logResult.reason;
|
||||
setError(reason instanceof Error ? reason.message : "Failed to load log data.");
|
||||
}
|
||||
} finally {
|
||||
if (showSpinner) setIsRefreshing(false);
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[linesCount, filterValue],
|
||||
);
|
||||
|
||||
// ---- initial load --------------------------------------------------------
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void fetchData(false);
|
||||
}, [fetchData]);
|
||||
|
||||
// ---- scroll to bottom when new log data arrives -------------------------
|
||||
useEffect(() => {
|
||||
if (logData) {
|
||||
// Tiny timeout lets the DOM paint before scrolling.
|
||||
const t = setTimeout(scrollToBottom, 50);
|
||||
return (): void => { clearTimeout(t); };
|
||||
}
|
||||
}, [logData, scrollToBottom]);
|
||||
|
||||
// ---- auto-refresh interval ----------------------------------------------
|
||||
useEffect(() => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
autoRefreshTimerRef.current = null;
|
||||
}
|
||||
if (autoRefresh) {
|
||||
autoRefreshTimerRef.current = setInterval(() => {
|
||||
void fetchData(true);
|
||||
}, refreshInterval * 1000);
|
||||
}
|
||||
return (): void => {
|
||||
if (autoRefreshTimerRef.current) {
|
||||
clearInterval(autoRefreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, refreshInterval, fetchData]);
|
||||
|
||||
// ---- filter debounce ----------------------------------------------------
|
||||
const handleFilterChange = useCallback((value: string): void => {
|
||||
setFilterRaw(value);
|
||||
if (filterDebounceRef.current) clearTimeout(filterDebounceRef.current);
|
||||
filterDebounceRef.current = setTimeout(() => {
|
||||
setFilterValue(value);
|
||||
}, FILTER_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
// ---- render helpers ------------------------------------------------------
|
||||
const renderLogLine = (line: string, idx: number): React.JSX.Element => {
|
||||
const severity = detectSeverity(line);
|
||||
const className =
|
||||
severity === "error"
|
||||
? styles.logLineError
|
||||
: severity === "warning"
|
||||
? styles.logLineWarning
|
||||
: severity === "debug"
|
||||
? styles.logLineDebug
|
||||
: styles.logLineDefault;
|
||||
return (
|
||||
<div key={idx} className={className}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- loading state -------------------------------------------------------
|
||||
if (loading) {
|
||||
return <Spinner label="Loading log viewer…" />;
|
||||
}
|
||||
|
||||
// ---- error state ---------------------------------------------------------
|
||||
if (error && !status && !logData) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
|
||||
const isNonFileTarget =
|
||||
logData?.log_target != null &&
|
||||
NON_FILE_TARGETS.has(logData.log_target.toUpperCase());
|
||||
|
||||
const isTruncated =
|
||||
logData != null && logData.total_lines > logData.lines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Service Health Panel */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<DocumentBulletList24Regular />
|
||||
<Text weight="semibold" size={400}>
|
||||
Service Health
|
||||
</Text>
|
||||
{status?.online ? (
|
||||
<Badge appearance="filled" color="success">
|
||||
Running
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge appearance="filled" color="danger">
|
||||
Offline
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && !status.online && (
|
||||
<MessageBar intent="warning" style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<MessageBarBody>
|
||||
fail2ban is not running or unreachable. Check the server and socket
|
||||
configuration.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{status?.online && (
|
||||
<>
|
||||
<div className={styles.healthGrid}>
|
||||
{status.version && (
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Version</Text>
|
||||
<Text className={styles.statValue}>{status.version}</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Active Jails</Text>
|
||||
<Text className={styles.statValue}>{status.jail_count}</Text>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Currently Banned</Text>
|
||||
<Text className={styles.statValue}>{status.total_bans}</Text>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<Text className={styles.statLabel}>Currently Failed</Text>
|
||||
<Text className={styles.statValue}>{status.total_failures}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<div className={styles.metaItem}>
|
||||
<Text className={styles.statLabel}>Log Level</Text>
|
||||
<Text size={300}>{status.log_level}</Text>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<Text className={styles.statLabel}>Log Target</Text>
|
||||
<Text size={300}>{status.log_target}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Log Viewer */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
||||
<Text weight="semibold" size={400}>
|
||||
Log Viewer
|
||||
</Text>
|
||||
{logData && (
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
{logData.log_path}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Non-file target info banner */}
|
||||
{isNonFileTarget && (
|
||||
<MessageBar intent="info" style={{ marginBottom: tokens.spacingVerticalM }}>
|
||||
<MessageBarBody>
|
||||
fail2ban is logging to <strong>{logData.log_target}</strong>.
|
||||
File-based log viewing is not available.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
{/* Toolbar — only shown when log data is available */}
|
||||
{!isNonFileTarget && (
|
||||
<>
|
||||
<div className={styles.toolbar}>
|
||||
{/* Filter input */}
|
||||
<Field label="Filter">
|
||||
<Input
|
||||
className={styles.filterInput}
|
||||
type="text"
|
||||
value={filterRaw}
|
||||
contentBefore={<Filter24Regular />}
|
||||
placeholder="Substring filter…"
|
||||
onChange={(_e, d) => { handleFilterChange(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Lines count selector */}
|
||||
<Field label="Lines">
|
||||
<Select
|
||||
value={String(linesCount)}
|
||||
onChange={(_e, d) => { setLinesCount(Number(d.value)); }}
|
||||
>
|
||||
{LINE_COUNT_OPTIONS.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{/* Manual refresh */}
|
||||
<div style={{ alignSelf: "flex-end" }}>
|
||||
<Button
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
appearance="secondary"
|
||||
onClick={() => void fetchData(true)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh toggle */}
|
||||
<div style={{ alignSelf: "flex-end" }}>
|
||||
<Switch
|
||||
label="Auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onChange={(_e, d) => { setAutoRefresh(d.checked); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh interval selector */}
|
||||
{autoRefresh && (
|
||||
<Field label="Interval">
|
||||
<Select
|
||||
value={String(refreshInterval)}
|
||||
onChange={(_e, d) => { setRefreshInterval(Number(d.value)); }}
|
||||
>
|
||||
{AUTO_REFRESH_INTERVALS.map((opt) => (
|
||||
<option key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Truncation notice */}
|
||||
{isTruncated && (
|
||||
<Text className={styles.truncatedBanner} block>
|
||||
Showing last {logData.lines.length} of {logData.total_lines} lines.
|
||||
Increase the line count or use the filter to narrow results.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Log lines container */}
|
||||
<div className={styles.logContainer} ref={logContainerRef}>
|
||||
{isRefreshing && (
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<Spinner size="tiny" label="Refreshing…" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logData && logData.lines.length === 0 ? (
|
||||
<div className={styles.emptyLog}>
|
||||
{filterValue
|
||||
? `No lines match the filter "${filterValue}".`
|
||||
: "No log entries found."}
|
||||
</div>
|
||||
) : (
|
||||
logData?.lines.map((line, idx) => renderLogLine(line, idx))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General fetch error */}
|
||||
{error && (
|
||||
<MessageBar intent="error" style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/components/config/__tests__/LogTab.test.tsx
Normal file
189
frontend/src/components/config/__tests__/LogTab.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Tests for the LogTab component (Task 2).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { LogTab } from "../LogTab";
|
||||
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../../api/config", () => ({
|
||||
fetchFail2BanLog: vi.fn(),
|
||||
fetchServiceStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
|
||||
|
||||
const mockFetchLog = vi.mocked(fetchFail2BanLog);
|
||||
const mockFetchStatus = vi.mocked(fetchServiceStatus);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onlineStatus: ServiceStatusResponse = {
|
||||
online: true,
|
||||
version: "1.0.2",
|
||||
jail_count: 3,
|
||||
total_bans: 12,
|
||||
total_failures: 5,
|
||||
log_level: "INFO",
|
||||
log_target: "/var/log/fail2ban.log",
|
||||
};
|
||||
|
||||
const offlineStatus: ServiceStatusResponse = {
|
||||
online: false,
|
||||
version: null,
|
||||
jail_count: 0,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
log_level: "UNKNOWN",
|
||||
log_target: "UNKNOWN",
|
||||
};
|
||||
|
||||
const logResponse: Fail2BanLogResponse = {
|
||||
log_path: "/var/log/fail2ban.log",
|
||||
lines: [
|
||||
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
|
||||
"2025-01-01 12:00:01 WARNING sshd Too many failures",
|
||||
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
|
||||
],
|
||||
total_lines: 1000,
|
||||
log_level: "INFO",
|
||||
log_target: "/var/log/fail2ban.log",
|
||||
};
|
||||
|
||||
const nonFileLogResponse: Fail2BanLogResponse = {
|
||||
...logResponse,
|
||||
log_target: "STDOUT",
|
||||
lines: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<LogTab />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("LogTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a spinner while loading", () => {
|
||||
// Never resolves during this test.
|
||||
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
|
||||
mockFetchLog.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the health panel with Running badge when online", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/loading log viewer/i)).toBeNull());
|
||||
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.0.2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
|
||||
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
|
||||
});
|
||||
|
||||
it("renders the Offline badge and warning when fail2ban is down", async () => {
|
||||
mockFetchStatus.mockResolvedValue(offlineStatus);
|
||||
mockFetchLog.mockRejectedValue(new Error("not running"));
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/loading log viewer/i)).toBeNull());
|
||||
|
||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
||||
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders log lines in the log viewer", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-file target info banner when log_target is STDOUT", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(nonFileLogResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Refresh/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state when no lines match the filter", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows truncation notice when total_lines > lines.length", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/showing last/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls fetchFail2BanLog again on Refresh button click", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Refresh/)).toBeInTheDocument());
|
||||
|
||||
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
|
||||
await user.click(refreshBtn);
|
||||
|
||||
await waitFor(() => expect(mockFetchLog).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export { GlobalTab } from "./GlobalTab";
|
||||
export { JailFilesTab } from "./JailFilesTab";
|
||||
export { JailFileForm } from "./JailFileForm";
|
||||
export { JailsTab } from "./JailsTab";
|
||||
export { LogTab } from "./LogTab";
|
||||
export { MapTab } from "./MapTab";
|
||||
export { RawConfigSection } from "./RawConfigSection";
|
||||
export type { RawConfigSectionProps } from "./RawConfigSection";
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FiltersTab,
|
||||
GlobalTab,
|
||||
JailsTab,
|
||||
LogTab,
|
||||
MapTab,
|
||||
RegexTesterTab,
|
||||
ServerTab,
|
||||
@@ -60,7 +61,8 @@ type TabValue =
|
||||
| "global"
|
||||
| "server"
|
||||
| "map"
|
||||
| "regex";
|
||||
| "regex"
|
||||
| "log";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
@@ -91,6 +93,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
<Tab value="log">Log</Tab>
|
||||
</TabList>
|
||||
|
||||
<div className={styles.tabContent} key={tab}>
|
||||
@@ -101,6 +104,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
{tab === "regex" && <RegexTesterTab />}
|
||||
{tab === "log" && <LogTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -592,3 +592,39 @@ export interface FilterCreateRequest {
|
||||
export interface AssignFilterRequest {
|
||||
filter_name: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fail2ban log viewer types (Task 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Response for ``GET /api/config/fail2ban-log``. */
|
||||
export interface Fail2BanLogResponse {
|
||||
/** Resolved absolute path of the log file being read. */
|
||||
log_path: string;
|
||||
/** Log lines (tail of file, optionally filtered by substring). */
|
||||
lines: string[];
|
||||
/** Total number of lines in the file before any filtering. */
|
||||
total_lines: number;
|
||||
/** Current fail2ban log level, e.g. "INFO". */
|
||||
log_level: string;
|
||||
/** Current fail2ban log target (file path or special value like "STDOUT"). */
|
||||
log_target: string;
|
||||
}
|
||||
|
||||
/** Response for ``GET /api/config/service-status``. */
|
||||
export interface ServiceStatusResponse {
|
||||
/** Whether fail2ban is reachable via its socket. */
|
||||
online: boolean;
|
||||
/** fail2ban version string, or null when offline. */
|
||||
version: string | null;
|
||||
/** Number of currently active jails. */
|
||||
jail_count: number;
|
||||
/** Aggregated current ban count across all jails. */
|
||||
total_bans: number;
|
||||
/** Aggregated current failure count across all jails. */
|
||||
total_failures: number;
|
||||
/** Current fail2ban log level. */
|
||||
log_level: string;
|
||||
/** Current fail2ban log target. */
|
||||
log_target: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user