From ab11ece0018b129334bf0dc4e792b6d9eb49e00e Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 14 Mar 2026 12:54:03 +0100 Subject: [PATCH] Add fail2ban log viewer and service health to Config page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Docs/Architekture.md | 4 +- Docs/Features.md | 21 + Docs/Tasks.md | 131 ++++- backend/app/models/config.py | 31 ++ backend/app/routers/config.py | 84 +++ backend/app/services/config_service.py | 173 ++++++ backend/tests/test_routers/test_config.py | 163 ++++++ .../test_services/test_config_service.py | 142 +++++ frontend/src/api/config.ts | 28 + frontend/src/api/endpoints.ts | 4 + frontend/src/components/config/LogTab.tsx | 518 ++++++++++++++++++ .../config/__tests__/LogTab.test.tsx | 189 +++++++ frontend/src/components/config/index.ts | 1 + frontend/src/pages/ConfigPage.tsx | 6 +- frontend/src/types/config.ts | 36 ++ 15 files changed, 1527 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/config/LogTab.tsx create mode 100644 frontend/src/components/config/__tests__/LogTab.test.tsx diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 2abb81d..10a0fcc 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -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 | diff --git a/Docs/Features.md b/Docs/Features.md index 3e8fea9..d66495b 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -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 diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 96dc860..14c570e 100644 --- a/Docs/Tasks.md +++ b/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 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 `` 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` + - 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 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. + +--- diff --git a/backend/app/models/config.py b/backend/app/models/config.py index 93fea9c..47693d9 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -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.") diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index e39a496..ac2f7df 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -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 + diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index c87471d..b700f95 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -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, + ) diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index 4bcd47d..1b3d8c9 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -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 + diff --git a/backend/tests/test_services/test_config_service.py b/backend/tests/test_services/test_config_service.py index dbf6595..6b90074 100644 --- a/backend/tests/test_services/test_config_service.py +++ b/backend/tests/test_services/test_config_service.py @@ -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" diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 18ee91d..ff7bdf2 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -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 { + 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(`${ENDPOINTS.configFail2BanLog}${query}`); +} + +/** Fetch fail2ban service health status with current log configuration. */ +export async function fetchServiceStatus(): Promise { + return get(ENDPOINTS.configServiceStatus); +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 2801b6f..c9aa2be 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -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 // ------------------------------------------------------------------------- diff --git a/frontend/src/components/config/LogTab.tsx b/frontend/src/components/config/LogTab.tsx new file mode 100644 index 0000000..f1ca66d --- /dev/null +++ b/frontend/src/components/config/LogTab.tsx @@ -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(null); + const [logData, setLogData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + // ---- toolbar state ------------------------------------------------------- + const [linesCount, setLinesCount] = useState(200); + const [filterRaw, setFilterRaw] = useState(""); + const [filterValue, setFilterValue] = useState(""); + const [autoRefresh, setAutoRefresh] = useState(false); + const [refreshInterval, setRefreshInterval] = useState(10); + + // ---- refs ---------------------------------------------------------------- + const logContainerRef = useRef(null); + const filterDebounceRef = useRef | null>(null); + const autoRefreshTimerRef = useRef | 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 => { + 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 ( +
+ {line} +
+ ); + }; + + // ---- loading state ------------------------------------------------------- + if (loading) { + return ; + } + + // ---- error state --------------------------------------------------------- + if (error && !status && !logData) { + return ( + + {error} + + ); + } + + 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 ( +
+ {/* ------------------------------------------------------------------ */} + {/* Service Health Panel */} + {/* ------------------------------------------------------------------ */} +
+
+ + + Service Health + + {status?.online ? ( + + Running + + ) : ( + + Offline + + )} +
+ + {status && !status.online && ( + + + fail2ban is not running or unreachable. Check the server and socket + configuration. + + + )} + + {status?.online && ( + <> +
+ {status.version && ( +
+ Version + {status.version} +
+ )} +
+ Active Jails + {status.jail_count} +
+
+ Currently Banned + {status.total_bans} +
+
+ Currently Failed + {status.total_failures} +
+
+ +
+
+ Log Level + {status.log_level} +
+
+ Log Target + {status.log_target} +
+
+ + )} +
+ + {/* ------------------------------------------------------------------ */} + {/* Log Viewer */} + {/* ------------------------------------------------------------------ */} +
+
+ + Log Viewer + + {logData && ( + + {logData.log_path} + + )} +
+ + {/* Non-file target info banner */} + {isNonFileTarget && ( + + + fail2ban is logging to {logData.log_target}. + File-based log viewing is not available. + + + )} + + {/* Toolbar — only shown when log data is available */} + {!isNonFileTarget && ( + <> +
+ {/* Filter input */} + + } + placeholder="Substring filter…" + onChange={(_e, d) => { handleFilterChange(d.value); }} + /> + + + {/* Lines count selector */} + + + + + {/* Manual refresh */} +
+ +
+ + {/* Auto-refresh toggle */} +
+ { setAutoRefresh(d.checked); }} + /> +
+ + {/* Auto-refresh interval selector */} + {autoRefresh && ( + + + + )} +
+ + {/* Truncation notice */} + {isTruncated && ( + + Showing last {logData.lines.length} of {logData.total_lines} lines. + Increase the line count or use the filter to narrow results. + + )} + + {/* Log lines container */} +
+ {isRefreshing && ( +
+ +
+ )} + + {logData && logData.lines.length === 0 ? ( +
+ {filterValue + ? `No lines match the filter "${filterValue}".` + : "No log entries found."} +
+ ) : ( + logData?.lines.map((line, idx) => renderLogLine(line, idx)) + )} +
+ + {/* General fetch error */} + {error && ( + + {error} + + )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/config/__tests__/LogTab.test.tsx b/frontend/src/components/config/__tests__/LogTab.test.tsx new file mode 100644 index 0000000..8667011 --- /dev/null +++ b/frontend/src/components/config/__tests__/LogTab.test.tsx @@ -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( + + + , + ); +} + +// --------------------------------------------------------------------------- +// 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)); + }); +}); diff --git a/frontend/src/components/config/index.ts b/frontend/src/components/config/index.ts index 1f8fe8a..37f8bac 100644 --- a/frontend/src/components/config/index.ts +++ b/frontend/src/components/config/index.ts @@ -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"; diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index c322bce..6e09fae 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -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 { Server Map Regex Tester + Log
@@ -101,6 +104,7 @@ export function ConfigPage(): React.JSX.Element { {tab === "server" && } {tab === "map" && } {tab === "regex" && } + {tab === "log" && }
); diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 812be64..b6f0c17 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -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; +}