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:
2026-03-14 12:54:03 +01:00
parent 5e1b8134d9
commit ab11ece001
15 changed files with 1527 additions and 4 deletions

View File

@@ -152,7 +152,7 @@ The HTTP interface layer. Each router maps URL paths to handler functions. Route
| `dashboard.py` | `/api/dashboard` | Server status bar data, recent bans for the dashboard | | `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 | | `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 | | `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 | | `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 | | `history.py` | `/api/history` | Query historical bans, per-IP timeline |
| `blocklist.py` | `/api/blocklists` | CRUD blocklist sources, trigger import, view import logs | | `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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text |

View File

@@ -220,6 +220,27 @@ A page to inspect and modify the fail2ban configuration without leaving the web
- Countries with zero bans remain transparent (no fill). - Countries with zero bans remain transparent (no fill).
- Changes take effect immediately on the World Map view without requiring a page reload. - 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 ## 7. Ban History

View File

@@ -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 ## 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) **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 ### 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. 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. - 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.
---

View File

@@ -860,3 +860,34 @@ class JailActivationResponse(BaseModel):
description="New activation state: ``True`` after activate, ``False`` after deactivate.", description="New activation state: ``True`` after activate, ``False`` after deactivate.",
) )
message: str = Field(..., description="Human-readable result message.") 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.")

View File

@@ -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 * ``PUT /api/config/actions/{name}`` — update an action's .local override
* ``POST /api/config/actions`` — create a new user-defined action * ``POST /api/config/actions`` — create a new user-defined action
* ``DELETE /api/config/actions/{name}`` — delete an action's .local file * ``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 from __future__ import annotations
@@ -46,6 +48,7 @@ from app.models.config import (
AddLogPathRequest, AddLogPathRequest,
AssignActionRequest, AssignActionRequest,
AssignFilterRequest, AssignFilterRequest,
Fail2BanLogResponse,
FilterConfig, FilterConfig,
FilterCreateRequest, FilterCreateRequest,
FilterListResponse, FilterListResponse,
@@ -63,6 +66,7 @@ from app.models.config import (
MapColorThresholdsUpdate, MapColorThresholdsUpdate,
RegexTestRequest, RegexTestRequest,
RegexTestResponse, RegexTestResponse,
ServiceStatusResponse,
) )
from app.services import config_file_service, config_service, jail_service from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import ( 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}", detail=f"Failed to write jail override: {exc}",
) from exc ) from exc
# ---------------------------------------------------------------------------
# fail2ban log viewer endpoints
# ---------------------------------------------------------------------------
@router.get(
"/fail2ban-log",
response_model=Fail2BanLogResponse,
summary="Read the tail of the fail2ban daemon log file",
)
async def get_fail2ban_log(
request: Request,
_auth: AuthDep,
lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200,
filter: Annotated[ # noqa: A002
str | None,
Query(description="Plain-text substring filter; only matching lines are returned."),
] = None,
) -> Fail2BanLogResponse:
"""Return the tail of the fail2ban daemon log file.
Queries the fail2ban socket for the current log target and log level,
reads the last *lines* entries from the file, and optionally filters
them by *filter*. Only file-based log targets are supported.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
lines: Number of tail lines to return (12000, default 200).
filter: Optional plain-text substring — only matching lines returned.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
HTTPException: 400 when the log target is not a file or path is outside
the allowed directory.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.read_fail2ban_log(socket_path, lines, filter)
except config_service.ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get(
"/service-status",
response_model=ServiceStatusResponse,
summary="Return fail2ban service health status with log configuration",
)
async def get_service_status(
request: Request,
_auth: AuthDep,
) -> ServiceStatusResponse:
"""Return fail2ban service health and current log configuration.
Probes the fail2ban daemon to determine online/offline state, then
augments the result with the current log level and log target values.
Args:
request: Incoming request.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable (the service itself
handles this gracefully and returns ``online=False``).
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.get_service_status(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -26,6 +26,7 @@ if TYPE_CHECKING:
from app.models.config import ( from app.models.config import (
AddLogPathRequest, AddLogPathRequest,
BantimeEscalation, BantimeEscalation,
Fail2BanLogResponse,
GlobalConfigResponse, GlobalConfigResponse,
GlobalConfigUpdate, GlobalConfigUpdate,
JailConfig, JailConfig,
@@ -39,6 +40,7 @@ from app.models.config import (
MapColorThresholdsUpdate, MapColorThresholdsUpdate,
RegexTestRequest, RegexTestRequest,
RegexTestResponse, RegexTestResponse,
ServiceStatusResponse,
) )
from app.services import setup_service from app.services import setup_service
from app.utils.fail2ban_client import Fail2BanClient from app.utils.fail2ban_client import Fail2BanClient
@@ -754,3 +756,174 @@ async def update_map_color_thresholds(
threshold_medium=update.threshold_medium, threshold_medium=update.threshold_medium,
threshold_low=update.threshold_low, 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 (12000).
filter_text: Optional plain-text substring — only matching lines are
returned. Applied server-side; does not affect *total_lines*.
Returns:
:class:`~app.models.config.Fail2BanLogResponse`.
Raises:
ConfigOperationError: When the log target is not a file, when the
resolved path is outside the allowed directories, or when the
file cannot be read.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
)
log_level = str(log_level_raw or "INFO").upper()
log_target = str(log_target_raw or "STDOUT")
# Reject non-file targets up front.
if log_target.upper() in _NON_FILE_LOG_TARGETS:
raise ConfigOperationError(
f"fail2ban is logging to {log_target!r}. "
"File-based log viewing is only available when fail2ban logs to a file path."
)
# Resolve and validate (security: no path traversal outside safe dirs).
try:
resolved = Path(log_target).resolve()
except (ValueError, OSError) as exc:
raise ConfigOperationError(
f"Cannot resolve log target path {log_target!r}: {exc}"
) from exc
resolved_str = str(resolved)
if not any(resolved_str.startswith(safe) for safe in _SAFE_LOG_PREFIXES):
raise ConfigOperationError(
f"Log path {resolved_str!r} is outside the allowed directory. "
"Only paths under /var/log are permitted."
)
if not resolved.is_file():
raise ConfigOperationError(f"Log file not found: {resolved_str!r}")
loop = asyncio.get_event_loop()
total_lines, raw_lines = await asyncio.gather(
loop.run_in_executor(None, _count_file_lines, resolved_str),
loop.run_in_executor(None, _read_tail_lines, resolved_str, lines),
)
filtered = (
[ln for ln in raw_lines if filter_text in ln]
if filter_text
else raw_lines
)
log.info(
"fail2ban_log_read",
log_path=resolved_str,
lines_requested=lines,
lines_returned=len(filtered),
filter_active=filter_text is not None,
)
return Fail2BanLogResponse(
log_path=resolved_str,
lines=filtered,
total_lines=total_lines,
log_level=log_level,
log_target=log_target,
)
async def get_service_status(socket_path: str) -> ServiceStatusResponse:
"""Return fail2ban service health status with log configuration.
Delegates to :func:`~app.services.health_service.probe` for the core
health snapshot and augments it with the current log-level and log-target
values from the socket.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.ServiceStatusResponse`.
"""
from app.services.health_service import probe # lazy import avoids circular dep
server_status = await probe(socket_path)
if server_status.online:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
)
log_level = str(log_level_raw or "INFO").upper()
log_target = str(log_target_raw or "STDOUT")
else:
log_level = "UNKNOWN"
log_target = "UNKNOWN"
log.info(
"service_status_fetched",
online=server_status.online,
jail_count=server_status.active_jails,
)
return ServiceStatusResponse(
online=server_status.online,
version=server_status.version,
jail_count=server_status.active_jails,
total_bans=server_status.total_bans,
total_failures=server_status.total_failures,
log_level=log_level,
log_target=log_target,
)

View File

@@ -13,12 +13,14 @@ from app.config import Settings
from app.db import init_db from app.db import init_db
from app.main import create_app from app.main import create_app
from app.models.config import ( from app.models.config import (
Fail2BanLogResponse,
FilterConfig, FilterConfig,
GlobalConfigResponse, GlobalConfigResponse,
JailConfig, JailConfig,
JailConfigListResponse, JailConfigListResponse,
JailConfigResponse, JailConfigResponse,
RegexTestResponse, RegexTestResponse,
ServiceStatusResponse,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1711,3 +1713,164 @@ class TestRemoveActionFromJailRouter:
).delete("/api/config/jails/sshd/action/iptables") ).delete("/api/config/jails/sshd/action/iptables")
assert resp.status_code == 401 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

View File

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

View File

@@ -18,6 +18,7 @@ import type {
ConfFileCreateRequest, ConfFileCreateRequest,
ConfFilesResponse, ConfFilesResponse,
ConfFileUpdateRequest, ConfFileUpdateRequest,
Fail2BanLogResponse,
FilterConfig, FilterConfig,
FilterConfigUpdate, FilterConfigUpdate,
FilterCreateRequest, FilterCreateRequest,
@@ -43,6 +44,7 @@ import type {
ServerSettingsUpdate, ServerSettingsUpdate,
JailFileConfig, JailFileConfig,
JailFileConfigUpdate, JailFileConfigUpdate,
ServiceStatusResponse,
} from "../types/config"; } from "../types/config";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -541,3 +543,29 @@ export async function deactivateJail(
undefined undefined
); );
} }
// ---------------------------------------------------------------------------
// fail2ban log viewer (Task 2)
// ---------------------------------------------------------------------------
/**
* Fetch the tail of the fail2ban daemon log file.
*
* @param lines - Number of tail lines to return (12000, default 200).
* @param filter - Optional plain-text substring; only matching lines returned.
*/
export async function fetchFail2BanLog(
lines?: number,
filter?: string,
): Promise<Fail2BanLogResponse> {
const params = new URLSearchParams();
if (lines !== undefined) params.set("lines", String(lines));
if (filter !== undefined && filter !== "") params.set("filter", filter);
const query = params.toString() ? `?${params.toString()}` : "";
return get<Fail2BanLogResponse>(`${ENDPOINTS.configFail2BanLog}${query}`);
}
/** Fetch fail2ban service health status with current log configuration. */
export async function fetchServiceStatus(): Promise<ServiceStatusResponse> {
return get<ServiceStatusResponse>(ENDPOINTS.configServiceStatus);
}

View File

@@ -100,6 +100,10 @@ export const ENDPOINTS = {
configActionParsed: (name: string): string => configActionParsed: (name: string): string =>
`/config/actions/${encodeURIComponent(name)}/parsed`, `/config/actions/${encodeURIComponent(name)}/parsed`,
// fail2ban log viewer (Task 2)
configFail2BanLog: "/config/fail2ban-log",
configServiceStatus: "/config/service-status",
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Server settings // Server settings
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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

View File

@@ -592,3 +592,39 @@ export interface FilterCreateRequest {
export interface AssignFilterRequest { export interface AssignFilterRequest {
filter_name: string; 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;
}