Compare commits

...

36 Commits

Author SHA1 Message Date
81f99d0b50 release script 2026-03-16 20:04:36 +01:00
030bca09b7 version 2026-03-16 20:02:22 +01:00
5b7d1a4360 Fix Failures tooltip wording; move Service Health to top of Server tab
Task 2: rename 'Failures:' label to 'Failed Attempts:' and update tooltip
to 'Total failed authentication attempts currently tracked by fail2ban
across all active jails' — more accurate than 'Currently failing IPs'.

Task 3: move <ServerHealthSection /> to the top of ServerTab so users
see connectivity status before scrolling through all settings fields.
2026-03-16 19:48:39 +01:00
e7834a888e Show BanGUI app version in sidebar, fix version tooltips
- Inject __APP_VERSION__ at build time via vite.config.ts define (reads
  frontend/package.json#version); declare the global in vite-env.d.ts.
- Render 'BanGUI v{__APP_VERSION__}' in the sidebar footer (MainLayout)
  when expanded; hidden when collapsed.
- Rename fail2ban version tooltip to 'fail2ban daemon version' in
  ServerStatusBar so it is visually distinct from the app version.
- Sync frontend/package.json version (0.9.0 → 0.9.3) to match
  Docker/VERSION; update release.sh to keep them in sync on every bump.
- Add vitest define stub for __APP_VERSION__ so tests compile cleanly.
- Add ServerStatusBar and MainLayout test suites (10 new test cases).
2026-03-16 19:45:55 +01:00
abb224e01b Docker: add PUID/PGID env vars, fix env format, add release script and VERSION 2026-03-16 19:22:16 +01:00
57cf93b1e5 Add ensure_jail_configs startup check for required jail config files
On startup BanGUI now verifies that the four fail2ban jail config files
required by its two custom jails (manual-Jail and blocklist-import) are
present in `$fail2ban_config_dir/jail.d`.  Any missing file is created
with the correct default content; existing files are never overwritten.

Files managed:
  - manual-Jail.conf        (enabled=false template)
  - manual-Jail.local       (enabled=true override)
  - blocklist-import.conf   (enabled=false template)
  - blocklist-import.local  (enabled=true override)

The check runs in the lifespan hook immediately after logging is
configured, before the database is opened.
2026-03-16 16:26:39 +01:00
c41165c294 Remove client-side SHA-256 pre-hashing from setup and login
The sha256Hex helper used window.crypto.subtle.digest(), which is only
available in a secure context (HTTPS / localhost). In the HTTP Docker
environment crypto.subtle is undefined, causing a TypeError before any
request is sent — the setup and login forms both silently failed with
'An unexpected error occurred'.

Fix: pass raw passwords directly to the API. The backend already applies
bcrypt, which is sufficient. No stored hashes need migration because
setup never completed successfully in the HTTP environment.

* frontend/src/pages/SetupPage.tsx  — remove sha256Hex call
* frontend/src/api/auth.ts          — remove sha256Hex call
* frontend/src/pages/__tests__/SetupPage.test.tsx — drop crypto mock
* frontend/src/utils/crypto.ts      — deleted (no remaining callers)
2026-03-15 21:29:23 +01:00
cdf73e2d65 docker files 2026-03-15 18:10:25 +01:00
21753c4f06 Fix Stage 0 bootstrap and startup regression
Task 0.1: Create database parent directory before connecting
- main.py _lifespan now calls Path(database_path).parent.mkdir(parents=True,
  exist_ok=True) before aiosqlite.connect() so the app starts cleanly on
  a fresh Docker volume with a nested database path.

Task 0.2: SetupRedirectMiddleware redirects when db is None
- Guard now reads: if db is None or not is_setup_complete(db)
  A missing database (startup still in progress) is treated as setup not
  complete instead of silently allowing all API routes through.

Task 0.3: SetupGuard redirects to /setup on API failure
- .catch() handler now sets status to 'pending' instead of 'done'.
  A crashed backend cannot serve protected routes; conservative fallback
  is to redirect to /setup.

Task 0.4: SetupPage shows spinner while checking setup status
- Added 'checking' boolean state; full-screen Spinner is rendered until
  getSetupStatus() resolves, preventing form flash before redirect.
- Added console.warn in catch block; cleanup return added to useEffect.

Also: remove unused type: ignore[call-arg] from config.py.

Tests: 18 backend tests pass; 117 frontend tests pass.
2026-03-15 18:05:53 +01:00
eb859af371 chore: bump version to 0.9.0 2026-03-15 17:13:11 +01:00
5a5c619a34 Fix JailsTab content pane not updating on jail switch
Add key={selectedActiveJail.name} and key={selectedInactiveJail.name} to
JailConfigDetail and InactiveJailDetail in JailsTab.tsx so React unmounts
and remounts the detail component whenever the selected jail changes,
resetting all internal state including the loadedRef guard.
2026-03-15 14:10:01 +01:00
00119ed68d Rename dev jail bangui-sim to manual-Jail
Rename fail2ban-dev-config jail.d/bangui-sim.conf and filter.d/bangui-sim.conf
to manual-Jail.conf. Update section header, filter reference, and comments in
both files. Update JAIL constant and header comment in check_ban_status.sh.
Update comments in simulate_failed_logins.sh. Replace all bangui-sim
occurrences in fail2ban-dev-config/README.md.
2026-03-15 14:09:49 +01:00
b81e0cdbb4 Fix raw action config endpoint shadowed by config router
Rename GET/PUT /api/config/actions/{name} to /actions/{name}/raw in
file_config.py to eliminate the route-shadowing conflict with config.py,
which registers its own GET /actions/{name} returning ActionConfig.

Add configActionRaw endpoint helper in endpoints.ts and update
fetchActionFile/updateActionFile in config.ts to use it. Add
TestGetActionFileRaw and TestUpdateActionFileRaw test classes.
2026-03-15 14:09:37 +01:00
41dcd60225 Improve activation rollback messages in ActivateJailDialog
- Replace vague 'System Recovered' message with 'Configuration Rolled Back'
  and actionable text describing the rollback outcome
- Replace 'Manual Intervention Required' with 'Rollback Unsuccessful' and
  specific instructions: check jail.d/{name}.local, fix manually, restart
- Add test_activate_jail_rollback_deletes_file_when_no_prior_local to cover
  rollback path when no .local file existed before activation
- Mark all three tasks complete in Tasks.md
2026-03-15 13:41:14 +01:00
12f04bd8d6 Remove RecoveryBanner component and dead onCrashDetected code
- Delete RecoveryBanner.tsx component and its test
- Remove RecoveryBanner from MainLayout
- Remove onCrashDetected prop from ActivateJailDialog, JailsTab
- Remove fetchPendingRecovery, rollbackJail API functions
- Remove configJailRollback, configPendingRecovery endpoints
- Remove PendingRecovery type
2026-03-15 13:41:06 +01:00
d4d04491d2 Add Deactivate Jail button for inactive jails with local override
- Add has_local_override field to InactiveJail model
- Update _build_inactive_jail and list_inactive_jails to compute the field
- Add delete_jail_local_override() service function
- Add DELETE /api/config/jails/{name}/local router endpoint
- Surface has_local_override in frontend InactiveJail type
- Show Deactivate Jail button in JailsTab when has_local_override is true
- Add tests: TestBuildInactiveJail, TestListInactiveJails, TestDeleteJailLocalOverride
2026-03-15 13:41:00 +01:00
93dc699825 Fix restart/reload endpoint correctness and safety
- jail_service.restart(): replace invalid ["restart"] socket command with
  ["stop"], matching fail2ban transmitter protocol. The daemon is now
  stopped via socket; the caller starts it via subprocess.

- config_file_service: expose _start_daemon and _wait_for_fail2ban as
  public start_daemon / wait_for_fail2ban functions.

- restart_fail2ban router: orchestrate stop (socket) → start (subprocess)
  → probe (socket). Returns 204 on success, 503 when fail2ban does not
  come back within 10 s. Catches JailOperationError → 409.

- reload_fail2ban router: add JailOperationError catch → 409 Conflict,
  consistent with other jail control endpoints.

- Tests: add TestJailControls.test_restart_* (3 cases), TestReloadFail2ban
  502/409 cases, TestRestartFail2ban (5 cases), TestRollbackJail (6
  integration tests verifying file-write, subprocess invocation, socket-
  probe truthiness, active_jails count, and offline-at-call-time).
2026-03-15 12:59:17 +01:00
61daa8bbc0 Fix BUG-001: resolve banaction interpolation error in fail2ban jails
The container init script (init-fail2ban-config) copies jail.conf from the
image's /defaults/ on every start, overwriting any direct edits.  The correct
fix is jail.local, which is not present in the image defaults and therefore
persists across restarts.

Changes:
- Add Docker/fail2ban-dev-config/fail2ban/jail.local with [DEFAULT] overrides
  for banaction = iptables-multiport and banaction_allports = iptables-allports.
  fail2ban loads jail.local after jail.conf so these values are available to
  all jails during %(action_)s interpolation.
- Untrack jail.local from .gitignore so it is committed to the repo.
- Fix TypeError in config_file_service: except jail_service.JailNotFoundError
  failed when jail_service was mocked in tests because MagicMock attributes are
  not BaseException subclasses.  Import JailNotFoundError directly instead.
- Mark BUG-001 as Done in Tasks.md.
2026-03-15 11:39:20 +01:00
57a0bbe36e Restructure Tasks.md to match Instructions.md workflow format 2026-03-15 11:14:55 +01:00
f62785aaf2 Fix fail2ban runtime errors: jail not found, action locks, log noise
This commit implements fixes for three independent bugs in the fail2ban configuration and integration layer:

1. Task 1: Detect UnknownJailException and prevent silent failures
   - Added JailNotFoundError detection in jail_service.reload_all()
   - Enhanced error handling in config_file_service to catch JailNotFoundError
   - Added specific error message with logpath validation hints
   - Added rollback test for this scenario

2. Task 2: Fix iptables-allports exit code 4 (xtables lock contention)
   - Added global banaction setting in jail.conf with -w 5 lockingopt
   - Removed redundant per-jail banaction overrides from bangui-sim and blocklist-import
   - Added production compose documentation note

3. Task 3: Suppress log noise from unsupported backend/idle commands
   - Implemented capability detection to cache command support status
   - Double-check locking to minimize lock contention
   - Avoids sending unsupported get <jail> backend/idle commands
   - Returns default values without socket calls when unsupported

All changes include comprehensive tests and maintain backward compatibility.
2026-03-15 10:57:00 +01:00
1e33220f59 Add reload and restart buttons to Server tab
Adds ability to reload or restart fail2ban service from the Server tab UI.

Backend changes:
- Add new restart() method to jail_service.py that sends 'restart' command
- Add new POST /api/config/restart endpoint in config router
- Endpoint returns 204 on success, 502 if fail2ban unreachable
- Includes structured logging via 'fail2ban_restarted' log entry

Frontend changes:
- Add configRestart endpoint to endpoints.ts
- Add restartFail2Ban() API function in config.ts API module
- Import ArrowSync24Regular icon from Fluent UI
- Add reload and restart button handlers to ServerTab
- Display 'Reload fail2ban' and 'Restart fail2ban' buttons in action row
- Show loading spinner during operation
- Display success/error MessageBar with appropriate feedback
- Update ServerTab docstring to document new buttons

All 115 frontend tests pass.
2026-03-14 22:03:58 +01:00
1da38361a9 Merge Log tab into Server tab and remove Log tab
The Log tab provided a service health panel and log viewer. These are
consolidated into the Server tab with a new ServerHealthSection component
that encapsulates all log-related functionality.

- Extract service health panel and log viewer into ServerHealthSection component
- Add severity-based log line color coding (ERROR=red, WARNING=yellow, DEBUG=gray)
- Implement log filtering, line count selection, and auto-refresh controls
- Scroll to bottom when new log data arrives
- Render health metrics grid with version, jail count, bans, failures
- Show read-only log level and log target in health section
- Handle non-file targets with informational banner
- Import ServerHealthSection in ServerTab and render after map thresholds
- Remove LogTab component import from ConfigPage
- Remove 'log' from TabValue type
- Remove Log tab element from TabList
- Remove conditional render for LogTab
- Remove LogTab from barrel export (index.ts)
- Delete LogTab.tsx and LogTab.test.tsx files
- Update ConfigPage docstring

All 115 frontend tests pass (8 fewer due to deleted LogTab tests).
2026-03-14 21:58:34 +01:00
9630aea877 Merge Map tab into Server tab and remove Map tab
The Map tab provided a form for editing world-map color thresholds
(low, medium, high). Moving this into the Server tab consolidates all
server-side configuration in one place.

- Add map color thresholds section to ServerTab with full validation
- Load map thresholds on component mount with useEffect
- Implement auto-save for threshold changes via useAutoSave hook
- Display threshold color interpolation guide
- Remove MapTab component import from ConfigPage
- Remove 'map' from TabValue type
- Remove Map tab element from TabList
- Remove conditional render for MapTab
- Remove MapTab from barrel export (index.ts)
- Delete MapTab.tsx file
- Update ConfigPage test to remove MapTab mock

All 123 frontend tests pass.
2026-03-14 21:55:30 +01:00
037c18eb00 Merge Global tab into Server tab and remove Global tab
Global tab provided the same four editable fields as Server tab:
log_level, log_target, db_purge_age, db_max_matches. Server tab already
has these fields plus additional read-only info (db_path, syslog_socket)
and a Flush Logs button.

- Add hint text to DB Purge Age and DB Max Matches fields in ServerTab
- Remove GlobalTab component import from ConfigPage
- Remove 'global' from TabValue type
- Remove Global tab element from TabList
- Remove conditional render for GlobalTab
- Remove GlobalTab from barrel export (index.ts)
- Delete GlobalTab.tsx file
- Update ConfigPage test to remove Global tab test case

All 123 frontend tests pass.
2026-03-14 21:52:44 +01:00
2e1a4b3b2b Fix chart color resolution by querying FluentProvider wrapper
The pie and bar charts were rendering with transparent/missing colors because
resolveFluentToken queried document.documentElement for CSS custom properties.
Fluent UI v9 injects these on its own wrapper div (.fui-FluentProvider), not
on :root. Changed to query that element with a fallback to document.html.

This fixes the fill colors for all four chart components.
2026-03-14 21:49:30 +01:00
4be2469f92 Implement tasks 1-3: sidebar order, jail activation rollback, pie chart colors
Task 1: Move Configuration to last position in sidebar NAV_ITEMS

Task 2: Add automatic rollback when jail activation fails
- Back up .local override file before writing
- Restore original file (or delete) on reload failure, health-check
  failure, or jail not appearing post-reload
- Return recovered=True/False in JailActivationResponse
- Show warning/critical banner in ActivateJailDialog based on recovery
- Add _restore_local_file_sync and _rollback_activation_async helpers
- Add 3 new tests: rollback on reload failure, health-check failure,
  and double failure (recovered=False)

Task 3: Color pie chart legend labels to match their slice color
- legendFormatter now returns ReactNode with span style={{ color }}
- Import LegendPayload from recharts/types/component/DefaultLegendContent
2026-03-14 21:16:58 +01:00
6bb38dbd8c Add ignore-self toggle to Jail Detail page
Implements the missing UI control for POST /api/jails/{name}/ignoreself:
- Add jailIgnoreSelf endpoint constant to endpoints.ts
- Add toggleIgnoreSelf(name, on) API function to jails.ts
- Expose toggleIgnoreSelf action from useJailDetail hook
- Replace read-only 'ignore self' badge with a Fluent Switch in
  IgnoreListSection to allow enabling/disabling the flag per jail
- Add 5 vitest tests for checked/unchecked state and toggle behaviour
2026-03-14 20:24:49 +01:00
d3b2022ffb Mark Task 7 as done in Tasks.md 2026-03-14 19:51:12 +01:00
4b6e118a88 Fix ActivateJailDialog blocking logic and mypy false positive
Two frontend bugs and one mypy false positive fixed:

- ActivateJailDialog: Activate button was never disabled when
  blockingIssues.length > 0 (missing condition in disabled prop).
- ActivateJailDialog: handleConfirm called onActivated() even when
  the backend returned active=false (blocked activation). Dialog now
  stays open and shows result.message instead.
- config.py: Settings() call flagged by mypy --strict because
  pydantic-settings loads required fields from env vars at runtime;
  suppressed with a targeted type: ignore[call-arg] comment.

Tests: added ActivateJailDialog.test.tsx (5 tests covering button state,
backend-rejection handling, success path, and crash detection callback).
2026-03-14 19:50:55 +01:00
936946010f Run immediate health probe after jail deactivation
After deactivation the endpoint now calls _run_probe to flush the
cached server status immediately, matching the activate_jail behaviour
added in Task 5. Without this, the dashboard active-jail count could
remain stale for up to 30 s after a deactivation reload.

- config.py: capture result, await _run_probe, return result
- test_config.py: add test_deactivate_triggers_health_probe; fix 3
  pre-existing UP017 ruff warnings (datetime.UTC alias)
- test_health.py: update test to assert the new fail2ban field
2026-03-14 19:25:24 +01:00
ee7412442a Complete tasks 1-5: UI cleanup, pie chart fix, log path allowlist, activation hardening
Task 1: Remove ActiveBansSection from JailsPage
- Delete buildBanColumns, fmtTimestamp, ActiveBansSection
- Remove Dialog/Delete/Dismiss imports, ActiveBan type
- Update JSDoc to reflect three sections

Task 2: Remove JailDistributionChart from Dashboard
- Delete import and JSX block from DashboardPage.tsx

Task 3: Fix transparent pie chart (TopCountriesPieChart)
- Add Cell import and per-slice <Cell fill={slice.fill}> children inside <Pie>
- Suppress @typescript-eslint/no-deprecated (recharts v3 types)

Task 4: Allow /config/log as safe log prefix
- Add '/config/log' to _SAFE_LOG_PREFIXES in config_service.py
- Update error message to list both allowed directories

Task 5: Block jail activation on missing filter/logpath
- activate_jail refuses to proceed when filter/logpath issues found
- ActivateJailDialog treats all validation issues as blocking
- Trigger immediate _run_probe after activation in config router
- /api/health now reports fail2ban online/offline from cached probe
- Add TestActivateJailBlocking tests; fix existing tests to mock validation
2026-03-14 18:57:01 +01:00
68d8056d2e fix: resolve ESLint no-confusing-void-expression in LogTab tests 2026-03-14 17:58:35 +01:00
528d0bd8ea fix: make all tests pass
backend/tests/test_routers/test_file_config.py:
  - TestListActionFiles.test_200_returns_files: GET /api/config/actions is
    handled by config.router (registered before file_config.router), so mock
    config_file_service.list_actions and assert on ActionListResponse.actions
  - TestCreateActionFile.test_201_creates_file: same route conflict; mock
    config_file_service.create_action and use ActionCreateRequest body format

frontend/src/components/__tests__/ConfigPageLogPath.test.tsx:
  - Log paths are rendered as <Input value={path}>, not text nodes; replace
    getByText() with getByDisplayValue() for both test assertions
2026-03-14 17:41:06 +01:00
baf45c6c62 feat: Task 4 — paginated banned-IPs section on jail detail page
Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
  IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
  query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)

Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
  (300 ms), prev/next pagination, page-size dropdown, per-row unban
  button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
2026-03-14 16:28:43 +01:00
0966f347c4 feat: Task 3 — invalid jail config recovery (pre-validation, crash detection, rollback)
- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
  health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
  GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
  add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
  record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
  extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
  result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
  alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
  + 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
  frontend tests; fix mock setup in existing activate_jail tests
2026-03-14 14:13:07 +01:00
ab11ece001 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
2026-03-14 12:54:03 +01:00
80 changed files with 7617 additions and 1065 deletions

1
.gitignore vendored
View File

@@ -105,6 +105,7 @@ Docker/fail2ban-dev-config/**
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
!Docker/fail2ban-dev-config/fail2ban/jail.local
# ── Misc ──────────────────────────────────────
*.log

View File

@@ -10,7 +10,7 @@
# ──────────────────────────────────────────────────────────────
# ── Stage 1: build dependencies ──────────────────────────────
FROM python:3.12-slim AS builder
FROM docker.io/library/python:3.12-slim AS builder
WORKDIR /build
@@ -28,7 +28,7 @@ RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir .
# ── Stage 2: runtime image ───────────────────────────────────
FROM python:3.12-slim AS runtime
FROM docker.io/library/python:3.12-slim AS runtime
LABEL maintainer="BanGUI" \
description="BanGUI backend — fail2ban web management API"

View File

@@ -10,7 +10,7 @@
# ──────────────────────────────────────────────────────────────
# ── Stage 1: install & build ─────────────────────────────────
FROM node:22-alpine AS builder
FROM docker.io/library/node:22-alpine AS builder
WORKDIR /build
@@ -23,7 +23,7 @@ COPY frontend/ /build/
RUN npm run build
# ── Stage 2: serve with nginx ────────────────────────────────
FROM nginx:1.27-alpine AS runtime
FROM docker.io/library/nginx:1.27-alpine AS runtime
LABEL maintainer="BanGUI" \
description="BanGUI frontend — fail2ban web management UI"

1
Docker/VERSION Normal file
View File

@@ -0,0 +1 @@
v0.9.4

View File

@@ -2,7 +2,7 @@
# ──────────────────────────────────────────────────────────────
# check_ban_status.sh
#
# Queries the bangui-sim jail inside the running fail2ban
# Queries the manual-Jail jail inside the running fail2ban
# container and optionally unbans a specific IP.
#
# Usage:
@@ -17,7 +17,7 @@
set -euo pipefail
readonly CONTAINER="bangui-fail2ban-dev"
readonly JAIL="bangui-sim"
readonly JAIL="manual-Jail"
# ── Helper: run a fail2ban-client command inside the container ─
f2b() {

View File

@@ -37,6 +37,11 @@ services:
timeout: 5s
start_period: 15s
retries: 3
# NOTE: The fail2ban-config volume must be pre-populated with the following files:
# • fail2ban/jail.conf (or jail.d/*.conf) with the DEFAULT section containing:
# banaction = iptables-allports[lockingopt="-w 5"]
# This prevents xtables lock contention errors when multiple jails start in parallel.
# See https://fail2ban.readthedocs.io/en/latest/development/environment.html
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
backend:

73
Docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,73 @@
version: '3.8'
services:
fail2ban:
image: lscr.io/linuxserver/fail2ban:latest
container_name: fail2ban
cap_add:
- NET_ADMIN
- NET_RAW
network_mode: host
environment:
- PUID=1011
- PGID=1001
- TZ=Etc/UTC
- VERBOSITY=-vv #optional
volumes:
- /server/server_fail2ban/config:/config
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
- /var/log:/var/log
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
restart: unless-stopped
backend:
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
container_name: bangui-backend
restart: unless-stopped
depends_on:
fail2ban:
condition: service_started
environment:
- PUID=1011
- PGID=1001
- BANGUI_DATABASE_PATH=/data/bangui.db
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
- BANGUI_LOG_LEVEL=info
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
volumes:
- /server/server_fail2ban/bangui-data:/data
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
- /server/server_fail2ban/config:/config:rw
expose:
- "8000"
networks:
- bangui-net
# ── Frontend (nginx serving built SPA + API proxy) ──────────
frontend:
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
container_name: bangui-frontend
restart: unless-stopped
environment:
- PUID=1011
- PGID=1001
ports:
- "${BANGUI_PORT:-8080}:80"
depends_on:
backend:
condition: service_started
networks:
- bangui-net
networks:
bangui-net:
name: bangui-net

View File

@@ -2,7 +2,7 @@
This directory contains the fail2ban configuration and supporting scripts for a
self-contained development test environment. A simulation script writes fake
authentication-failure log lines, fail2ban detects them via the `bangui-sim`
authentication-failure log lines, fail2ban detects them via the `manual-Jail`
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
without a real service.
@@ -71,14 +71,14 @@ Chains steps 13 automatically with appropriate sleep intervals.
| File | Purpose |
|------|---------|
| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines |
| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines |
| `fail2ban/jail.d/manual-Jail.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
(see `fail2ban/paths-lsio.conf``remote_logs_path = /remotelogs`).
To change sensitivity, edit `fail2ban/jail.d/bangui-sim.conf`:
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
```ini
maxretry = 3 # failures before a ban
@@ -108,14 +108,14 @@ Test the regex manually:
```bash
docker exec bangui-fail2ban-dev \
fail2ban-regex /remotelogs/bangui/auth.log bangui-sim
fail2ban-regex /remotelogs/bangui/auth.log manual-Jail
```
The output should show matched lines. If nothing matches, check that the log
lines match the corresponding `failregex` pattern:
```
# bangui-sim (auth log):
# manual-Jail (auth log):
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
```
@@ -132,7 +132,7 @@ sudo modprobe ip_tables
### IP not banned despite enough failures
Check whether the source IP falls inside the `ignoreip` range defined in
`fail2ban/jail.d/bangui-sim.conf`:
`fail2ban/jail.d/manual-Jail.conf`:
```ini
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -3,6 +3,7 @@
#
# Matches lines written by Docker/simulate_failed_logins.sh
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
# Jail: manual-Jail
# ──────────────────────────────────────────────────────────────
[Definition]

View File

@@ -20,7 +20,6 @@ maxretry = 1
findtime = 1d
# Block imported IPs for one week.
bantime = 1w
banaction = iptables-allports
# Never ban the Docker bridge network or localhost.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -5,16 +5,15 @@
# for lines produced by Docker/simulate_failed_logins.sh.
# ──────────────────────────────────────────────────────────────
[bangui-sim]
[manual-Jail]
enabled = true
filter = bangui-sim
filter = manual-Jail
logpath = /remotelogs/bangui/auth.log
backend = polling
maxretry = 3
findtime = 120
bantime = 60
banaction = iptables-allports
# Never ban localhost, the Docker bridge network, or the host machine.
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12

View File

@@ -0,0 +1,6 @@
# Local overrides — not overwritten by the container init script.
# Provides banaction so all jails can resolve %(action_)s interpolation.
[DEFAULT]
banaction = iptables-multiport
banaction_allports = iptables-allports

86
Docker/release.sh Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Bump the project version and push images to the registry.
#
# Usage:
# ./release.sh
#
# The current version is stored in VERSION (next to this script).
# You will be asked whether to bump major, minor, or patch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION_FILE="${SCRIPT_DIR}/VERSION"
# ---------------------------------------------------------------------------
# Read current version
# ---------------------------------------------------------------------------
if [[ ! -f "${VERSION_FILE}" ]]; then
echo "0.0.0" > "${VERSION_FILE}"
fi
CURRENT="$(cat "${VERSION_FILE}")"
# Strip leading 'v' for arithmetic
VERSION="${CURRENT#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
echo "============================================"
echo " BanGUI — Release"
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
echo "============================================"
echo ""
echo "How would you like to bump the version?"
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
echo ""
read -rp "Enter choice [1/2/3]: " CHOICE
case "${CHOICE}" in
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
*)
echo "Invalid choice. Aborting." >&2
exit 1
;;
esac
echo ""
echo "New version: ${NEW_TAG}"
read -rp "Confirm? [y/N]: " CONFIRM
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
echo "Aborted."
exit 0
fi
# ---------------------------------------------------------------------------
# Write new version
# ---------------------------------------------------------------------------
echo "${NEW_TAG}" > "${VERSION_FILE}"
echo "Version file updated → ${VERSION_FILE}"
# Keep frontend/package.json in sync so __APP_VERSION__ matches Docker/VERSION.
FRONT_VERSION="${NEW_TAG#v}"
FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
echo "frontend/package.json version updated → ${FRONT_VERSION}"
# ---------------------------------------------------------------------------
# Git tag
# ---------------------------------------------------------------------------
cd "${SCRIPT_DIR}/.."
git add Docker/VERSION frontend/package.json
git commit -m "chore: release ${NEW_TAG}"
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
git push origin HEAD
git push origin "${NEW_TAG}"
echo "Git tag ${NEW_TAG} created and pushed."
# ---------------------------------------------------------------------------
# Push
# ---------------------------------------------------------------------------
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
bash "${SCRIPT_DIR}/push.sh"

View File

@@ -3,7 +3,7 @@
# simulate_failed_logins.sh
#
# Writes synthetic authentication-failure log lines to a file
# that matches the bangui-sim fail2ban filter.
# that matches the manual-Jail fail2ban filter.
#
# Usage:
# bash Docker/simulate_failed_logins.sh [COUNT] [SOURCE_IP] [LOG_FILE]
@@ -13,7 +13,7 @@
# SOURCE_IP: 192.168.100.99
# LOG_FILE : Docker/logs/auth.log (relative to repo root)
#
# Log line format (must match bangui-sim failregex exactly):
# Log line format (must match manual-Jail failregex exactly):
# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
# ──────────────────────────────────────────────────────────────

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 |
| `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 |

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).
- 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

View File

@@ -4,254 +4,56 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Task 1 — Jail Page: Show Only Active Jails (No Inactive Configs)
## Open Issues
**Status:** done
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done
**Summary:** Backend `GET /api/jails` already only returned active jails (queries fail2ban socket `status` command). Frontend `JailsPage.tsx` updated: removed the "Inactive Jails" section, the "Show inactive" toggle, the `fetchInactiveJails()` call, the `ActivateJailDialog` import/usage, and the `InactiveJail` type import. The Config page (`JailsTab.tsx`) retains full inactive-jail management. All backend tests pass (96/96). TypeScript and ESLint report zero errors. (`JailsPage.tsx`) currently displays inactive jail configurations alongside active jails. Inactive jails — those defined in config files but not running — belong on the **Configuration** page (`ConfigPage.tsx`, Jails tab), not on the operational Jail management page. The Jail page should be a pure operational view: only jails that fail2ban reports as active/running appear here.
**Implemented:**
- `frontend/vite.config.ts`: reads `package.json#version` at build time and injects it as the global `__APP_VERSION__` via Vite `define`.
- `frontend/src/vite-env.d.ts`: adds `declare const __APP_VERSION__: string` so TypeScript knows about the global.
- `frontend/src/layouts/MainLayout.tsx`: renders `BanGUI v{__APP_VERSION__}` in the sidebar footer when expanded (hidden when collapsed).
- `frontend/src/components/ServerStatusBar.tsx`: tooltip changed from `"fail2ban version"` to `"fail2ban daemon version"`.
- `Docker/release.sh`: after bumping `VERSION`, also updates `frontend/package.json#version` via `sed` to keep them in sync.
- `frontend/package.json`: version bumped from `0.9.0` to `0.9.3` to match `Docker/VERSION`.
- Tests added: `src/components/__tests__/ServerStatusBar.test.tsx`, `src/layouts/__tests__/MainLayout.test.tsx`.
### Goal
**Problem:** The `ServerStatusBar` component on the Dashboard displays `v{status.version}`, which is the **fail2ban daemon version** (e.g. `v1.1.0`). The BanGUI application version lives in `Docker/VERSION` (e.g. `v0.9.3`) and is unrelated to the fail2ban version. Users see a version number they don't recognise and assume it reflects the BanGUI release.
Remove all inactive-jail display and activation UI from the Jail management page. The Jail page shows only jails that are currently loaded in the running fail2ban instance. Users who want to discover and activate inactive jails do so exclusively through the Configuration page's Jails tab.
**Goal:** Make the distinction clear and expose the BanGUI application version.
### Backend Changes
**Suggested approach:**
1. Inject the BanGUI app version at build time — add a `define` entry in `frontend/vite.config.ts` that reads the `version` field from `frontend/package.json` (e.g. `__APP_VERSION__`). Keep `frontend/package.json` and `Docker/VERSION` in sync (update the release script `Docker/release.sh` or `Makefile` to write `package.json#version` from `VERSION`).
2. Show the BanGUI version in the sidebar footer inside `MainLayout.tsx` (collapsed view: show only when expanded, or via tooltip). This is the natural place for an "about" version tag.
3. Update the fail2ban version tooltip in `ServerStatusBar.tsx` from the generic `"fail2ban version"` to something like `"fail2ban daemon version"` so the two are no longer visually indistinguishable.
1. **Review `GET /api/jails`** in `backend/app/routers/jails.py` and `jail_service.py`. Confirm this endpoint only returns jails that are reported as active by fail2ban via the socket (`status` command). If it already does, no change needed. If it includes inactive/config-only jails in its response, strip them out.
2. **No new endpoints needed.** The inactive-jail listing and activation endpoints already live under `/api/config/jails` and `/api/config/jails/{name}/activate` in `config.py` / `config_file_service.py` — those stay as-is for the Config page.
### Frontend Changes
3. **`JailsPage.tsx`** — Remove the "Inactive Jails" section, the toggle that reveals inactive jails, and the `fetchInactiveJails()` call. The page should only call `fetchJails()` (which queries `/api/jails`) and render that list. Remove the `ActivateJailDialog` import and usage from this page if present.
4. **`JailsPage.tsx`** — Remove any "Activate" buttons or affordances that reference inactive jails. The jail overview table should show: jail name, status (running / stopped / idle), backend type, currently banned count, total bans, currently failed, total failed, find time, ban time, max retries. No "Inactive" badge or "Activate" button.
5. **Verify the Config page** (`ConfigPage.tsx` → Jails tab / `JailsTab.tsx`) still shows the full list including inactive jails with Active/Inactive badges and the Activate button. This is the only place where inactive jails are managed. No changes expected here — just verify nothing broke.
### Tests
6. **Backend:** If there are existing tests for `GET /api/jails` that assert inactive jails are included, update them so they assert inactive jails are excluded.
7. **Frontend:** Update or remove any component tests for the inactive-jail section on `JailsPage`. Ensure Config-page tests for inactive jail activation still pass.
### Acceptance Criteria
- The Jail page shows zero inactive jails under any circumstance.
- All Jail page data comes only from the fail2ban socket's active jail list.
- Inactive-jail discovery and activation remain fully functional on the Configuration page, Jails tab.
- No regressions in existing jail control actions (start, stop, reload, idle, ignore-list) on the Jail page.
**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`.
---
## Task 2 — Configuration Subpage: fail2ban Log Viewer & Service Health
### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done
**Status:** not started
**References:** [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
**Implemented:** In `frontend/src/components/ServerStatusBar.tsx`, changed the `Failures:` label to `Failed Attempts:` and updated the tooltip from `"Currently failing IPs"` to `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Updated `ServerStatusBar.test.tsx` to assert the new label text.
### Problem
**Problem:** The `ServerStatusBar` shows a "Failures: 42" counter with the tooltip `"Currently failing IPs"`. In fail2ban terminology *failures* are individual **failed authentication attempts** tracked in the fail2ban DB, not the number of unique IPs that failed. The current wording is ambiguous and misleading — users may think it means broken connections or error states.
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.
**Goal:** Replace the tooltip with accurate, self-explanatory wording.
### Goal
**Suggested fix:** Change the `Tooltip` content for the Failures stat in `ServerStatusBar.tsx` from `"Currently failing IPs"` to something like `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Additionally, consider renaming the label from `"Failures:"` to `"Failed Attempts:"` to match the tooltip language.
Add a new **Log** tab to the Configuration page. This tab shows two things:
1. A **Service Health panel** — a compact summary showing whether fail2ban is running, its version, active jail count, total bans, total failures, and the current log level/target. This reuses data from the existing health probe.
2. A **Log viewer** — displays the tail of the fail2ban daemon log file with newest entries at the bottom. Supports manual refresh and optional auto-refresh on an interval.
### Backend Changes
#### New Endpoint: Read fail2ban Log
1. **Create `GET /api/config/fail2ban-log`** in `backend/app/routers/config.py` (or a new router file `backend/app/routers/log.py` if `config.py` is getting large).
- **Query parameters:**
- `lines` (int, default 200, max 2000) — number of lines to return from the tail of the log file.
- `filter` (optional string) — a plain-text substring filter; only return lines containing this string (for searching).
- **Response model:** `Fail2BanLogResponse` with fields:
- `log_path: str` — the resolved path of the log file being read.
- `lines: list[str]` — the log lines.
- `total_lines: int` — total number of lines in the file (so the UI can indicate if it's truncated).
- `log_level: str` — the current fail2ban log level.
- `log_target: str` — the current fail2ban log target.
- **Behaviour:** Query the fail2ban socket for `get logtarget` to find the current log file path. Read the last N lines from that file using an efficient tail implementation (read from end of file, do not load the entire file into memory). If the log target is not a file (stdout, syslog, systemd-journal), return an informative error explaining that log viewing is only available when fail2ban logs to a file.
- **Security:** Validate that the resolved log path is under an expected directory (e.g. `/var/log/`). Do not allow path traversal. Never expose arbitrary file contents.
2. **Create the service method** `read_fail2ban_log()` in `backend/app/services/config_service.py` (or a new `log_service.py`).
- Use `fail2ban_client.py` to query `get logtarget` and `get loglevel`.
- Implement an async file tail: open the file, seek to end, read backwards until N newlines are found OR the beginning of the file is reached.
- Apply the optional substring filter on the server side before returning.
3. **Create Pydantic models** in `backend/app/models/config.py`:
- `Fail2BanLogResponse(log_path: str, lines: list[str], total_lines: int, log_level: str, log_target: str)`
#### Extend Health Data for Config Page
4. **Create `GET /api/config/service-status`** (or reuse/extend `GET /api/dashboard/status` if appropriate).
- Returns: `online` (bool), `version` (str), `jail_count` (int), `total_bans` (int), `total_failures` (int), `log_level` (str), `log_target` (str), `db_path` (str), `uptime` or `start_time` if available.
- This can delegate to the existing `health_service.probe()` and augment with the log-level/target info from the socket.
### Frontend Changes
#### New Tab: Log
5. **Create `frontend/src/components/config/LogTab.tsx`.**
- **Service Health panel** at the top:
- A status badge: green "Running" or red "Offline".
- Version, active jails count, total bans, total failures displayed in a compact row of stat cards.
- Current log level and log target shown as labels.
- If fail2ban is offline, show a prominent warning banner with the text: "fail2ban is not running or unreachable. Check the server and socket configuration."
- **Log viewer** below:
- A monospace-font scrollable container showing the log lines.
- A toolbar above the log area with:
- A **Refresh** button to re-fetch the log.
- An **Auto-refresh** toggle (off by default) with a selectable interval (5s, 10s, 30s).
- A **Lines** dropdown to choose how many lines to load (100, 200, 500, 1000).
- A **Filter** text input to search within the log (sends the filter param to the backend).
- Log lines should be syntax-highlighted or at minimum color-coded by log level (ERROR = red, WARNING = yellow, INFO = default, DEBUG = muted).
- The container auto-scrolls to the bottom on load and on refresh (since newest entries are at the end).
- If the log target is not a file, show an info banner: "fail2ban is logging to [target]. File-based log viewing is not available."
6. **Register the tab** in `ConfigPage.tsx`. Add a "Log" tab after the existing tabs (Jails, Filters, Actions, Global, Server, Map, Regex Tester). Use a log-file icon.
7. **Create API functions** in `frontend/src/api/config.ts`:
- `fetchFail2BanLog(lines?: number, filter?: string): Promise<Fail2BanLogResponse>`
- `fetchServiceStatus(): Promise<ServiceStatusResponse>`
8. **Create TypeScript types** in `frontend/src/types/config.ts` (or wherever config types live):
- `Fail2BanLogResponse { log_path: string; lines: string[]; total_lines: number; log_level: string; log_target: string; }`
- `ServiceStatusResponse { online: boolean; version: string; jail_count: number; total_bans: number; total_failures: number; log_level: string; log_target: string; }`
### Tests
9. **Backend:** Write tests for the new log endpoint — mock the file read, test line-count limiting, test the substring filter, test the error case when log target is not a file, test path-traversal prevention.
10. **Backend:** Write tests for the service-status endpoint.
11. **Frontend:** Write component tests for `LogTab.tsx` — renders health panel, renders log lines, filter input works, handles offline state.
### Acceptance Criteria
- The Configuration page has a new "Log" tab.
- The Log tab shows a clear health summary with running/offline state and key metrics.
- The Log tab displays the tail of the fail2ban daemon log file.
- Users can choose how many lines to display, can refresh manually, and can optionally enable auto-refresh.
- Users can filter log lines by substring.
- Log lines are visually differentiated by severity level.
- If fail2ban logs to a non-file target, a clear message is shown instead of the log viewer.
- The log endpoint does not allow reading arbitrary files — only the actual fail2ban log target.
**Files:** `frontend/src/components/ServerStatusBar.tsx`.
---
## Task 3 — Invalid Jail Config Recovery: Detect Broken fail2ban & Auto-Disable Bad Jails
### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done
**Status:** not started
**References:** [Features.md § 5 — Jail Management](Features.md), [Features.md § 6 — Configuration View](Features.md), [Architekture.md § 2](Architekture.md)
**Implemented:** In `frontend/src/components/config/ServerTab.tsx`, moved `<ServerHealthSection />` from the end of the JSX return to be the first element rendered inside the tab container, before all settings fields.
### Problem
**Problem:** In the Config page → Server tab, the `Service Health` panel (`ServerHealthSection`) is rendered at the bottom of the tab, after all settings sections (log level, log target, DB purge settings, map thresholds, reload/restart buttons). This means users must scroll past all editable fields to check service connectivity status, even though the health status is the most critical piece of context — it indicates whether the server is reachable at all.
When a user activates a jail from the Configuration page, the system writes `enabled = true` to a `.local` override file and triggers a fail2ban reload. If the jail's configuration is invalid (bad regex, missing log file, broken filter reference, syntax error in an action), fail2ban may **refuse to start entirely** — not just skip the one bad jail but stop the whole daemon. At that point every jail is down, all monitoring stops, and the user is locked out of all fail2ban operations in BanGUI.
**Goal:** Move the `<ServerHealthSection />` block to the **top** of the `ServerTab` render output, before any settings fields.
The current `activate_jail()` flow in `config_file_service.py` does a post-reload check (queries fail2ban for the jail's status and returns `active=false` if it didn't start), but this only works when fail2ban is still running. If the entire daemon crashes after the reload, the socket is gone and BanGUI cannot query anything. The user sees generic "offline" errors but has no clear path to fix the problem.
**Suggested fix:** In `frontend/src/components/config/ServerTab.tsx`, move the `{/* Service Health & Log Viewer section */}` block (currently at the end of the JSX return around line 415) to be the first section rendered inside the tab container.
### Goal
Build a multi-layered safety net that:
1. **Pre-validates** the jail config before activating it (catch obvious errors before the reload).
2. **Detects** when fail2ban goes down after a jail activation (detect the crash quickly).
3. **Alerts** the user with a clear, actionable message explaining which jail was just activated and that it likely caused the failure.
4. **Offers a one-click rollback** that disables the bad jail config and restarts fail2ban.
### Plan
#### Layer 1: Pre-Activation Validation
1. **Extend `activate_jail()` in `config_file_service.py`** (or add a new `validate_jail_config()` method) to perform dry-run checks before writing the `.local` file and reloading:
- **Filter existence:** Verify the jail's `filter` setting references a filter file that actually exists in `filter.d/`.
- **Action existence:** Verify every action referenced by the jail exists in `action.d/`.
- **Regex compilation:** Attempt to compile all `failregex` and `ignoreregex` patterns with Python's `re` module. Report which pattern is broken.
- **Log path check:** Verify that the log file paths declared in the jail config actually exist on disk and are readable.
- **Syntax check:** Parse the full merged config (base + overrides) and check for obvious syntax issues (malformed interpolation, missing required keys).
2. **Return validation errors as a structured response** before proceeding with activation. The response should list every issue found so the user can fix them before trying again.
3. **Create a new endpoint `POST /api/config/jails/{name}/validate`** that runs only the validation step without actually activating. The frontend can call this for a "Check Config" button.
#### Layer 2: Post-Activation Health Check
4. **After each `activate_jail()` reload**, perform a health-check sequence with retries:
- Wait 2 seconds after sending the reload command.
- Probe the fail2ban socket with `ping`.
- If the probe succeeds, check if the specific jail is active.
- If the probe fails (socket gone / connection refused), retry up to 3 times with 2-second intervals.
- Return the probe result as part of the activation response.
5. **Extend the `JailActivationResponse` model** to include:
- `fail2ban_running: bool` — whether the fail2ban daemon is still running after reload.
- `validation_warnings: list[str]` — any non-fatal warnings from the pre-validation step.
- `error: str | None` — a human-readable error message if something went wrong.
#### Layer 3: Automatic Crash Detection via Background Task
6. **Extend `tasks/health_check.py`** (the periodic health probe that runs every 30 seconds):
- Track the **last known activation event**: when a jail was activated, store its name and timestamp in an in-memory variable (or a lightweight DB record).
- If the health check detects that fail2ban transitioned from `online` to `offline`, and a jail was activated within the last 60 seconds, flag this as a **probable activation failure**.
- Store a `PendingRecovery` record: `{ jail_name: str, activated_at: datetime, detected_at: datetime, recovered: bool }`.
7. **Create a new endpoint `GET /api/config/pending-recovery`** that returns the current `PendingRecovery` record (or `null` if none).
- The frontend polls this endpoint (or it is included in the dashboard status response) to detect when a recovery state is active.
#### Layer 4: User Alert & One-Click Rollback
8. **Frontend — Global alert banner.** When the health status transitions to offline and a `PendingRecovery` record exists:
- Show a **full-width warning banner** at the top of every page (not just the Config page). The banner is dismissible only after the issue is resolved.
- Banner text: "fail2ban stopped after activating jail **{name}**. The jail's configuration may be invalid. Disable this jail and restart fail2ban?"
- Two buttons:
- **"Disable & Restart"** — calls the rollback endpoint (see below).
- **"View Details"** — navigates to the Config page Log tab so the user can inspect the fail2ban log for the exact error message.
9. **Create a rollback endpoint `POST /api/config/jails/{name}/rollback`** in the backend:
- Writes `enabled = false` to the jail's `.local` override (same as `deactivate_jail()` but works even when fail2ban is down since it only writes a file).
- Attempts to start (not reload) the fail2ban daemon via the configured start command (e.g. `systemctl start fail2ban` or `fail2ban-client start`). Make the start command configurable in the app settings.
- Waits up to 10 seconds for the socket to come back, probing every 2 seconds.
- Returns a response indicating whether fail2ban is back online and how many jails are now active.
- Clears the `PendingRecovery` record on success.
10. **Frontend — Rollback result.** After the rollback call returns:
- If successful: show a success toast "fail2ban restarted with {n} active jails. The jail **{name}** has been disabled." and dismiss the banner.
- If fail2ban still doesn't start: show an error dialog explaining that the problem may not be limited to the last activated jail. Suggest the user check the fail2ban log (link to the Log tab) or SSH into the server. Keep the banner visible.
#### Layer 5: Config Page Enhancements
11. **On the Config page Jails tab**, when activating a jail:
- Before activation, show a confirmation dialog that includes any validation warnings from the pre-check.
- During activation, show a spinner with the text "Activating jail and verifying fail2ban…" (acknowledge the post-activation health check takes a few seconds).
- After activation, if `fail2ban_running` is false in the response, immediately show the recovery banner and rollback option without waiting for the background health check.
12. **Add a "Validate" button** next to the "Activate" button on inactive jails. Clicking it calls `POST /api/config/jails/{name}/validate` and shows the validation results in a panel (green for pass, red for each issue found).
### Backend File Map
| File | Changes |
|---|---|
| `services/config_file_service.py` | Add `validate_jail_config()`, extend `activate_jail()` with pre-validation and post-reload health check. |
| `routers/config.py` | Add `POST /api/config/jails/{name}/validate`, `GET /api/config/pending-recovery`, `POST /api/config/jails/{name}/rollback`. |
| `models/config.py` | Add `JailValidationResult`, `PendingRecovery`, extend `JailActivationResponse`. |
| `tasks/health_check.py` | Track last activation event, detect crash-after-activation, write `PendingRecovery` record. |
| `services/health_service.py` | Add helper to attempt daemon start (not just probe). |
### Frontend File Map
| File | Changes |
|---|---|
| `components/config/ActivateJailDialog.tsx` | Add pre-validation call, show warnings, show extended activation feedback. |
| `components/config/JailsTab.tsx` | Add "Validate" button next to "Activate" for inactive jails. |
| `components/common/RecoveryBanner.tsx` (new) | Global warning banner for activation failures with rollback button. |
| `pages/AppLayout.tsx` (or root layout) | Mount the `RecoveryBanner` component so it appears on all pages. |
| `api/config.ts` | Add `validateJailConfig()`, `fetchPendingRecovery()`, `rollbackJail()`. |
| `types/config.ts` | Add `JailValidationResult`, `PendingRecovery`, extend `JailActivationResponse`. |
### Tests
13. **Backend:** Test `validate_jail_config()` — valid config passes, missing filter fails, bad regex fails, missing log path fails.
14. **Backend:** Test the rollback endpoint — mock file write, mock daemon start, verify response for success and failure cases.
15. **Backend:** Test the health-check crash detection — simulate online→offline transition with a recent activation, verify `PendingRecovery` is set.
16. **Frontend:** Test `RecoveryBanner` — renders when `PendingRecovery` is present, disappears after successful rollback, shows error on failed rollback.
17. **Frontend:** Test the "Validate" button on the Jails tab — shows green on valid, shows errors on invalid.
### Acceptance Criteria
- Obvious config errors (missing filter, bad regex, missing log file) are caught **before** the jail is activated.
- If fail2ban crashes after a jail activation, BanGUI detects it within 30 seconds and shows a prominent alert.
- The user can disable the problematic jail and restart fail2ban with a single click from the alert banner.
- If the automatic rollback succeeds, BanGUI confirms fail2ban is back and shows the number of recovered jails.
- If the automatic rollback fails, the user is guided to check the log or intervene manually.
- A standalone "Validate" button lets users check a jail's config without activating it.
- All new endpoints have tests covering success, failure, and edge cases.
**Files:** `frontend/src/components/config/ServerTab.tsx`.
---

View File

@@ -60,6 +60,15 @@ class Settings(BaseSettings):
"Used for listing, viewing, and editing configuration files through the web UI."
),
)
fail2ban_start_command: str = Field(
default="fail2ban-client start",
description=(
"Shell command used to start (not reload) the fail2ban daemon during "
"recovery rollback. Split by whitespace to build the argument list — "
"no shell interpretation is performed. "
"Example: 'systemctl start fail2ban' or 'fail2ban-client start'."
),
)
model_config = SettingsConfigDict(
env_prefix="BANGUI_",
@@ -76,4 +85,4 @@ def get_settings() -> Settings:
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
if required keys are absent or values fail validation.
"""
return Settings()
return Settings() # pydantic-settings populates required fields from env vars

View File

@@ -49,6 +49,7 @@ from app.routers import (
)
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
from app.utils.jail_config import ensure_jail_configs
# ---------------------------------------------------------------------------
# Ensure the bundled fail2ban package is importable from fail2ban-master/
@@ -137,7 +138,13 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
log.info("bangui_starting_up", database_path=settings.database_path)
# --- Ensure required jail config files are present ---
ensure_jail_configs(Path(settings.fail2ban_config_dir) / "jail.d")
# --- Application database ---
db_path: Path = Path(settings.database_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
log.debug("database_directory_ensured", directory=str(db_path.parent))
db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
db.row_factory = aiosqlite.Row
await init_db(db)
@@ -320,17 +327,15 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
if path.startswith("/api") and not getattr(
request.app.state, "_setup_complete_cached", False
):
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
if db is not None:
from app.services import setup_service # noqa: PLC0415
from app.services import setup_service # noqa: PLC0415
if await setup_service.is_setup_complete(db):
request.app.state._setup_complete_cached = True
else:
return RedirectResponse(
url="/api/setup",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
if db is None or not await setup_service.is_setup_complete(db):
return RedirectResponse(
url="/api/setup",
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
)
request.app.state._setup_complete_cached = True
return await call_next(request)

View File

@@ -306,3 +306,30 @@ class BansByJailResponse(BaseModel):
description="Jails ordered by ban count descending.",
)
total: int = Field(..., ge=0, description="Total ban count in the selected window.")
# ---------------------------------------------------------------------------
# Jail-specific paginated bans
# ---------------------------------------------------------------------------
class JailBannedIpsResponse(BaseModel):
"""Paginated response for ``GET /api/jails/{name}/banned``.
Contains only the current page of active ban entries for a single jail,
geo-enriched exclusively for the page slice to avoid rate-limit issues.
"""
model_config = ConfigDict(strict=True)
items: list[ActiveBan] = Field(
default_factory=list,
description="Active ban entries for the current page.",
)
total: int = Field(
...,
ge=0,
description="Total matching entries (after applying the search filter).",
)
page: int = Field(..., ge=1, description="Current page number (1-based).")
page_size: int = Field(..., ge=1, description="Number of items per page.")

View File

@@ -3,6 +3,8 @@
Request, response, and domain models for the config router and service.
"""
import datetime
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
@@ -805,6 +807,14 @@ class InactiveJail(BaseModel):
"inactive jails that appear in this list."
),
)
has_local_override: bool = Field(
default=False,
description=(
"``True`` when a ``jail.d/{name}.local`` file exists for this jail. "
"Only meaningful for inactive jails; indicates that a cleanup action "
"is available."
),
)
class InactiveJailListResponse(BaseModel):
@@ -860,3 +870,140 @@ class JailActivationResponse(BaseModel):
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
)
message: str = Field(..., description="Human-readable result message.")
fail2ban_running: bool = Field(
default=True,
description=(
"Whether the fail2ban daemon is still running after the activation "
"and reload. ``False`` signals that the daemon may have crashed."
),
)
validation_warnings: list[str] = Field(
default_factory=list,
description="Non-fatal warnings from the pre-activation validation step.",
)
recovered: bool | None = Field(
default=None,
description=(
"Set when activation failed after writing the config file. "
"``True`` means the system automatically rolled back the change and "
"restarted fail2ban. ``False`` means the rollback itself also "
"failed and manual intervention is required. ``None`` when "
"activation succeeded or failed before the file was written."
),
)
# ---------------------------------------------------------------------------
# Jail validation models (Task 3)
# ---------------------------------------------------------------------------
class JailValidationIssue(BaseModel):
"""A single issue found during pre-activation validation of a jail config."""
model_config = ConfigDict(strict=True)
field: str = Field(
...,
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
)
message: str = Field(..., description="Human-readable description of the issue.")
class JailValidationResult(BaseModel):
"""Result of pre-activation validation of a single jail configuration."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the validated jail.")
valid: bool = Field(..., description="True when no issues were found.")
issues: list[JailValidationIssue] = Field(
default_factory=list,
description="Validation issues found. Empty when valid=True.",
)
# ---------------------------------------------------------------------------
# Rollback response model (Task 3)
# ---------------------------------------------------------------------------
class RollbackResponse(BaseModel):
"""Response for ``POST /api/config/jails/{name}/rollback``."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(..., description="Name of the jail that was disabled.")
disabled: bool = Field(
...,
description="Whether the jail's .local override was successfully written with enabled=false.",
)
fail2ban_running: bool = Field(
...,
description="Whether fail2ban is online after the rollback attempt.",
)
active_jails: int = Field(
default=0,
ge=0,
description="Number of currently active jails after a successful restart.",
)
message: str = Field(..., description="Human-readable result message.")
# ---------------------------------------------------------------------------
# Pending recovery model (Task 3)
# ---------------------------------------------------------------------------
class PendingRecovery(BaseModel):
"""Records a probable activation-caused fail2ban crash pending user action."""
model_config = ConfigDict(strict=True)
jail_name: str = Field(
...,
description="Name of the jail whose activation likely caused the crash.",
)
activated_at: datetime.datetime = Field(
...,
description="ISO-8601 UTC timestamp of when the jail was activated.",
)
detected_at: datetime.datetime = Field(
...,
description="ISO-8601 UTC timestamp of when the crash was detected.",
)
recovered: bool = Field(
default=False,
description="Whether fail2ban has been successfully restarted.",
)
# ---------------------------------------------------------------------------
# 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

@@ -9,6 +9,9 @@ global settings, test regex patterns, add log paths, and preview log files.
* ``GET /api/config/jails/inactive`` — list all inactive jails
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
* ``POST /api/config/jails/{name}/validate`` — validate jail config pre-activation (Task 3)
* ``POST /api/config/jails/{name}/rollback`` — disable bad jail and restart fail2ban (Task 3)
* ``GET /api/config/pending-recovery`` — active crash-recovery record (Task 3)
* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail
* ``POST /api/config/jails/{name}/action`` — add an action to a jail
* ``DELETE /api/config/jails/{name}/action/{action_name}`` — remove an action from a jail
@@ -28,15 +31,21 @@ 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
import datetime
from typing import Annotated
import structlog
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import AuthDep
log: structlog.stdlib.BoundLogger = structlog.get_logger()
from app.models.config import (
ActionConfig,
ActionCreateRequest,
@@ -46,6 +55,7 @@ from app.models.config import (
AddLogPathRequest,
AssignActionRequest,
AssignFilterRequest,
Fail2BanLogResponse,
FilterConfig,
FilterCreateRequest,
FilterListResponse,
@@ -57,12 +67,16 @@ from app.models.config import (
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
JailValidationResult,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
PendingRecovery,
RegexTestRequest,
RegexTestResponse,
RollbackResponse,
ServiceStatusResponse,
)
from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
@@ -86,6 +100,8 @@ from app.services.config_service import (
ConfigValidationError,
JailNotFoundError,
)
from app.services.jail_service import JailOperationError
from app.tasks.health_check import _run_probe
from app.utils.fail2ban_client import Fail2BanConnectionError
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
@@ -345,15 +361,88 @@ async def reload_fail2ban(
_auth: Validated session.
Raises:
HTTPException: 409 when fail2ban reports the reload failed.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await jail_service.reload_all(socket_path)
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"fail2ban reload failed: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Restart endpoint
# ---------------------------------------------------------------------------
@router.post(
"/restart",
status_code=status.HTTP_204_NO_CONTENT,
summary="Restart the fail2ban service",
)
async def restart_fail2ban(
request: Request,
_auth: AuthDep,
) -> None:
"""Trigger a full fail2ban service restart.
Stops the fail2ban daemon via the Unix domain socket, then starts it
again using the configured ``fail2ban_start_command``. After starting,
probes the socket for up to 10 seconds to confirm the daemon came back
online.
Args:
request: Incoming request.
_auth: Validated session.
Raises:
HTTPException: 409 when fail2ban reports the stop command failed.
HTTPException: 502 when fail2ban is unreachable for the stop command.
HTTPException: 503 when fail2ban does not come back online within
10 seconds after being started. Check the fail2ban log for
initialisation errors. Use
``POST /api/config/jails/{name}/rollback`` if a specific jail
is suspect.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
start_cmd: str = request.app.state.settings.fail2ban_start_command
start_cmd_parts: list[str] = start_cmd.split()
# Step 1: stop the daemon via socket.
try:
await jail_service.restart(socket_path)
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"fail2ban stop command failed: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Step 2: start the daemon via subprocess.
await config_file_service.start_daemon(start_cmd_parts)
# Step 3: probe the socket until fail2ban is responsive or the budget expires.
fail2ban_running: bool = await config_file_service.wait_for_fail2ban(
socket_path, max_wait_seconds=10.0
)
if not fail2ban_running:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
"fail2ban was stopped but did not come back online within 10 seconds. "
"Check the fail2ban log for initialisation errors. "
"Use POST /api/config/jails/{name}/rollback if a specific jail is suspect."
),
)
log.info("fail2ban_restarted")
# ---------------------------------------------------------------------------
# Regex tester (stateless)
# ---------------------------------------------------------------------------
@@ -607,7 +696,7 @@ async def activate_jail(
req = body if body is not None else ActivateJailRequest()
try:
return await config_file_service.activate_jail(
result = await config_file_service.activate_jail(
config_dir, socket_path, name, req
)
except JailNameError as exc:
@@ -627,6 +716,28 @@ async def activate_jail(
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Record this activation so the health-check task can attribute a
# subsequent fail2ban crash to it.
request.app.state.last_activation = {
"jail_name": name,
"at": datetime.datetime.now(tz=datetime.UTC),
}
# If fail2ban stopped responding after the reload, create a pending-recovery
# record immediately (before the background health task notices).
if not result.fail2ban_running:
request.app.state.pending_recovery = PendingRecovery(
jail_name=name,
activated_at=request.app.state.last_activation["at"],
detected_at=datetime.datetime.now(tz=datetime.UTC),
)
# Force an immediate health probe so the cached status reflects the current
# fail2ban state without waiting for the next scheduled check.
await _run_probe(request.app)
return result
@router.post(
"/jails/{name}/deactivate",
@@ -661,7 +772,7 @@ async def deactivate_jail(
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.deactivate_jail(config_dir, socket_path, name)
result = await config_file_service.deactivate_jail(config_dir, socket_path, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
@@ -679,6 +790,186 @@ async def deactivate_jail(
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Force an immediate health probe so the cached status reflects the current
# fail2ban state (reload changes the active-jail count) without waiting for
# the next scheduled background check (up to 30 seconds).
await _run_probe(request.app)
return result
@router.delete(
"/jails/{name}/local",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete the jail.d override file for an inactive jail",
)
async def delete_jail_local_override(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> None:
"""Remove the ``jail.d/{name}.local`` override file for an inactive jail.
This endpoint is the clean-up action for inactive jails that still carry
a ``.local`` override file (e.g. one written with ``enabled = false`` by a
previous deactivation). The file is deleted without modifying fail2ban's
running state, since the jail is already inactive.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail whose ``.local`` file should be removed.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
HTTPException: 409 if the jail is currently active.
HTTPException: 500 if the file cannot be deleted.
HTTPException: 502 if fail2ban is unreachable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.delete_jail_local_override(
config_dir, socket_path, name
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except JailAlreadyActiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is currently active; deactivate it first.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Jail validation & rollback endpoints (Task 3)
# ---------------------------------------------------------------------------
@router.post(
"/jails/{name}/validate",
response_model=JailValidationResult,
summary="Validate jail configuration before activation",
)
async def validate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> JailValidationResult:
"""Run pre-activation validation checks on a jail configuration.
Validates filter and action file existence, regex pattern compilation, and
log path existence without modifying any files or reloading fail2ban.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name to validate.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await config_file_service.validate_jail_config(config_dir, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
@router.get(
"/pending-recovery",
response_model=PendingRecovery | None,
summary="Return active crash-recovery record if one exists",
)
async def get_pending_recovery(
request: Request,
_auth: AuthDep,
) -> PendingRecovery | None:
"""Return the current :class:`~app.models.config.PendingRecovery` record.
A non-null response means fail2ban crashed shortly after a jail activation
and the user should be offered a rollback option. Returns ``null`` (HTTP
200 with ``null`` body) when no recovery is pending.
Args:
request: FastAPI request object.
_auth: Validated session.
Returns:
:class:`~app.models.config.PendingRecovery` or ``None``.
"""
return getattr(request.app.state, "pending_recovery", None)
@router.post(
"/jails/{name}/rollback",
response_model=RollbackResponse,
summary="Disable a bad jail config and restart fail2ban",
)
async def rollback_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> RollbackResponse:
"""Disable the specified jail and attempt to restart fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
fail2ban is down — no socket is needed), then runs the configured start
command and waits up to ten seconds for the daemon to come back online.
On success, clears the :class:`~app.models.config.PendingRecovery` record.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name to disable and roll back.
Returns:
:class:`~app.models.config.RollbackResponse`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 500 if writing the .local override file fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
start_cmd: str = request.app.state.settings.fail2ban_start_command
start_cmd_parts: list[str] = start_cmd.split()
try:
result = await config_file_service.rollback_jail(
config_dir, socket_path, name, start_cmd_parts
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
# Clear pending recovery if fail2ban came back online.
if result.fail2ban_running:
request.app.state.pending_recovery = None
request.app.state.last_activation = None
return result
# ---------------------------------------------------------------------------
# Filter discovery endpoints (Task 2.1)
@@ -1319,3 +1610,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 (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

@@ -14,8 +14,8 @@ Endpoints:
* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
* ``GET /api/config/actions`` — list all action files
* ``GET /api/config/actions/{name}`` — get one action file (with content)
* ``PUT /api/config/actions/{name}`` — update an action file
* ``GET /api/config/actions/{name}/raw`` — get one action file (raw content)
* ``PUT /api/config/actions/{name}/raw`` — update an action file (raw content)
* ``POST /api/config/actions`` — create a new action file
* ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model
* ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model
@@ -460,7 +460,7 @@ async def list_action_files(
@router.get(
"/actions/{name}",
"/actions/{name}/raw",
response_model=ConfFileContent,
summary="Return an action definition file with its content",
)
@@ -496,7 +496,7 @@ async def get_action_file(
@router.put(
"/actions/{name}",
"/actions/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action definition file",
)

View File

@@ -1,21 +1,37 @@
"""Health check router.
A lightweight ``GET /api/health`` endpoint that verifies the application
is running and can serve requests. It does not probe fail2ban — that
responsibility belongs to the health service (Stage 4).
is running and can serve requests. Also reports the cached fail2ban liveness
state so monitoring tools and Docker health checks can observe daemon status
without probing the socket directly.
"""
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from app.models.server import ServerStatus
router: APIRouter = APIRouter(prefix="/api", tags=["Health"])
@router.get("/health", summary="Application health check")
async def health_check() -> JSONResponse:
"""Return a 200 response confirming the API is operational.
async def health_check(request: Request) -> JSONResponse:
"""Return 200 with application and fail2ban status.
HTTP 200 is always returned so Docker health checks do not restart the
backend container when fail2ban is temporarily offline. The
``fail2ban`` field in the body indicates the daemon's current state.
Args:
request: FastAPI request (used to read cached server status).
Returns:
A JSON object with ``{"status": "ok"}``.
A JSON object with ``{"status": "ok", "fail2ban": "online"|"offline"}``.
"""
return JSONResponse(content={"status": "ok"})
cached: ServerStatus = getattr(
request.app.state, "server_status", ServerStatus(online=False)
)
return JSONResponse(content={
"status": "ok",
"fail2ban": "online" if cached.online else "offline",
})

View File

@@ -4,6 +4,7 @@ Provides CRUD and control operations for fail2ban jails:
* ``GET /api/jails`` — list all jails
* ``GET /api/jails/{name}`` — full detail for one jail
* ``GET /api/jails/{name}/banned`` — paginated currently-banned IPs for one jail
* ``POST /api/jails/{name}/start`` — start a jail
* ``POST /api/jails/{name}/stop`` — stop a jail
* ``POST /api/jails/{name}/idle`` — toggle idle mode
@@ -23,6 +24,7 @@ from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Path, Request, status
from app.dependencies import AuthDep
from app.models.ban import JailBannedIpsResponse
from app.models.jail import (
IgnoreIpRequest,
JailCommandResponse,
@@ -540,3 +542,74 @@ async def toggle_ignore_self(
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Currently banned IPs (paginated)
# ---------------------------------------------------------------------------
@router.get(
"/{name}/banned",
response_model=JailBannedIpsResponse,
summary="Return paginated currently-banned IPs for a single jail",
)
async def get_jail_banned_ips(
request: Request,
_auth: AuthDep,
name: _NamePath,
page: int = 1,
page_size: int = 25,
search: str | None = None,
) -> JailBannedIpsResponse:
"""Return a paginated list of IPs currently banned by a specific jail.
The full ban list is fetched from the fail2ban socket, filtered by the
optional *search* substring, sliced to the requested page, and then
geo-enriched exclusively for that page slice.
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
name: Jail name.
page: 1-based page number (default 1, min 1).
page_size: Items per page (default 25, max 100).
search: Optional case-insensitive substring filter on the IP address.
Returns:
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
Raises:
HTTPException: 400 when *page* or *page_size* are out of range.
HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable.
"""
if page < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="page must be >= 1.",
)
if not (1 <= page_size <= 100):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="page_size must be between 1 and 100.",
)
socket_path: str = request.app.state.settings.fail2ban_socket
http_session = getattr(request.app.state, "http_session", None)
app_db = getattr(request.app.state, "db", None)
try:
return await jail_service.get_jail_banned_ips(
socket_path=socket_path,
jail_name=name,
page=page,
page_size=page_size,
search=search,
http_session=http_session,
app_db=app_db,
)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -50,8 +50,12 @@ from app.models.config import (
InactiveJail,
InactiveJailListResponse,
JailActivationResponse,
JailValidationIssue,
JailValidationResult,
RollbackResponse,
)
from app.services import conffile_parser, jail_service
from app.services.jail_service import JailNotFoundError as JailNotFoundError
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -425,6 +429,7 @@ def _build_inactive_jail(
name: str,
settings: dict[str, str],
source_file: str,
config_dir: Path | None = None,
) -> InactiveJail:
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
@@ -432,6 +437,8 @@ def _build_inactive_jail(
name: Jail section name.
settings: Merged key→value dict (DEFAULT values already applied).
source_file: Path of the file that last defined this section.
config_dir: Absolute path to the fail2ban configuration directory, used
to check whether a ``jail.d/{name}.local`` override file exists.
Returns:
Populated :class:`~app.models.config.InactiveJail`.
@@ -509,6 +516,11 @@ def _build_inactive_jail(
bantime_escalation=bantime_escalation,
source_file=source_file,
enabled=enabled,
has_local_override=(
(config_dir / "jail.d" / f"{name}.local").is_file()
if config_dir is not None
else False
),
)
@@ -560,6 +572,242 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
return set()
# ---------------------------------------------------------------------------
# Validation helpers (Task 3)
# ---------------------------------------------------------------------------
# Seconds to wait between fail2ban liveness probes after a reload.
_POST_RELOAD_PROBE_INTERVAL: float = 2.0
# Maximum number of post-reload probe attempts (initial attempt + retries).
_POST_RELOAD_MAX_ATTEMPTS: int = 4
def _extract_action_base_name(action_str: str) -> str | None:
"""Return the base action name from an action assignment string.
Returns ``None`` for complex fail2ban expressions that cannot be resolved
to a single filename (e.g. ``%(action_)s`` interpolations or multi-token
composite actions).
Args:
action_str: A single line from the jail's ``action`` setting.
Returns:
Simple base name suitable for a filesystem lookup, or ``None``.
"""
if "%" in action_str or "$" in action_str:
return None
base = action_str.split("[")[0].strip()
if _SAFE_ACTION_NAME_RE.match(base):
return base
return None
def _validate_jail_config_sync(
config_dir: Path,
name: str,
) -> JailValidationResult:
"""Run synchronous pre-activation checks on a jail configuration.
Validates:
1. Filter file existence in ``filter.d/``.
2. Action file existence in ``action.d/`` (for resolvable action names).
3. Regex compilation for every ``failregex`` and ``ignoreregex`` pattern.
4. Log path existence on disk (generates warnings, not errors).
Args:
config_dir: The fail2ban configuration root directory.
name: Validated jail name.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
"""
issues: list[JailValidationIssue] = []
all_jails, _ = _parse_jails_sync(config_dir)
settings = all_jails.get(name)
if settings is None:
return JailValidationResult(
jail_name=name,
valid=False,
issues=[
JailValidationIssue(
field="name",
message=f"Jail {name!r} not found in config files.",
)
],
)
filter_d = config_dir / "filter.d"
action_d = config_dir / "action.d"
# 1. Filter existence check.
raw_filter = settings.get("filter", "")
if raw_filter:
mode = settings.get("mode", "normal")
resolved = _resolve_filter(raw_filter, name, mode)
base_filter = _extract_filter_base_name(resolved)
if base_filter:
conf_ok = (filter_d / f"{base_filter}.conf").is_file()
local_ok = (filter_d / f"{base_filter}.local").is_file()
if not conf_ok and not local_ok:
issues.append(
JailValidationIssue(
field="filter",
message=(
f"Filter file not found: filter.d/{base_filter}.conf"
" (or .local)"
),
)
)
# 2. Action existence check.
raw_action = settings.get("action", "")
if raw_action:
for action_line in _parse_multiline(raw_action):
action_name = _extract_action_base_name(action_line)
if action_name:
conf_ok = (action_d / f"{action_name}.conf").is_file()
local_ok = (action_d / f"{action_name}.local").is_file()
if not conf_ok and not local_ok:
issues.append(
JailValidationIssue(
field="action",
message=(
f"Action file not found: action.d/{action_name}.conf"
" (or .local)"
),
)
)
# 3. failregex compilation.
for pattern in _parse_multiline(settings.get("failregex", "")):
try:
re.compile(pattern)
except re.error as exc:
issues.append(
JailValidationIssue(
field="failregex",
message=f"Invalid regex pattern: {exc}",
)
)
# 4. ignoreregex compilation.
for pattern in _parse_multiline(settings.get("ignoreregex", "")):
try:
re.compile(pattern)
except re.error as exc:
issues.append(
JailValidationIssue(
field="ignoreregex",
message=f"Invalid regex pattern: {exc}",
)
)
# 5. Log path existence (warning only — paths may be created at runtime).
raw_logpath = settings.get("logpath", "")
if raw_logpath:
for log_path in _parse_multiline(raw_logpath):
# Skip glob patterns and fail2ban variable references.
if "*" in log_path or "?" in log_path or "%(" in log_path:
continue
if not Path(log_path).exists():
issues.append(
JailValidationIssue(
field="logpath",
message=f"Log file not found on disk: {log_path}",
)
)
valid = len(issues) == 0
log.debug(
"jail_validation_complete",
jail=name,
valid=valid,
issue_count=len(issues),
)
return JailValidationResult(jail_name=name, valid=valid, issues=issues)
async def _probe_fail2ban_running(socket_path: str) -> bool:
"""Return ``True`` if the fail2ban socket responds to a ping.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
``True`` when fail2ban is reachable, ``False`` otherwise.
"""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=5.0)
resp = await client.send(["ping"])
return isinstance(resp, (list, tuple)) and resp[0] == 0
except Exception: # noqa: BLE001
return False
async def wait_for_fail2ban(
socket_path: str,
max_wait_seconds: float = 10.0,
poll_interval: float = 2.0,
) -> bool:
"""Poll the fail2ban socket until it responds or the timeout expires.
Args:
socket_path: Path to the fail2ban Unix domain socket.
max_wait_seconds: Total time budget in seconds.
poll_interval: Delay between probe attempts in seconds.
Returns:
``True`` if fail2ban came online within the budget.
"""
elapsed = 0.0
while elapsed < max_wait_seconds:
if await _probe_fail2ban_running(socket_path):
return True
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return False
async def start_daemon(start_cmd_parts: list[str]) -> bool:
"""Start the fail2ban daemon using *start_cmd_parts*.
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
to avoid command injection.
Args:
start_cmd_parts: Command and arguments, e.g.
``["fail2ban-client", "start"]``.
Returns:
``True`` when the process exited with code 0.
"""
if not start_cmd_parts:
log.warning("fail2ban_start_cmd_empty")
return False
try:
proc = await asyncio.create_subprocess_exec(
*start_cmd_parts,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.wait(), timeout=30.0)
success = proc.returncode == 0
if not success:
log.warning(
"fail2ban_start_cmd_nonzero",
cmd=start_cmd_parts,
returncode=proc.returncode,
)
return success
except (TimeoutError, OSError) as exc:
log.warning("fail2ban_start_cmd_error", cmd=start_cmd_parts, error=str(exc))
return False
def _write_local_override_sync(
config_dir: Path,
jail_name: str,
@@ -648,6 +896,50 @@ def _write_local_override_sync(
)
def _restore_local_file_sync(local_path: Path, original_content: bytes | None) -> None:
"""Restore a ``.local`` file to its pre-activation state.
If *original_content* is ``None``, the file is deleted (it did not exist
before the activation). Otherwise the original bytes are written back
atomically via a temp-file rename.
Args:
local_path: Absolute path to the ``.local`` file to restore.
original_content: Original raw bytes to write back, or ``None`` to
delete the file.
Raises:
ConfigWriteError: If the write or delete operation fails.
"""
if original_content is None:
try:
local_path.unlink(missing_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path} during rollback: {exc}"
) from exc
return
tmp_name: str | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=local_path.parent,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(original_content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
if tmp_name is not None:
os.unlink(tmp_name)
raise ConfigWriteError(
f"Failed to restore {local_path} during rollback: {exc}"
) from exc
def _validate_regex_patterns(patterns: list[str]) -> None:
"""Validate each pattern in *patterns* using Python's ``re`` module.
@@ -827,7 +1119,7 @@ async def list_inactive_jails(
continue
source = source_files.get(jail_name, config_dir)
inactive.append(_build_inactive_jail(jail_name, settings, source))
inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir)))
log.info(
"inactive_jails_listed",
@@ -846,9 +1138,10 @@ async def activate_jail(
) -> JailActivationResponse:
"""Enable an inactive jail and reload fail2ban.
Writes ``enabled = true`` (plus any override values from *req*) to
``jail.d/{name}.local`` and then triggers a full fail2ban reload so the
jail starts immediately.
Performs pre-activation validation, writes ``enabled = true`` (plus any
override values from *req*) to ``jail.d/{name}.local``, and triggers a
full fail2ban reload. After the reload a multi-attempt health probe
determines whether fail2ban (and the specific jail) are still running.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
@@ -857,7 +1150,8 @@ async def activate_jail(
req: Optional override values to write alongside ``enabled = true``.
Returns:
:class:`~app.models.config.JailActivationResponse`.
:class:`~app.models.config.JailActivationResponse` including
``fail2ban_running`` and ``validation_warnings`` fields.
Raises:
JailNameError: If *name* contains invalid characters.
@@ -881,6 +1175,39 @@ async def activate_jail(
if name in active_names:
raise JailAlreadyActiveError(name)
# ---------------------------------------------------------------------- #
# Pre-activation validation — collect warnings but do not block #
# ---------------------------------------------------------------------- #
validation_result: JailValidationResult = await loop.run_in_executor(
None, _validate_jail_config_sync, Path(config_dir), name
)
warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues]
if warnings:
log.warning(
"jail_activation_validation_warnings",
jail=name,
warnings=warnings,
)
# Block activation on critical validation failures (missing filter or logpath).
blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")]
if blocking:
log.warning(
"jail_activation_blocked",
jail=name,
issues=[f"{i.field}: {i.message}" for i in blocking],
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
validation_warnings=warnings,
message=(
f"Jail {name!r} cannot be activated: "
+ "; ".join(i.message for i in blocking)
),
)
overrides: dict[str, Any] = {
"bantime": req.bantime,
"findtime": req.findtime,
@@ -889,6 +1216,16 @@ async def activate_jail(
"logpath": req.logpath,
}
# ---------------------------------------------------------------------- #
# Backup the existing .local file (if any) before overwriting it so that #
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await loop.run_in_executor(
None,
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await loop.run_in_executor(
None,
_write_local_override_sync,
@@ -898,28 +1235,108 @@ async def activate_jail(
overrides,
)
# ---------------------------------------------------------------------- #
# Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- #
try:
await jail_service.reload_all(socket_path, include_jails=[name])
except JailNotFoundError as exc:
# Jail configuration is invalid (e.g. missing logpath that prevents
# fail2ban from loading the jail). Roll back and provide a specific error.
log.warning(
"reload_after_activate_failed_jail_not_found",
jail=name,
error=str(exc),
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} activation failed: {str(exc)}. "
"Check that all logpath files exist and are readable. "
"The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
except Exception as exc: # noqa: BLE001
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} activation failed during reload and the "
"configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
# Verify the jail actually started after the reload. A config error
# (bad regex, missing log file, etc.) may silently prevent fail2ban from
# starting the jail even though the reload command succeeded.
# ---------------------------------------------------------------------- #
# Post-reload health probe with retries #
# ---------------------------------------------------------------------- #
fail2ban_running = False
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
if attempt > 0:
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
if await _probe_fail2ban_running(socket_path):
fail2ban_running = True
break
if not fail2ban_running:
log.warning(
"fail2ban_down_after_activate",
jail=name,
message="fail2ban socket unreachable after reload — initiating rollback.",
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} activation failed: fail2ban stopped responding "
"after reload. The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
# Verify the jail actually started (config error may prevent it silently).
post_reload_names = await _get_active_jail_names(socket_path)
actually_running = name in post_reload_names
if not actually_running:
log.warning(
"jail_activation_unverified",
jail=name,
message="Jail did not appear in running jails after reload.",
message="Jail did not appear in running jails — initiating rollback.",
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} was written to config but did not start after "
"reload — check the jail configuration (filters, log paths, regex)."
"reload. The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
@@ -927,10 +1344,76 @@ async def activate_jail(
return JailActivationResponse(
name=name,
active=True,
fail2ban_running=True,
validation_warnings=warnings,
message=f"Jail {name!r} activated successfully.",
)
async def _rollback_activation_async(
config_dir: str,
name: str,
socket_path: str,
original_content: bytes | None,
) -> bool:
"""Restore the pre-activation ``.local`` file and reload fail2ban.
Called internally by :func:`activate_jail` when the activation fails after
the config file was already written. Tries to:
1. Restore the original file content (or delete the file if it was newly
created by the activation attempt).
2. Reload fail2ban so the daemon runs with the restored configuration.
3. Probe fail2ban to confirm it came back up.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Name of the jail whose ``.local`` file should be restored.
socket_path: Path to the fail2ban Unix domain socket.
original_content: Raw bytes of the original ``.local`` file, or
``None`` if the file did not exist before the activation.
Returns:
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
try:
await loop.run_in_executor(
None, _restore_local_file_sync, local_path, original_content
)
log.info("jail_activation_rollback_file_restored", jail=name)
except ConfigWriteError as exc:
log.error(
"jail_activation_rollback_restore_failed", jail=name, error=str(exc)
)
return False
# Step 2 — reload fail2ban with the restored config.
try:
await jail_service.reload_all(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001
log.warning(
"jail_activation_rollback_reload_failed", jail=name, error=str(exc)
)
return False
# Step 3 — wait for fail2ban to come back.
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
if attempt > 0:
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
if await _probe_fail2ban_running(socket_path):
log.info("jail_activation_rollback_recovered", jail=name)
return True
log.warning("jail_activation_rollback_still_down", jail=name)
return False
async def deactivate_jail(
config_dir: str,
socket_path: str,
@@ -994,6 +1477,168 @@ async def deactivate_jail(
)
async def delete_jail_local_override(
config_dir: str,
socket_path: str,
name: str,
) -> None:
"""Delete the ``jail.d/{name}.local`` override file for an inactive jail.
This is the clean-up action shown in the config UI when an inactive jail
still has a ``.local`` override file (e.g. ``enabled = false``). The
file is deleted outright; no fail2ban reload is required because the jail
is already inactive.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail whose ``.local`` file should be removed.
Raises:
JailNameError: If *name* contains invalid characters.
JailNotFoundInConfigError: If *name* is not defined in any config file.
JailAlreadyActiveError: If the jail is currently active (refusing to
delete the live config file).
ConfigWriteError: If the file cannot be deleted.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
if name in active_names:
raise JailAlreadyActiveError(name)
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
try:
await loop.run_in_executor(
None, lambda: local_path.unlink(missing_ok=True)
)
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path}: {exc}"
) from exc
log.info("jail_local_override_deleted", jail=name, path=str(local_path))
async def validate_jail_config(
config_dir: str,
name: str,
) -> JailValidationResult:
"""Run pre-activation validation checks on a jail configuration.
Validates that referenced filter and action files exist in ``filter.d/``
and ``action.d/``, that all regex patterns compile, and that declared log
paths exist on disk.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Name of the jail to validate.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
Raises:
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
_validate_jail_config_sync,
Path(config_dir),
name,
)
async def rollback_jail(
config_dir: str,
socket_path: str,
name: str,
start_cmd_parts: list[str],
) -> RollbackResponse:
"""Disable a bad jail config and restart the fail2ban daemon.
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
fail2ban is down — only a file write), then attempts to start the daemon
with *start_cmd_parts*. Waits up to 10 seconds for the socket to respond.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to disable.
start_cmd_parts: Argument list for the daemon start command, e.g.
``["fail2ban-client", "start"]``.
Returns:
:class:`~app.models.config.RollbackResponse`.
Raises:
JailNameError: If *name* contains invalid characters.
ConfigWriteError: If writing the ``.local`` file fails.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
False,
{},
)
log.info("jail_rolled_back_disabled", jail=name)
# Attempt to start the daemon.
started = await start_daemon(start_cmd_parts)
log.info("jail_rollback_start_attempted", jail=name, start_ok=started)
# Wait for the socket to come back.
fail2ban_running = await wait_for_fail2ban(
socket_path, max_wait_seconds=10.0, poll_interval=2.0
)
active_jails = 0
if fail2ban_running:
names = await _get_active_jail_names(socket_path)
active_jails = len(names)
if fail2ban_running:
log.info("jail_rollback_success", jail=name, active_jails=active_jails)
return RollbackResponse(
jail_name=name,
disabled=True,
fail2ban_running=True,
active_jails=active_jails,
message=(
f"Jail {name!r} disabled and fail2ban restarted successfully "
f"with {active_jails} active jail(s)."
),
)
log.warning("jail_rollback_fail2ban_still_down", jail=name)
return RollbackResponse(
jail_name=name,
disabled=True,
fail2ban_running=False,
active_jails=0,
message=(
f"Jail {name!r} was disabled but fail2ban did not come back online. "
"Check the fail2ban log for additional errors."
),
)
# ---------------------------------------------------------------------------
# Filter discovery helpers (Task 2.1)
# ---------------------------------------------------------------------------

View File

@@ -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", "/config/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 or /config/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

@@ -18,7 +18,7 @@ from typing import Any
import structlog
from app.models.ban import ActiveBan, ActiveBanListResponse
from app.models.ban import ActiveBan, ActiveBanListResponse, JailBannedIpsResponse
from app.models.config import BantimeEscalation
from app.models.jail import (
Jail,
@@ -43,6 +43,13 @@ _SOCKET_TIMEOUT: float = 10.0
# ensures only one reload stream is in-flight at a time.
_reload_all_lock: asyncio.Lock = asyncio.Lock()
# Capability detection for optional fail2ban transmitter commands (backend, idle).
# These commands are not supported in all fail2ban versions. Caching the result
# avoids sending unsupported commands every polling cycle and spamming the
# fail2ban log with "Invalid command" errors.
_backend_cmd_supported: bool | None = None
_backend_cmd_lock: asyncio.Lock = asyncio.Lock()
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
@@ -185,6 +192,51 @@ async def _safe_get(
return default
async def _check_backend_cmd_supported(
client: Fail2BanClient,
jail_name: str,
) -> bool:
"""Detect whether the fail2ban daemon supports optional ``get ... backend`` command.
Some fail2ban versions (e.g. LinuxServer.io container) do not implement the
optional ``get <jail> backend`` and ``get <jail> idle`` transmitter sub-commands.
This helper probes the daemon once and caches the result to avoid repeated
"Invalid command" errors in the fail2ban log.
Uses double-check locking to minimize lock contention in concurrent polls.
Args:
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
jail_name: Name of any jail to use for the probe command.
Returns:
``True`` if the command is supported, ``False`` otherwise.
Once determined, the result is cached and reused for all jails.
"""
global _backend_cmd_supported
# Fast path: return cached result if already determined.
if _backend_cmd_supported is not None:
return _backend_cmd_supported
# Slow path: acquire lock and probe the command once.
async with _backend_cmd_lock:
# Double-check idiom: another coroutine may have probed while we waited.
if _backend_cmd_supported is not None:
return _backend_cmd_supported
# Probe: send the command and catch any exception.
try:
_ok(await client.send(["get", jail_name, "backend"]))
_backend_cmd_supported = True
log.debug("backend_cmd_supported_detected")
except Exception:
_backend_cmd_supported = False
log.debug("backend_cmd_unsupported_detected")
return _backend_cmd_supported
# ---------------------------------------------------------------------------
# Public API — Jail listing & detail
# ---------------------------------------------------------------------------
@@ -238,7 +290,11 @@ async def _fetch_jail_summary(
"""Fetch and build a :class:`~app.models.jail.JailSummary` for one jail.
Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``,
``backend``, and ``idle`` commands in parallel.
``backend``, and ``idle`` commands in parallel (if supported).
The ``backend`` and ``idle`` commands are optional and not supported in
all fail2ban versions. If not supported, this function will not send them
to avoid spamming the fail2ban log with "Invalid command" errors.
Args:
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
@@ -247,15 +303,38 @@ async def _fetch_jail_summary(
Returns:
A :class:`~app.models.jail.JailSummary` populated from the responses.
"""
_r = await asyncio.gather(
# Check whether optional backend/idle commands are supported.
# This probe happens once per session and is cached to avoid repeated
# "Invalid command" errors in the fail2ban log.
backend_cmd_is_supported = await _check_backend_cmd_supported(client, name)
# Build the gather list based on command support.
gather_list: list[Any] = [
client.send(["status", name, "short"]),
client.send(["get", name, "bantime"]),
client.send(["get", name, "findtime"]),
client.send(["get", name, "maxretry"]),
client.send(["get", name, "backend"]),
client.send(["get", name, "idle"]),
return_exceptions=True,
)
]
if backend_cmd_is_supported:
# Commands are supported; send them for real values.
gather_list.extend([
client.send(["get", name, "backend"]),
client.send(["get", name, "idle"]),
])
uses_backend_backend_commands = True
else:
# Commands not supported; return default values without sending.
async def _return_default(value: Any) -> tuple[int, Any]:
return (0, value)
gather_list.extend([
_return_default("polling"), # backend default
_return_default(False), # idle default
])
uses_backend_backend_commands = False
_r = await asyncio.gather(*gather_list, return_exceptions=True)
status_raw: Any = _r[0]
bantime_raw: Any = _r[1]
findtime_raw: Any = _r[2]
@@ -569,7 +648,10 @@ async def reload_all(
exclude_jails: Jail names to remove from the start stream.
Raises:
JailOperationError: If fail2ban reports the operation failed.
JailNotFoundError: If a jail in *include_jails* does not exist or
its configuration is invalid (e.g. missing logpath).
JailOperationError: If fail2ban reports the operation failed for
a different reason.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
@@ -593,9 +675,43 @@ async def reload_all(
_ok(await client.send(["reload", "--all", [], stream]))
log.info("all_jails_reloaded")
except ValueError as exc:
# Detect UnknownJailException (missing or invalid jail configuration)
# and re-raise as JailNotFoundError for better error specificity.
if _is_not_found_error(exc):
# Extract the jail name from include_jails if available.
jail_name = include_jails[0] if include_jails else "unknown"
raise JailNotFoundError(jail_name) from exc
raise JailOperationError(str(exc)) from exc
async def restart(socket_path: str) -> None:
"""Stop the fail2ban daemon via the Unix socket.
Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()``
on the daemon side and tears down all jails. The caller is responsible
for starting the daemon again (e.g. via ``fail2ban-client start``).
Note:
``["restart"]`` is a *client-side* orchestration command that is not
handled by the fail2ban server transmitter — sending it to the socket
raises ``"Invalid command"`` in the daemon.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Raises:
JailOperationError: If fail2ban reports the stop command failed.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["stop"]))
log.info("fail2ban_stopped_for_restart")
except ValueError as exc:
raise JailOperationError(str(exc)) from exc
# ---------------------------------------------------------------------------
# Public API — Ban / Unban
# ---------------------------------------------------------------------------
@@ -862,6 +978,120 @@ def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None:
return None
# ---------------------------------------------------------------------------
# Public API — Jail-specific paginated bans
# ---------------------------------------------------------------------------
#: Maximum allowed page size for :func:`get_jail_banned_ips`.
_MAX_PAGE_SIZE: int = 100
async def get_jail_banned_ips(
socket_path: str,
jail_name: str,
page: int = 1,
page_size: int = 25,
search: str | None = None,
http_session: Any | None = None,
app_db: Any | None = None,
) -> JailBannedIpsResponse:
"""Return a paginated list of currently banned IPs for a single jail.
Fetches the full ban list from the fail2ban socket, applies an optional
substring search filter on the IP, paginates server-side, and geo-enriches
**only** the current page slice to stay within rate limits.
Args:
socket_path: Path to the fail2ban Unix domain socket.
jail_name: Name of the jail to query.
page: 1-based page number (default 1).
page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25).
search: Optional case-insensitive substring filter applied to IP addresses.
http_session: Optional shared :class:`aiohttp.ClientSession` for geo
enrichment via :func:`~app.services.geo_service.lookup_batch`.
app_db: Optional BanGUI application database for persistent geo cache.
Returns:
:class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans.
Raises:
JailNotFoundError: If *jail_name* is not a known active jail.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is
unreachable.
"""
from app.services import geo_service # noqa: PLC0415
# Clamp page_size to the allowed maximum.
page_size = min(page_size, _MAX_PAGE_SIZE)
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
# Verify the jail exists.
try:
_ok(await client.send(["status", jail_name, "short"]))
except ValueError as exc:
if _is_not_found_error(exc):
raise JailNotFoundError(jail_name) from exc
raise
# Fetch the full ban list for this jail.
try:
raw_result = _ok(await client.send(["get", jail_name, "banip", "--with-time"]))
except (ValueError, TypeError):
raw_result = []
ban_list: list[str] = raw_result or []
# Parse all entries.
all_bans: list[ActiveBan] = []
for entry in ban_list:
ban = _parse_ban_entry(str(entry), jail_name)
if ban is not None:
all_bans.append(ban)
# Apply optional substring search filter (case-insensitive).
if search:
search_lower = search.lower()
all_bans = [b for b in all_bans if search_lower in b.ip.lower()]
total = len(all_bans)
# Slice the requested page.
start = (page - 1) * page_size
page_bans = all_bans[start : start + page_size]
# Geo-enrich only the page slice.
if http_session is not None and page_bans:
page_ips = [b.ip for b in page_bans]
try:
geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db)
except Exception: # noqa: BLE001
log.warning("jail_banned_ips_geo_failed", jail=jail_name)
geo_map = {}
enriched_page: list[ActiveBan] = []
for ban in page_bans:
geo = geo_map.get(ban.ip)
if geo is not None:
enriched_page.append(ban.model_copy(update={"country": geo.country_code}))
else:
enriched_page.append(ban)
page_bans = enriched_page
log.info(
"jail_banned_ips_fetched",
jail=jail_name,
total=total,
page=page,
page_size=page_size,
)
return JailBannedIpsResponse(
items=page_bans,
total=total,
page=page,
page_size=page_size,
)
async def _enrich_bans(
bans: list[ActiveBan],
geo_enricher: Any,

View File

@@ -4,14 +4,25 @@ Registers an APScheduler job that probes the fail2ban socket every 30 seconds
and stores the result on ``app.state.server_status``. The dashboard endpoint
reads from this cache, keeping HTTP responses fast and the daemon connection
decoupled from user-facing requests.
Crash detection (Task 3)
------------------------
When a jail activation is performed, the router stores a timestamp on
``app.state.last_activation`` (a ``dict`` with ``jail_name`` and ``at``
keys). If the health probe subsequently detects an online→offline transition
within 60 seconds of that activation, a
:class:`~app.models.config.PendingRecovery` record is written to
``app.state.pending_recovery`` so the UI can offer a one-click rollback.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any
import structlog
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.services import health_service
@@ -23,10 +34,19 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
#: How often the probe fires (seconds).
HEALTH_CHECK_INTERVAL: int = 30
#: Maximum seconds since an activation for a subsequent crash to be attributed
#: to that activation.
_ACTIVATION_CRASH_WINDOW: int = 60
async def _run_probe(app: Any) -> None:
"""Probe fail2ban and cache the result on *app.state*.
Detects online/offline state transitions. When fail2ban goes offline
within :data:`_ACTIVATION_CRASH_WINDOW` seconds of the last jail
activation, writes a :class:`~app.models.config.PendingRecovery` record to
``app.state.pending_recovery``.
This is the APScheduler job callback. It reads ``fail2ban_socket`` from
``app.state.settings``, runs the health probe, and writes the result to
``app.state.server_status``.
@@ -42,11 +62,54 @@ async def _run_probe(app: Any) -> None:
status: ServerStatus = await health_service.probe(socket_path)
app.state.server_status = status
now = datetime.datetime.now(tz=datetime.UTC)
# Log transitions between online and offline states.
if status.online and not prev_status.online:
log.info("fail2ban_came_online", version=status.version)
# Clear any pending recovery once fail2ban is back online.
existing: PendingRecovery | None = getattr(
app.state, "pending_recovery", None
)
if existing is not None and not existing.recovered:
app.state.pending_recovery = PendingRecovery(
jail_name=existing.jail_name,
activated_at=existing.activated_at,
detected_at=existing.detected_at,
recovered=True,
)
log.info(
"pending_recovery_resolved",
jail=existing.jail_name,
)
elif not status.online and prev_status.online:
log.warning("fail2ban_went_offline")
# Check whether this crash happened shortly after a jail activation.
last_activation: dict[str, Any] | None = getattr(
app.state, "last_activation", None
)
if last_activation is not None:
activated_at: datetime.datetime = last_activation["at"]
seconds_since = (now - activated_at).total_seconds()
if seconds_since <= _ACTIVATION_CRASH_WINDOW:
jail_name: str = last_activation["jail_name"]
# Only create a new record when there is not already an
# unresolved one for the same jail.
current: PendingRecovery | None = getattr(
app.state, "pending_recovery", None
)
if current is None or current.recovered:
app.state.pending_recovery = PendingRecovery(
jail_name=jail_name,
activated_at=activated_at,
detected_at=now,
)
log.warning(
"activation_crash_detected",
jail=jail_name,
seconds_since_activation=seconds_since,
)
log.debug(
"health_check_complete",
@@ -71,6 +134,10 @@ def register(app: FastAPI) -> None:
# first probe fires.
app.state.server_status = ServerStatus(online=False)
# Initialise activation tracking state.
app.state.last_activation = None
app.state.pending_recovery = None
app.state.scheduler.add_job(
_run_probe,
trigger="interval",

View File

@@ -0,0 +1,93 @@
"""Utilities for ensuring required fail2ban jail configuration files exist.
BanGUI requires two custom jails — ``manual-Jail`` and ``blocklist-import``
— to be present in the fail2ban ``jail.d`` directory. This module provides
:func:`ensure_jail_configs` which checks each of the four files
(``*.conf`` template + ``*.local`` override) and creates any that are missing
with the correct default content.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import structlog
if TYPE_CHECKING:
from pathlib import Path
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Default file contents
# ---------------------------------------------------------------------------
_MANUAL_JAIL_CONF = """\
[manual-Jail]
enabled = false
filter = manual-Jail
logpath = /remotelogs/bangui/auth.log
backend = polling
maxretry = 3
findtime = 120
bantime = 60
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
"""
_MANUAL_JAIL_LOCAL = """\
[manual-Jail]
enabled = true
"""
_BLOCKLIST_IMPORT_CONF = """\
[blocklist-import]
enabled = false
filter =
logpath = /dev/null
backend = auto
maxretry = 1
findtime = 1d
bantime = 1w
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
"""
_BLOCKLIST_IMPORT_LOCAL = """\
[blocklist-import]
enabled = true
"""
# ---------------------------------------------------------------------------
# File registry: (filename, default_content)
# ---------------------------------------------------------------------------
_JAIL_FILES: list[tuple[str, str]] = [
("manual-Jail.conf", _MANUAL_JAIL_CONF),
("manual-Jail.local", _MANUAL_JAIL_LOCAL),
("blocklist-import.conf", _BLOCKLIST_IMPORT_CONF),
("blocklist-import.local", _BLOCKLIST_IMPORT_LOCAL),
]
def ensure_jail_configs(jail_d_path: Path) -> None:
"""Ensure the required fail2ban jail configuration files exist.
Checks for ``manual-Jail.conf``, ``manual-Jail.local``,
``blocklist-import.conf``, and ``blocklist-import.local`` inside
*jail_d_path*. Any file that is missing is created with its default
content. Existing files are **never** overwritten.
Args:
jail_d_path: Path to the fail2ban ``jail.d`` directory. Will be
created (including all parents) if it does not already exist.
"""
jail_d_path.mkdir(parents=True, exist_ok=True)
for filename, default_content in _JAIL_FILES:
file_path = jail_d_path / filename
if file_path.exists():
log.debug("jail_config_already_exists", path=str(file_path))
else:
file_path.write_text(default_content, encoding="utf-8")
log.info("jail_config_created", path=str(file_path))

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bangui-backend"
version = "0.1.0"
version = "0.9.0"
description = "BanGUI backend — fail2ban web management interface"
requires-python = ">=3.12"
dependencies = [

View File

@@ -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,
)
# ---------------------------------------------------------------------------
@@ -368,6 +370,124 @@ class TestReloadFail2ban:
assert resp.status_code == 204
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
"""POST /api/config/reload returns 502 when fail2ban socket is unreachable."""
from app.utils.fail2ban_client import Fail2BanConnectionError
with patch(
"app.routers.config.jail_service.reload_all",
AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")),
):
resp = await config_client.post("/api/config/reload")
assert resp.status_code == 502
async def test_409_when_reload_operation_fails(self, config_client: AsyncClient) -> None:
"""POST /api/config/reload returns 409 when fail2ban reports a reload error."""
from app.services.jail_service import JailOperationError
with patch(
"app.routers.config.jail_service.reload_all",
AsyncMock(side_effect=JailOperationError("reload rejected")),
):
resp = await config_client.post("/api/config/reload")
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# POST /api/config/restart
# ---------------------------------------------------------------------------
class TestRestartFail2ban:
"""Tests for ``POST /api/config/restart``."""
async def test_204_on_success(self, config_client: AsyncClient) -> None:
"""POST /api/config/restart returns 204 when fail2ban restarts cleanly."""
with (
patch(
"app.routers.config.jail_service.restart",
AsyncMock(return_value=None),
),
patch(
"app.routers.config.config_file_service.start_daemon",
AsyncMock(return_value=True),
),
patch(
"app.routers.config.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=True),
),
):
resp = await config_client.post("/api/config/restart")
assert resp.status_code == 204
async def test_503_when_fail2ban_does_not_come_back(self, config_client: AsyncClient) -> None:
"""POST /api/config/restart returns 503 when fail2ban does not come back online."""
with (
patch(
"app.routers.config.jail_service.restart",
AsyncMock(return_value=None),
),
patch(
"app.routers.config.config_file_service.start_daemon",
AsyncMock(return_value=True),
),
patch(
"app.routers.config.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=False),
),
):
resp = await config_client.post("/api/config/restart")
assert resp.status_code == 503
async def test_409_when_stop_command_fails(self, config_client: AsyncClient) -> None:
"""POST /api/config/restart returns 409 when fail2ban rejects the stop command."""
from app.services.jail_service import JailOperationError
with patch(
"app.routers.config.jail_service.restart",
AsyncMock(side_effect=JailOperationError("stop failed")),
):
resp = await config_client.post("/api/config/restart")
assert resp.status_code == 409
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
"""POST /api/config/restart returns 502 when fail2ban socket is unreachable."""
from app.utils.fail2ban_client import Fail2BanConnectionError
with patch(
"app.routers.config.jail_service.restart",
AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")),
):
resp = await config_client.post("/api/config/restart")
assert resp.status_code == 502
async def test_start_daemon_called_after_stop(self, config_client: AsyncClient) -> None:
"""start_daemon is called after a successful stop."""
mock_start = AsyncMock(return_value=True)
with (
patch(
"app.routers.config.jail_service.restart",
AsyncMock(return_value=None),
),
patch(
"app.routers.config.config_file_service.start_daemon",
mock_start,
),
patch(
"app.routers.config.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=True),
),
):
await config_client.post("/api/config/restart")
mock_start.assert_awaited_once()
# ---------------------------------------------------------------------------
# POST /api/config/regex-test
@@ -740,6 +860,32 @@ class TestActivateJail:
).post("/api/config/jails/sshd/activate", json={})
assert resp.status_code == 401
async def test_200_with_active_false_on_missing_logpath(self, config_client: AsyncClient) -> None:
"""POST .../activate returns 200 with active=False when the service blocks due to missing logpath."""
from app.models.config import JailActivationResponse
blocked_response = JailActivationResponse(
name="airsonic-auth",
active=False,
fail2ban_running=True,
validation_warnings=["logpath: log file '/var/log/airsonic/airsonic.log' not found"],
message="Jail 'airsonic-auth' cannot be activated: log file '/var/log/airsonic/airsonic.log' not found",
)
with patch(
"app.routers.config.config_file_service.activate_jail",
AsyncMock(return_value=blocked_response),
):
resp = await config_client.post(
"/api/config/jails/airsonic-auth/activate", json={}
)
assert resp.status_code == 200
data = resp.json()
assert data["active"] is False
assert data["fail2ban_running"] is True
assert "cannot be activated" in data["message"]
assert len(data["validation_warnings"]) == 1
# ---------------------------------------------------------------------------
# POST /api/config/jails/{name}/deactivate
@@ -819,6 +965,30 @@ class TestDeactivateJail:
).post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 401
async def test_deactivate_triggers_health_probe(self, config_client: AsyncClient) -> None:
"""POST .../deactivate triggers an immediate health probe after success."""
from app.models.config import JailActivationResponse
mock_response = JailActivationResponse(
name="sshd",
active=False,
message="Jail 'sshd' deactivated successfully.",
)
with (
patch(
"app.routers.config.config_file_service.deactivate_jail",
AsyncMock(return_value=mock_response),
),
patch(
"app.routers.config._run_probe",
AsyncMock(),
) as mock_probe,
):
resp = await config_client.post("/api/config/jails/sshd/deactivate")
assert resp.status_code == 200
mock_probe.assert_awaited_once()
# ---------------------------------------------------------------------------
# GET /api/config/filters
@@ -1711,3 +1881,378 @@ 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
# ---------------------------------------------------------------------------
# Task 3 endpoints
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestValidateJailEndpoint:
"""Tests for ``POST /api/config/jails/{name}/validate``."""
async def test_200_valid_config(self, config_client: AsyncClient) -> None:
"""Returns 200 with valid=True when the jail config has no issues."""
from app.models.config import JailValidationResult
mock_result = JailValidationResult(
jail_name="sshd", valid=True, issues=[]
)
with patch(
"app.routers.config.config_file_service.validate_jail_config",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/validate")
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is True
assert data["jail_name"] == "sshd"
assert data["issues"] == []
async def test_200_invalid_config(self, config_client: AsyncClient) -> None:
"""Returns 200 with valid=False and issues when there are errors."""
from app.models.config import JailValidationIssue, JailValidationResult
issue = JailValidationIssue(field="filter", message="Filter file not found: filter.d/bad.conf (or .local)")
mock_result = JailValidationResult(
jail_name="sshd", valid=False, issues=[issue]
)
with patch(
"app.routers.config.config_file_service.validate_jail_config",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/validate")
assert resp.status_code == 200
data = resp.json()
assert data["valid"] is False
assert len(data["issues"]) == 1
assert data["issues"][0]["field"] == "filter"
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/bad-name/validate returns 400 on JailNameError."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.validate_jail_config",
AsyncMock(side_effect=JailNameError("bad name")),
):
resp = await config_client.post("/api/config/jails/bad-name/validate")
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/validate returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/validate")
assert resp.status_code == 401
@pytest.mark.asyncio
class TestPendingRecovery:
"""Tests for ``GET /api/config/pending-recovery``."""
async def test_returns_null_when_no_pending_recovery(
self, config_client: AsyncClient
) -> None:
"""Returns null body (204-like 200) when pending_recovery is not set."""
app = config_client._transport.app # type: ignore[attr-defined]
app.state.pending_recovery = None
resp = await config_client.get("/api/config/pending-recovery")
assert resp.status_code == 200
assert resp.json() is None
async def test_returns_record_when_set(self, config_client: AsyncClient) -> None:
"""Returns the PendingRecovery model when one is stored on app.state."""
import datetime
from app.models.config import PendingRecovery
now = datetime.datetime.now(tz=datetime.UTC)
record = PendingRecovery(
jail_name="sshd",
activated_at=now - datetime.timedelta(seconds=20),
detected_at=now,
)
app = config_client._transport.app # type: ignore[attr-defined]
app.state.pending_recovery = record
resp = await config_client.get("/api/config/pending-recovery")
assert resp.status_code == 200
data = resp.json()
assert data["jail_name"] == "sshd"
assert data["recovered"] is False
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""GET /api/config/pending-recovery returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/config/pending-recovery")
assert resp.status_code == 401
@pytest.mark.asyncio
class TestRollbackEndpoint:
"""Tests for ``POST /api/config/jails/{name}/rollback``."""
async def test_200_success_clears_pending_recovery(
self, config_client: AsyncClient
) -> None:
"""A successful rollback returns 200 and clears app.state.pending_recovery."""
import datetime
from app.models.config import PendingRecovery, RollbackResponse
# Set up a pending recovery record on the app.
app = config_client._transport.app # type: ignore[attr-defined]
now = datetime.datetime.now(tz=datetime.UTC)
app.state.pending_recovery = PendingRecovery(
jail_name="sshd",
activated_at=now - datetime.timedelta(seconds=10),
detected_at=now,
)
mock_result = RollbackResponse(
jail_name="sshd",
disabled=True,
fail2ban_running=True,
active_jails=0,
message="Jail 'sshd' disabled and fail2ban restarted.",
)
with patch(
"app.routers.config.config_file_service.rollback_jail",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/rollback")
assert resp.status_code == 200
data = resp.json()
assert data["disabled"] is True
assert data["fail2ban_running"] is True
# Successful rollback must clear the pending record.
assert app.state.pending_recovery is None
async def test_200_fail_preserves_pending_recovery(
self, config_client: AsyncClient
) -> None:
"""When fail2ban is still down after rollback, pending_recovery is retained."""
import datetime
from app.models.config import PendingRecovery, RollbackResponse
app = config_client._transport.app # type: ignore[attr-defined]
now = datetime.datetime.now(tz=datetime.UTC)
record = PendingRecovery(
jail_name="sshd",
activated_at=now - datetime.timedelta(seconds=10),
detected_at=now,
)
app.state.pending_recovery = record
mock_result = RollbackResponse(
jail_name="sshd",
disabled=True,
fail2ban_running=False,
active_jails=0,
message="fail2ban did not come back online.",
)
with patch(
"app.routers.config.config_file_service.rollback_jail",
AsyncMock(return_value=mock_result),
):
resp = await config_client.post("/api/config/jails/sshd/rollback")
assert resp.status_code == 200
data = resp.json()
assert data["fail2ban_running"] is False
# Pending record should NOT be cleared when rollback didn't fully succeed.
assert app.state.pending_recovery is not None
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/bad/rollback returns 400 on JailNameError."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.rollback_jail",
AsyncMock(side_effect=JailNameError("bad")),
):
resp = await config_client.post("/api/config/jails/bad/rollback")
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/rollback returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/rollback")
assert resp.status_code == 401

View File

@@ -327,41 +327,150 @@ class TestCreateFilterFile:
# ---------------------------------------------------------------------------
# GET /api/config/actions (smoke test — same logic as filters)
# Note: GET /api/config/actions is handled by config.router (registered first);
# file_config.router's "/actions" endpoint is shadowed by it.
# ---------------------------------------------------------------------------
class TestListActionFiles:
async def test_200_returns_files(self, file_config_client: AsyncClient) -> None:
action_entry = ConfFileEntry(name="iptables", filename="iptables.conf")
resp_data = ConfFilesResponse(files=[action_entry], total=1)
from app.models.config import ActionListResponse
mock_action = ActionConfig(
name="iptables",
filename="iptables.conf",
)
resp_data = ActionListResponse(actions=[mock_action], total=1)
with patch(
"app.routers.file_config.file_config_service.list_action_files",
"app.routers.config.config_file_service.list_actions",
AsyncMock(return_value=resp_data),
):
resp = await file_config_client.get("/api/config/actions")
assert resp.status_code == 200
assert resp.json()["files"][0]["filename"] == "iptables.conf"
assert resp.json()["actions"][0]["name"] == "iptables"
# ---------------------------------------------------------------------------
# POST /api/config/actions
# Note: POST /api/config/actions is also handled by config.router.
# ---------------------------------------------------------------------------
class TestCreateActionFile:
async def test_201_creates_file(self, file_config_client: AsyncClient) -> None:
created = ActionConfig(
name="myaction",
filename="myaction.local",
actionban="echo ban <ip>",
)
with patch(
"app.routers.file_config.file_config_service.create_action_file",
AsyncMock(return_value="myaction.conf"),
"app.routers.config.config_file_service.create_action",
AsyncMock(return_value=created),
):
resp = await file_config_client.post(
"/api/config/actions",
json={"name": "myaction", "content": "[Definition]\n"},
json={"name": "myaction", "actionban": "echo ban <ip>"},
)
assert resp.status_code == 201
assert resp.json()["filename"] == "myaction.conf"
assert resp.json()["name"] == "myaction"
# ---------------------------------------------------------------------------
# GET /api/config/actions/{name}/raw
# ---------------------------------------------------------------------------
class TestGetActionFileRaw:
"""Tests for ``GET /api/config/actions/{name}/raw``."""
async def test_200_returns_content(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.get_action_file",
AsyncMock(return_value=_conf_file_content("iptables")),
):
resp = await file_config_client.get("/api/config/actions/iptables/raw")
assert resp.status_code == 200
assert resp.json()["name"] == "iptables"
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.get_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.get("/api/config/actions/missing/raw")
assert resp.status_code == 404
async def test_503_on_config_dir_error(
self, file_config_client: AsyncClient
) -> None:
with patch(
"app.routers.file_config.file_config_service.get_action_file",
AsyncMock(side_effect=ConfigDirError("no dir")),
):
resp = await file_config_client.get("/api/config/actions/iptables/raw")
assert resp.status_code == 503
# ---------------------------------------------------------------------------
# PUT /api/config/actions/{name}/raw
# ---------------------------------------------------------------------------
class TestUpdateActionFileRaw:
"""Tests for ``PUT /api/config/actions/{name}/raw``."""
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.write_action_file",
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/raw",
json={"content": "[Definition]\nactionban = iptables -I INPUT -s <ip> -j DROP\n"},
)
assert resp.status_code == 204
async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.write_action_file",
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/actions/iptables/raw",
json={"content": "x"},
)
assert resp.status_code == 400
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.write_action_file",
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
):
resp = await file_config_client.put(
"/api/config/actions/missing/raw",
json={"content": "x"},
)
assert resp.status_code == 404
async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None:
with patch(
"app.routers.file_config.file_config_service.write_action_file",
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.put(
"/api/config/actions/escape/raw",
json={"content": "x"},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------

View File

@@ -13,10 +13,11 @@ async def test_health_check_returns_200(client: AsyncClient) -> None:
@pytest.mark.asyncio
async def test_health_check_returns_ok_status(client: AsyncClient) -> None:
"""``GET /api/health`` must return ``{"status": "ok"}``."""
"""``GET /api/health`` must contain ``status: ok`` and a ``fail2ban`` field."""
response = await client.get("/api/health")
data: dict[str, str] = response.json()
assert data == {"status": "ok"}
assert data["status"] == "ok"
assert data["fail2ban"] in ("online", "offline")
@pytest.mark.asyncio

View File

@@ -788,3 +788,146 @@ class TestFail2BanConnectionErrors:
resp = await jails_client.post("/api/jails/sshd/reload")
assert resp.status_code == 502
# ---------------------------------------------------------------------------
# GET /api/jails/{name}/banned
# ---------------------------------------------------------------------------
class TestGetJailBannedIps:
"""Tests for ``GET /api/jails/{name}/banned``."""
def _mock_response(
self,
*,
items: list[dict] | None = None,
total: int = 2,
page: int = 1,
page_size: int = 25,
) -> "JailBannedIpsResponse": # type: ignore[name-defined]
from app.models.ban import ActiveBan, JailBannedIpsResponse
ban_items = (
[
ActiveBan(
ip=item.get("ip", "1.2.3.4"),
jail="sshd",
banned_at=item.get("banned_at", "2025-01-01T10:00:00+00:00"),
expires_at=item.get("expires_at", "2025-01-01T10:10:00+00:00"),
ban_count=1,
country=item.get("country", None),
)
for item in (items or [{"ip": "1.2.3.4"}, {"ip": "5.6.7.8"}])
]
)
return JailBannedIpsResponse(
items=ban_items, total=total, page=page, page_size=page_size
)
async def test_200_returns_paginated_bans(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned returns 200 with a JailBannedIpsResponse."""
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(return_value=self._mock_response()),
):
resp = await jails_client.get("/api/jails/sshd/banned")
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert data["total"] == 2
async def test_200_with_search_parameter(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?search=1.2.3 passes search to service."""
mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1))
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3")
assert resp.status_code == 200
_args, call_kwargs = mock_fn.call_args
assert call_kwargs.get("search") == "1.2.3"
async def test_200_with_page_and_page_size(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page=2&page_size=10 passes params to service."""
mock_fn = AsyncMock(
return_value=self._mock_response(page=2, page_size=10, total=0, items=[])
)
with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn):
resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10")
assert resp.status_code == 200
_args, call_kwargs = mock_fn.call_args
assert call_kwargs.get("page") == 2
assert call_kwargs.get("page_size") == 10
async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page=0 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page=0")
assert resp.status_code == 400
async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page_size=200 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page_size=200")
assert resp.status_code == 400
async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned?page_size=0 returns 400."""
resp = await jails_client.get("/api/jails/sshd/banned?page_size=0")
assert resp.status_code == 400
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/ghost/banned returns 404 when jail is unknown."""
from app.services.jail_service import JailNotFoundError
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(side_effect=JailNotFoundError("ghost")),
):
resp = await jails_client.get("/api/jails/ghost/banned")
assert resp.status_code == 404
async def test_502_when_fail2ban_unreachable(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned returns 502 when fail2ban is unreachable."""
from app.utils.fail2ban_client import Fail2BanConnectionError
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(
side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")
),
):
resp = await jails_client.get("/api/jails/sshd/banned")
assert resp.status_code == 502
async def test_response_items_have_expected_fields(
self, jails_client: AsyncClient
) -> None:
"""Response items contain ip, jail, banned_at, expires_at, ban_count, country."""
with patch(
"app.routers.jails.jail_service.get_jail_banned_ips",
AsyncMock(return_value=self._mock_response()),
):
resp = await jails_client.get("/api/jails/sshd/banned")
item = resp.json()["items"][0]
assert "ip" in item
assert "jail" in item
assert "banned_at" in item
assert "expires_at" in item
assert "ban_count" in item
assert "country" in item
async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None:
"""GET /api/jails/sshd/banned returns 401 without a session cookie."""
resp = await AsyncClient(
transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).get("/api/jails/sshd/banned")
assert resp.status_code == 401

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import aiosqlite
import pytest
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.main import _lifespan, create_app
# ---------------------------------------------------------------------------
# Shared setup payload
@@ -286,3 +286,151 @@ class TestSetupCompleteCaching:
# Cache was warm — is_setup_complete must not have been called.
assert call_count == 0
# ---------------------------------------------------------------------------
# Task 0.1 — Lifespan creates the database parent directory (Task 0.1)
# ---------------------------------------------------------------------------
class TestLifespanDatabaseDirectoryCreation:
"""App lifespan creates the database parent directory when it does not exist."""
async def test_creates_nested_database_directory(self, tmp_path: Path) -> None:
"""Lifespan creates intermediate directories for the database path.
Verifies that a deeply-nested database path is handled correctly —
the parent directories are created before ``aiosqlite.connect`` is
called so the app does not crash on a fresh volume.
"""
nested_db = tmp_path / "deep" / "nested" / "bangui.db"
assert not nested_db.parent.exists()
settings = Settings(
database_path=str(nested_db),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-mkdir-secret",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
mock_scheduler = MagicMock()
mock_scheduler.start = MagicMock()
mock_scheduler.shutdown = MagicMock()
with (
patch("app.services.geo_service.init_geoip"),
patch(
"app.services.geo_service.load_cache_from_db",
new=AsyncMock(return_value=None),
),
patch("app.tasks.health_check.register"),
patch("app.tasks.blocklist_import.register"),
patch("app.tasks.geo_cache_flush.register"),
patch("app.tasks.geo_re_resolve.register"),
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
patch("app.main.ensure_jail_configs"),
):
async with _lifespan(app):
assert nested_db.parent.exists(), (
"Expected lifespan to create database parent directory"
)
async def test_existing_database_directory_is_not_an_error(
self, tmp_path: Path
) -> None:
"""Lifespan does not raise when the database directory already exists.
``mkdir(exist_ok=True)`` must be used so that re-starts on an existing
volume do not fail.
"""
db_path = tmp_path / "bangui.db"
# tmp_path already exists — this simulates a pre-existing volume.
settings = Settings(
database_path=str(db_path),
fail2ban_socket="/tmp/fake.sock",
session_secret="test-lifespan-exist-ok-secret",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
mock_scheduler = MagicMock()
mock_scheduler.start = MagicMock()
mock_scheduler.shutdown = MagicMock()
with (
patch("app.services.geo_service.init_geoip"),
patch(
"app.services.geo_service.load_cache_from_db",
new=AsyncMock(return_value=None),
),
patch("app.tasks.health_check.register"),
patch("app.tasks.blocklist_import.register"),
patch("app.tasks.geo_cache_flush.register"),
patch("app.tasks.geo_re_resolve.register"),
patch("app.main.AsyncIOScheduler", return_value=mock_scheduler),
patch("app.main.ensure_jail_configs"),
):
# Should not raise FileExistsError or similar.
async with _lifespan(app):
assert tmp_path.exists()
# ---------------------------------------------------------------------------
# Task 0.2 — Middleware redirects when app.state.db is None
# ---------------------------------------------------------------------------
class TestSetupRedirectMiddlewareDbNone:
"""SetupRedirectMiddleware redirects when the database is not yet available."""
async def test_redirects_to_setup_when_db_not_set(self, tmp_path: Path) -> None:
"""A ``None`` db on app.state causes a 307 redirect to ``/api/setup``.
Simulates the race window where a request arrives before the lifespan
has finished initialising the database connection.
"""
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-db-none-secret",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
# Deliberately do NOT set app.state.db to simulate startup not complete.
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test"
) as ac:
response = await ac.get("/api/auth/login", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/api/setup"
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
"""Health endpoint is always reachable even when db is not initialised."""
settings = Settings(
database_path=str(tmp_path / "bangui.db"),
fail2ban_socket="/tmp/fake_fail2ban.sock",
session_secret="test-db-none-health-secret",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test"
) as ac:
response = await ac.get("/api/health")
assert response.status_code == 200

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from app.models.ban import ActiveBanListResponse
from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse
from app.models.jail import JailDetailResponse, JailListResponse
from app.services import jail_service
from app.services.jail_service import JailNotFoundError, JailOperationError
@@ -184,10 +184,90 @@ class TestListJails:
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
await jail_service.list_jails(_SOCKET)
async def test_backend_idle_commands_unsupported(self) -> None:
"""list_jails handles unsupported backend and idle commands gracefully.
# ---------------------------------------------------------------------------
# get_jail
# ---------------------------------------------------------------------------
When the fail2ban daemon does not support get ... backend/idle commands,
list_jails should not send them, avoiding "Invalid command" errors in the
fail2ban log.
"""
# Reset the capability cache to test detection.
jail_service._backend_cmd_supported = None
responses = {
"status": _make_global_status("sshd"),
"status|sshd|short": _make_short_status(),
# Capability probe: get backend fails (command not supported).
"get|sshd|backend": (1, Exception("Invalid command (no get action or not yet implemented)")),
# Subsequent gets should still work.
"get|sshd|bantime": (0, 600),
"get|sshd|findtime": (0, 600),
"get|sshd|maxretry": (0, 5),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
# Verify the result uses the default values for backend and idle.
jail = result.jails[0]
assert jail.backend == "polling" # default
assert jail.idle is False # default
# Capability should now be cached as False.
assert jail_service._backend_cmd_supported is False
async def test_backend_idle_commands_supported(self) -> None:
"""list_jails detects and sends backend/idle commands when supported."""
# Reset the capability cache to test detection.
jail_service._backend_cmd_supported = None
responses = {
"status": _make_global_status("sshd"),
"status|sshd|short": _make_short_status(),
# Capability probe: get backend succeeds.
"get|sshd|backend": (0, "systemd"),
# All other commands.
"get|sshd|bantime": (0, 600),
"get|sshd|findtime": (0, 600),
"get|sshd|maxretry": (0, 5),
"get|sshd|idle": (0, True),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
# Verify real values are returned.
jail = result.jails[0]
assert jail.backend == "systemd" # real value
assert jail.idle is True # real value
# Capability should now be cached as True.
assert jail_service._backend_cmd_supported is True
async def test_backend_idle_commands_cached_after_first_probe(self) -> None:
"""list_jails caches capability result and reuses it across polling cycles."""
# Reset the capability cache.
jail_service._backend_cmd_supported = None
responses = {
"status": _make_global_status("sshd, nginx"),
# Probes happen once per polling cycle (for the first jail listed).
"status|sshd|short": _make_short_status(),
"status|nginx|short": _make_short_status(),
# Capability probe: backend is unsupported.
"get|sshd|backend": (1, Exception("Invalid command")),
# Subsequent jails do not trigger another probe; they use cached result.
# (The mock doesn't have get|nginx|backend because it shouldn't be called.)
"get|sshd|bantime": (0, 600),
"get|sshd|findtime": (0, 600),
"get|sshd|maxretry": (0, 5),
"get|nginx|bantime": (0, 600),
"get|nginx|findtime": (0, 600),
"get|nginx|maxretry": (0, 5),
}
with _patch_client(responses):
result = await jail_service.list_jails(_SOCKET)
# Both jails should return default values (cached result is False).
for jail in result.jails:
assert jail.backend == "polling"
assert jail.idle is False
class TestGetJail:
@@ -339,6 +419,55 @@ class TestJailControls:
_SOCKET, include_jails=["new"], exclude_jails=["old"]
)
async def test_reload_all_unknown_jail_raises_jail_not_found(self) -> None:
"""reload_all detects UnknownJailException and raises JailNotFoundError.
When fail2ban cannot load a jail due to invalid configuration (e.g.,
missing logpath), it raises UnknownJailException during reload. This
test verifies that reload_all detects this and re-raises as
JailNotFoundError instead of the generic JailOperationError.
"""
with _patch_client(
{
"status": _make_global_status("sshd"),
"reload|--all|[]|[['start', 'airsonic-auth'], ['start', 'sshd']]": (
1,
Exception("UnknownJailException('airsonic-auth')"),
),
}
), pytest.raises(jail_service.JailNotFoundError) as exc_info:
await jail_service.reload_all(
_SOCKET, include_jails=["airsonic-auth"]
)
assert exc_info.value.name == "airsonic-auth"
async def test_restart_sends_stop_command(self) -> None:
"""restart() sends the ['stop'] command to the fail2ban socket."""
with _patch_client({"stop": (0, None)}):
await jail_service.restart(_SOCKET) # should not raise
async def test_restart_operation_error_raises(self) -> None:
"""restart() raises JailOperationError when fail2ban rejects the stop."""
with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises(
JailOperationError
):
await jail_service.restart(_SOCKET)
async def test_restart_connection_error_propagates(self) -> None:
"""restart() propagates Fail2BanConnectionError when socket is unreachable."""
class _FailClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
)
with (
patch("app.services.jail_service.Fail2BanClient", _FailClient),
pytest.raises(Fail2BanConnectionError),
):
await jail_service.restart(_SOCKET)
async def test_start_not_found_raises(self) -> None:
"""start_jail raises JailNotFoundError for unknown jail."""
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
@@ -700,3 +829,201 @@ class TestUnbanAllIps:
pytest.raises(Fail2BanConnectionError),
):
await jail_service.unban_all_ips(_SOCKET)
# ---------------------------------------------------------------------------
# get_jail_banned_ips
# ---------------------------------------------------------------------------
#: A raw ban entry string in the format produced by fail2ban --with-time.
_BAN_ENTRY_1 = "1.2.3.4\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"
_BAN_ENTRY_2 = "5.6.7.8\t2025-01-01 11:00:00 + 600 = 2025-01-01 11:10:00"
_BAN_ENTRY_3 = "9.10.11.12\t2025-01-01 12:00:00 + 600 = 2025-01-01 12:10:00"
def _banned_ips_responses(jail: str = "sshd", entries: list[str] | None = None) -> dict[str, Any]:
"""Build mock responses for get_jail_banned_ips tests."""
if entries is None:
entries = [_BAN_ENTRY_1, _BAN_ENTRY_2]
return {
f"status|{jail}|short": _make_short_status(),
f"get|{jail}|banip|--with-time": (0, entries),
}
class TestGetJailBannedIps:
"""Unit tests for :func:`~app.services.jail_service.get_jail_banned_ips`."""
async def test_returns_jail_banned_ips_response(self) -> None:
"""get_jail_banned_ips returns a JailBannedIpsResponse."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert isinstance(result, JailBannedIpsResponse)
async def test_total_reflects_all_entries(self) -> None:
"""total equals the number of parsed ban entries."""
with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert result.total == 3
async def test_page_1_returns_first_n_items(self) -> None:
"""page=1 with page_size=2 returns the first two entries."""
with _patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=1, page_size=2
)
assert len(result.items) == 2
assert result.items[0].ip == "1.2.3.4"
assert result.items[1].ip == "5.6.7.8"
assert result.total == 3
async def test_page_2_returns_remaining_items(self) -> None:
"""page=2 with page_size=2 returns the third entry."""
with _patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=2, page_size=2
)
assert len(result.items) == 1
assert result.items[0].ip == "9.10.11.12"
async def test_page_beyond_last_returns_empty_items(self) -> None:
"""Requesting a page past the end returns an empty items list."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=99, page_size=25
)
assert result.items == []
assert result.total == 2
async def test_search_filter_narrows_results(self) -> None:
"""search parameter filters entries by IP substring."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="1.2.3"
)
assert result.total == 1
assert result.items[0].ip == "1.2.3.4"
async def test_search_filter_case_insensitive(self) -> None:
"""search filter is case-insensitive."""
entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"]
with _patch_client(_banned_ips_responses(entries=entries)):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="192.168"
)
assert result.total == 1
async def test_search_no_match_returns_empty(self) -> None:
"""search that matches nothing returns empty items and total=0."""
with _patch_client(_banned_ips_responses()):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", search="999.999"
)
assert result.total == 0
assert result.items == []
async def test_empty_ban_list_returns_total_zero(self) -> None:
"""get_jail_banned_ips handles an empty ban list gracefully."""
responses = {
"status|sshd|short": _make_short_status(),
"get|sshd|banip|--with-time": (0, []),
}
with _patch_client(responses):
result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd")
assert result.total == 0
assert result.items == []
async def test_page_size_clamped_to_max(self) -> None:
"""page_size values above 100 are silently clamped to 100."""
entries = [f"10.0.0.{i}\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" for i in range(1, 101)]
responses = {
"status|sshd|short": _make_short_status(),
"get|sshd|banip|--with-time": (0, entries),
}
with _patch_client(responses):
result = await jail_service.get_jail_banned_ips(
_SOCKET, "sshd", page=1, page_size=200
)
assert len(result.items) <= 100
async def test_geo_enrichment_called_for_page_slice_only(self) -> None:
"""Geo enrichment is requested only for IPs in the current page."""
from unittest.mock import MagicMock
from app.services import geo_service
http_session = MagicMock()
geo_enrichment_ips: list[list[str]] = []
async def _mock_lookup_batch(
ips: list[str], _session: Any, **_kw: Any
) -> dict[str, Any]:
geo_enrichment_ips.append(list(ips))
return {}
with (
_patch_client(
_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])
),
patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch),
):
result = await jail_service.get_jail_banned_ips(
_SOCKET,
"sshd",
page=1,
page_size=2,
http_session=http_session,
)
# Only the 2-IP page slice should be passed to geo enrichment.
assert len(geo_enrichment_ips) == 1
assert len(geo_enrichment_ips[0]) == 2
assert result.total == 3
async def test_unknown_jail_raises_jail_not_found_error(self) -> None:
"""get_jail_banned_ips raises JailNotFoundError for unknown jail."""
responses = {
"status|ghost|short": (0, pytest.raises), # will be overridden
}
# Simulate fail2ban returning an "unknown jail" error.
class _FakeClient:
def __init__(self, **_kw: Any) -> None:
pass
async def send(self, command: list[Any]) -> Any:
raise ValueError("Unknown jail: ghost")
with (
patch("app.services.jail_service.Fail2BanClient", _FakeClient),
pytest.raises(JailNotFoundError),
):
await jail_service.get_jail_banned_ips(_SOCKET, "ghost")
async def test_connection_error_propagates(self) -> None:
"""get_jail_banned_ips propagates Fail2BanConnectionError."""
class _FailClient:
def __init__(self, **_kw: Any) -> None:
self.send = AsyncMock(
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
)
with (
patch("app.services.jail_service.Fail2BanClient", _FailClient),
pytest.raises(Fail2BanConnectionError),
):
await jail_service.get_jail_banned_ips(_SOCKET, "sshd")

View File

@@ -8,10 +8,12 @@ the scheduler and primes the initial status.
from __future__ import annotations
import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.tasks.health_check import HEALTH_CHECK_INTERVAL, _run_probe, register
@@ -33,6 +35,8 @@ def _make_app(prev_online: bool = False) -> MagicMock:
app.state.settings.fail2ban_socket = "/var/run/fail2ban/fail2ban.sock"
app.state.server_status = ServerStatus(online=prev_online)
app.state.scheduler = MagicMock()
app.state.last_activation = None
app.state.pending_recovery = None
return app
@@ -236,3 +240,111 @@ class TestRegister:
_, kwargs = app.state.scheduler.add_job.call_args
assert kwargs["kwargs"] == {"app": app}
def test_register_initialises_last_activation_none(self) -> None:
"""``register`` must set ``app.state.last_activation = None``."""
app = _make_app()
register(app)
assert app.state.last_activation is None
def test_register_initialises_pending_recovery_none(self) -> None:
"""``register`` must set ``app.state.pending_recovery = None``."""
app = _make_app()
register(app)
assert app.state.pending_recovery is None
# ---------------------------------------------------------------------------
# Crash detection (Task 3)
# ---------------------------------------------------------------------------
class TestCrashDetection:
"""Tests for activation-crash detection in _run_probe."""
@pytest.mark.asyncio
async def test_crash_within_window_creates_pending_recovery(self) -> None:
"""An online→offline transition within 60 s of activation must set pending_recovery."""
app = _make_app(prev_online=True)
now = datetime.datetime.now(tz=datetime.timezone.utc)
app.state.last_activation = {
"jail_name": "sshd",
"at": now - datetime.timedelta(seconds=10),
}
app.state.pending_recovery = None
offline_status = ServerStatus(online=False)
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=offline_status,
):
await _run_probe(app)
assert app.state.pending_recovery is not None
assert isinstance(app.state.pending_recovery, PendingRecovery)
assert app.state.pending_recovery.jail_name == "sshd"
assert app.state.pending_recovery.recovered is False
@pytest.mark.asyncio
async def test_crash_outside_window_does_not_create_pending_recovery(self) -> None:
"""A crash more than 60 s after activation must NOT set pending_recovery."""
app = _make_app(prev_online=True)
app.state.last_activation = {
"jail_name": "sshd",
"at": datetime.datetime.now(tz=datetime.timezone.utc)
- datetime.timedelta(seconds=120),
}
app.state.pending_recovery = None
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=ServerStatus(online=False),
):
await _run_probe(app)
assert app.state.pending_recovery is None
@pytest.mark.asyncio
async def test_came_online_marks_pending_recovery_resolved(self) -> None:
"""An offline→online transition must mark an existing pending_recovery as recovered."""
app = _make_app(prev_online=False)
activated_at = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(seconds=30)
detected_at = datetime.datetime.now(tz=datetime.timezone.utc)
app.state.pending_recovery = PendingRecovery(
jail_name="sshd",
activated_at=activated_at,
detected_at=detected_at,
recovered=False,
)
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=ServerStatus(online=True),
):
await _run_probe(app)
assert app.state.pending_recovery.recovered is True
@pytest.mark.asyncio
async def test_crash_without_recent_activation_does_nothing(self) -> None:
"""A crash when last_activation is None must not create a pending_recovery."""
app = _make_app(prev_online=True)
app.state.last_activation = None
app.state.pending_recovery = None
with patch(
"app.tasks.health_check.health_service.probe",
new_callable=AsyncMock,
return_value=ServerStatus(online=False),
):
await _run_probe(app)
assert app.state.pending_recovery is None

View File

@@ -0,0 +1,134 @@
"""Tests for app.utils.jail_config.ensure_jail_configs."""
from __future__ import annotations
from pathlib import Path
from app.utils.jail_config import (
_BLOCKLIST_IMPORT_CONF,
_BLOCKLIST_IMPORT_LOCAL,
_MANUAL_JAIL_CONF,
_MANUAL_JAIL_LOCAL,
ensure_jail_configs,
)
# ---------------------------------------------------------------------------
# Expected filenames
# ---------------------------------------------------------------------------
_MANUAL_CONF = "manual-Jail.conf"
_MANUAL_LOCAL = "manual-Jail.local"
_BLOCKLIST_CONF = "blocklist-import.conf"
_BLOCKLIST_LOCAL = "blocklist-import.local"
_ALL_FILES = [_MANUAL_CONF, _MANUAL_LOCAL, _BLOCKLIST_CONF, _BLOCKLIST_LOCAL]
_CONTENT_MAP: dict[str, str] = {
_MANUAL_CONF: _MANUAL_JAIL_CONF,
_MANUAL_LOCAL: _MANUAL_JAIL_LOCAL,
_BLOCKLIST_CONF: _BLOCKLIST_IMPORT_CONF,
_BLOCKLIST_LOCAL: _BLOCKLIST_IMPORT_LOCAL,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read(jail_d: Path, filename: str) -> str:
return (jail_d / filename).read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Tests: ensure_jail_configs
# ---------------------------------------------------------------------------
class TestEnsureJailConfigs:
def test_all_missing_creates_all_four(self, tmp_path: Path) -> None:
"""All four files are created when the directory is empty."""
jail_d = tmp_path / "jail.d"
ensure_jail_configs(jail_d)
for name in _ALL_FILES:
assert (jail_d / name).exists(), f"{name} should have been created"
assert _read(jail_d, name) == _CONTENT_MAP[name]
def test_all_missing_creates_correct_content(self, tmp_path: Path) -> None:
"""Each created file has exactly the expected default content."""
jail_d = tmp_path / "jail.d"
ensure_jail_configs(jail_d)
# .conf files must set enabled = false
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
content = _read(jail_d, conf_file)
assert "enabled = false" in content
# .local files must set enabled = true and nothing else
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
content = _read(jail_d, local_file)
assert "enabled = true" in content
def test_all_present_overwrites_nothing(self, tmp_path: Path) -> None:
"""Existing files are never overwritten."""
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
sentinel = "# EXISTING CONTENT — must not be replaced\n"
for name in _ALL_FILES:
(jail_d / name).write_text(sentinel, encoding="utf-8")
ensure_jail_configs(jail_d)
for name in _ALL_FILES:
assert _read(jail_d, name) == sentinel, (
f"{name} should not have been overwritten"
)
def test_only_local_files_missing_creates_only_locals(
self, tmp_path: Path
) -> None:
"""Only the .local files are created when the .conf files already exist."""
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
sentinel = "# pre-existing conf\n"
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
(jail_d / conf_file).write_text(sentinel, encoding="utf-8")
ensure_jail_configs(jail_d)
# .conf files must remain unchanged
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
assert _read(jail_d, conf_file) == sentinel
# .local files must have been created with correct content
for local_file, expected in (
(_MANUAL_LOCAL, _MANUAL_JAIL_LOCAL),
(_BLOCKLIST_LOCAL, _BLOCKLIST_IMPORT_LOCAL),
):
assert (jail_d / local_file).exists(), f"{local_file} should have been created"
assert _read(jail_d, local_file) == expected
def test_creates_jail_d_directory_if_missing(self, tmp_path: Path) -> None:
"""The jail.d directory is created automatically when absent."""
jail_d = tmp_path / "nested" / "jail.d"
assert not jail_d.exists()
ensure_jail_configs(jail_d)
assert jail_d.is_dir()
def test_idempotent_on_repeated_calls(self, tmp_path: Path) -> None:
"""Calling ensure_jail_configs twice does not alter any file."""
jail_d = tmp_path / "jail.d"
ensure_jail_configs(jail_d)
# Record content after first call
first_pass = {name: _read(jail_d, name) for name in _ALL_FILES}
ensure_jail_configs(jail_d)
for name in _ALL_FILES:
assert _read(jail_d, name) == first_pass[name], (
f"{name} changed on second call"
)

View File

@@ -1,7 +1,7 @@
{
"name": "bangui-frontend",
"private": true,
"version": "0.1.0",
"version": "0.9.4",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {

View File

@@ -7,22 +7,16 @@
import { api } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
import { sha256Hex } from "../utils/crypto";
import type { LoginResponse, LogoutResponse } from "../types/auth";
/**
* Authenticate with the master password.
*
* The password is SHA-256 hashed client-side before transmission so that
* the plaintext never leaves the browser. The backend bcrypt-verifies the
* received hash against the stored bcrypt(sha256) digest.
*
* @param password - The master password entered by the user.
* @returns The login response containing the session token.
*/
export async function login(password: string): Promise<LoginResponse> {
const body: LoginRequest = { password: await sha256Hex(password) };
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
return api.post<LoginResponse>(ENDPOINTS.authLogin, { password });
}
/**

View File

@@ -18,6 +18,7 @@ import type {
ConfFileCreateRequest,
ConfFilesResponse,
ConfFileUpdateRequest,
Fail2BanLogResponse,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
@@ -33,6 +34,7 @@ import type {
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
JailValidationResult,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
@@ -43,6 +45,7 @@ import type {
ServerSettingsUpdate,
JailFileConfig,
JailFileConfigUpdate,
ServiceStatusResponse,
} from "../types/config";
// ---------------------------------------------------------------------------
@@ -83,7 +86,7 @@ export async function updateGlobalConfig(
}
// ---------------------------------------------------------------------------
// Reload
// Reload and Restart
// ---------------------------------------------------------------------------
export async function reloadConfig(
@@ -91,6 +94,11 @@ export async function reloadConfig(
await post<undefined>(ENDPOINTS.configReload, undefined);
}
export async function restartFail2Ban(
): Promise<void> {
await post<undefined>(ENDPOINTS.configRestart, undefined);
}
// ---------------------------------------------------------------------------
// Regex tester
// ---------------------------------------------------------------------------
@@ -255,14 +263,14 @@ export async function fetchActionFiles(): Promise<ConfFilesResponse> {
}
export async function fetchActionFile(name: string): Promise<ConfFileContent> {
return get<ConfFileContent>(ENDPOINTS.configAction(name));
return get<ConfFileContent>(ENDPOINTS.configActionRaw(name));
}
export async function updateActionFile(
name: string,
req: ConfFileUpdateRequest
): Promise<void> {
await put<undefined>(ENDPOINTS.configAction(name), req);
await put<undefined>(ENDPOINTS.configActionRaw(name), req);
}
export async function createActionFile(
@@ -541,3 +549,57 @@ export async function deactivateJail(
undefined
);
}
/**
* Delete the ``jail.d/{name}.local`` override file for an inactive jail.
*
* Only valid when the jail is **not** currently active. Use this to clean up
* leftover ``.local`` files after a jail has been fully deactivated.
*
* @param name - The jail name.
*/
export async function deleteJailLocalOverride(name: string): Promise<void> {
await del<undefined>(ENDPOINTS.configJailLocalOverride(name));
}
// ---------------------------------------------------------------------------
// 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);
}
// ---------------------------------------------------------------------------
// Jail config recovery (Task 3)
// ---------------------------------------------------------------------------
/**
* Run pre-activation validation on a jail's config.
*
* Checks that referenced filter/action files exist, that all regex patterns
* compile, and that log paths are accessible on the server.
*/
export async function validateJailConfig(
name: string,
): Promise<JailValidationResult> {
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
}

View File

@@ -38,12 +38,14 @@ export const ENDPOINTS = {
// -------------------------------------------------------------------------
jails: "/jails",
jail: (name: string): string => `/jails/${encodeURIComponent(name)}`,
jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`,
jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`,
jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`,
jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`,
jailReload: (name: string): string => `/jails/${encodeURIComponent(name)}/reload`,
jailsReloadAll: "/jails/reload-all",
jailIgnoreIp: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreip`,
jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`,
// -------------------------------------------------------------------------
// Bans
@@ -69,8 +71,13 @@ export const ENDPOINTS = {
`/config/jails/${encodeURIComponent(name)}/activate`,
configJailDeactivate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/deactivate`,
configJailLocalOverride: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/local`,
configJailValidate: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/validate`,
configGlobal: "/config/global",
configReload: "/config/reload",
configRestart: "/config/restart",
configRegexTest: "/config/regex-test",
configPreviewLog: "/config/preview-log",
configMapColorThresholds: "/config/map-color-thresholds",
@@ -97,9 +104,14 @@ export const ENDPOINTS = {
`/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`,
configActions: "/config/actions",
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`,
configActionParsed: (name: string): string =>
`/config/actions/${encodeURIComponent(name)}/parsed`,
// fail2ban log viewer (Task 2)
configFail2BanLog: "/config/fail2ban-log",
configServiceStatus: "/config/service-status",
// -------------------------------------------------------------------------
// Server settings
// -------------------------------------------------------------------------

View File

@@ -10,6 +10,7 @@ import { ENDPOINTS } from "./endpoints";
import type {
ActiveBanListResponse,
IpLookupResponse,
JailBannedIpsResponse,
JailCommandResponse,
JailDetailResponse,
JailListResponse,
@@ -148,6 +149,24 @@ export async function delIgnoreIp(
return del<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
}
/**
* Enable or disable the `ignoreself` flag for a jail.
*
* When enabled, fail2ban automatically adds the server's own IP addresses to
* the ignore list so the host can never ban itself.
*
* @param name - Jail name.
* @param on - `true` to enable, `false` to disable.
* @returns A {@link JailCommandResponse} confirming the change.
* @throws {ApiError} On non-2xx responses.
*/
export async function toggleIgnoreSelf(
name: string,
on: boolean,
): Promise<JailCommandResponse> {
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreSelf(name), on);
}
// ---------------------------------------------------------------------------
// Ban / unban
// ---------------------------------------------------------------------------
@@ -224,3 +243,37 @@ export async function unbanAllBans(): Promise<UnbanAllResponse> {
export async function lookupIp(ip: string): Promise<IpLookupResponse> {
return get<IpLookupResponse>(ENDPOINTS.geoLookup(ip));
}
// ---------------------------------------------------------------------------
// Jail-specific paginated bans
// ---------------------------------------------------------------------------
/**
* Fetch the currently banned IPs for a specific jail, paginated.
*
* Only the requested page is geo-enriched on the backend, so this call
* remains fast even when a jail has thousands of banned IPs.
*
* @param jailName - Jail name (e.g. `"sshd"`).
* @param page - 1-based page number (default 1).
* @param pageSize - Items per page; max 100 (default 25).
* @param search - Optional case-insensitive IP substring filter.
* @returns A {@link JailBannedIpsResponse} with paginated ban entries.
* @throws {ApiError} On non-2xx responses (404 if jail unknown, 502 if fail2ban down).
*/
export async function fetchJailBannedIps(
jailName: string,
page = 1,
pageSize = 25,
search?: string,
): Promise<JailBannedIpsResponse> {
const params: Record<string, string> = {
page: String(page),
page_size: String(pageSize),
};
if (search !== undefined && search !== "") {
params.search = search;
}
const query = new URLSearchParams(params).toString();
return get<JailBannedIpsResponse>(`${ENDPOINTS.jailBanned(jailName)}?${query}`);
}

View File

@@ -109,7 +109,7 @@ export function ServerStatusBar(): React.JSX.Element {
{/* Version */}
{/* ---------------------------------------------------------------- */}
{status?.version != null && (
<Tooltip content="fail2ban version" relationship="description">
<Tooltip content="fail2ban daemon version" relationship="description">
<Text size={200} className={styles.statValue}>
v{status.version}
</Text>
@@ -139,9 +139,9 @@ export function ServerStatusBar(): React.JSX.Element {
</div>
</Tooltip>
<Tooltip content="Currently failing IPs" relationship="description">
<Tooltip content="Total failed authentication attempts currently tracked by fail2ban across all active jails" relationship="description">
<div className={styles.statGroup}>
<Text size={200}>Failures:</Text>
<Text size={200}>Failed Attempts:</Text>
<Text size={200} className={styles.statValue}>
{status.total_failures}
</Text>

View File

@@ -33,9 +33,9 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
if (!cancelled) setStatus(res.completed ? "done" : "pending");
})
.catch((): void => {
// If the check fails, optimistically allow through — the backend will
// redirect API calls to /api/setup anyway.
if (!cancelled) setStatus("done");
// A failed check conservatively redirects to /setup — a crashed
// backend cannot serve protected routes anyway.
if (!cancelled) setStatus("pending");
});
return (): void => {
cancelled = true;

View File

@@ -4,6 +4,7 @@
*/
import {
Cell,
Legend,
Pie,
PieChart,
@@ -11,6 +12,7 @@ import {
Tooltip,
} from "recharts";
import type { PieLabelRenderProps } from "recharts";
import type { LegendPayload } from "recharts/types/component/DefaultLegendContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles, Text } from "@fluentui/react-components";
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
@@ -152,12 +154,19 @@ export function TopCountriesPieChart({
);
}
/** Format legend entries as "Country Name (xx%)" */
const legendFormatter = (value: string): string => {
/** Format legend entries as "Country Name (xx%)" and colour them to match their slice. */
const legendFormatter = (
value: string,
entry: LegendPayload,
): React.ReactNode => {
const slice = slices.find((s) => s.name === value);
if (slice == null || total === 0) return value;
const pct = ((slice.value / total) * 100).toFixed(1);
return `${value} (${pct}%)`;
return (
<span style={{ color: entry.color }}>
{value} ({pct}%)
</span>
);
};
return (
@@ -177,7 +186,12 @@ export function TopCountriesPieChart({
return `${name}: ${(percent * 100).toFixed(0)}%`;
}}
labelLine={false}
/>
>
{slices.map((slice, index) => (
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Cell key={index} fill={slice.fill} />
))}
</Pie>
<Tooltip content={PieTooltip} />
<Legend formatter={legendFormatter} />
</PieChart>

View File

@@ -174,8 +174,8 @@ describe("ConfigPage — Add Log Path", () => {
renderConfigPage();
await openSshdAccordion(user);
// Existing path from fixture
expect(screen.getByText("/var/log/auth.log")).toBeInTheDocument();
// Existing path from fixture — rendered as an <input> value
expect(screen.getByDisplayValue("/var/log/auth.log")).toBeInTheDocument();
// Add-log-path input placeholder
expect(
@@ -222,8 +222,8 @@ describe("ConfigPage — Add Log Path", () => {
});
});
// New path should appear in the list
expect(screen.getByText("/var/log/nginx/access.log")).toBeInTheDocument();
// New path should appear in the list as an <input> value
expect(screen.getByDisplayValue("/var/log/nginx/access.log")).toBeInTheDocument();
// Input should be cleared
expect(input).toHaveValue("");

View File

@@ -0,0 +1,153 @@
/**
* Tests for the ServerStatusBar component.
*
* Covers loading state, online / offline rendering, and correct tooltip
* wording that distinguishes the fail2ban daemon version from the BanGUI
* application version.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ServerStatusBar } from "../ServerStatusBar";
// ---------------------------------------------------------------------------
// Mock useServerStatus so tests never touch the network.
// ---------------------------------------------------------------------------
vi.mock("../../hooks/useServerStatus");
import { useServerStatus } from "../../hooks/useServerStatus";
const mockedUseServerStatus = vi.mocked(useServerStatus);
function renderBar(): void {
render(
<FluentProvider theme={webLightTheme}>
<ServerStatusBar />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ServerStatusBar", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while the initial load is in progress", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
loading: true,
error: null,
refresh: vi.fn(),
});
renderBar();
// The status-area spinner is labelled "Checking\u2026".
expect(screen.getByText("Checking\u2026")).toBeInTheDocument();
});
it("renders an Online badge when the server is reachable", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.1.0",
active_jails: 3,
total_bans: 10,
total_failures: 5,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("renders an Offline badge when the server is unreachable", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: false,
version: null,
active_jails: 0,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("Offline")).toBeInTheDocument();
});
it("displays the daemon version string when available", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.2.3",
active_jails: 1,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
});
it("does not render the version element when version is null", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: false,
version: null,
active_jails: 0,
total_bans: 0,
total_failures: 0,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
// No version string should appear in the document.
expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument();
});
it("shows jail / ban / failure counts when the server is online", () => {
mockedUseServerStatus.mockReturnValue({
status: {
online: true,
version: "1.0.0",
active_jails: 4,
total_bans: 21,
total_failures: 99,
},
loading: false,
error: null,
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("21")).toBeInTheDocument();
expect(screen.getByText("99")).toBeInTheDocument();
// Verify the "Failed Attempts:" label (renamed from "Failures:").
expect(screen.getByText("Failed Attempts:")).toBeInTheDocument();
});
it("renders an error message when the status fetch fails", () => {
mockedUseServerStatus.mockReturnValue({
status: null,
loading: false,
error: "Network error",
refresh: vi.fn(),
});
renderBar();
expect(screen.getByText("Network error")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { SetupGuard } from "../SetupGuard";
// Mock the setup API module so tests never hit a real network.
vi.mock("../../api/setup", () => ({
getSetupStatus: vi.fn(),
}));
import { getSetupStatus } from "../../api/setup";
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
function renderGuard() {
return render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/dashboard"]}>
<Routes>
<Route
path="/dashboard"
element={
<SetupGuard>
<div data-testid="protected-content">Protected</div>
</SetupGuard>
}
/>
<Route
path="/setup"
element={<div data-testid="setup-page">Setup Page</div>}
/>
</Routes>
</MemoryRouter>
</FluentProvider>,
);
}
describe("SetupGuard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a spinner while the setup status is loading", () => {
// getSetupStatus resolves eventually — spinner should show immediately.
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
renderGuard();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("renders children when setup is complete", async () => {
mockedGetSetupStatus.mockResolvedValue({ completed: true });
renderGuard();
await waitFor(() => {
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
});
});
it("redirects to /setup when setup is not complete", async () => {
mockedGetSetupStatus.mockResolvedValue({ completed: false });
renderGuard();
await waitFor(() => {
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
});
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
});
it("redirects to /setup when the API call fails", async () => {
// Task 0.3: a failed check must redirect to /setup, not allow through.
mockedGetSetupStatus.mockRejectedValue(new Error("Network error"));
renderGuard();
await waitFor(() => {
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
});
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
});
});

View File

@@ -4,9 +4,12 @@
* Displays the jail name and provides optional override fields for bantime,
* findtime, maxretry, port and logpath. Calls the activate endpoint on
* confirmation and propagates the result via callbacks.
*
* Runs pre-activation validation when the dialog opens and displays any
* warnings or blocking errors before the user confirms.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
@@ -19,12 +22,17 @@ import {
Input,
MessageBar,
MessageBarBody,
MessageBarTitle,
Spinner,
Text,
tokens,
} from "@fluentui/react-components";
import { activateJail } from "../../api/config";
import type { ActivateJailRequest, InactiveJail } from "../../types/config";
import { activateJail, validateJailConfig } from "../../api/config";
import type {
ActivateJailRequest,
InactiveJail,
JailValidationIssue,
} from "../../types/config";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
@@ -68,6 +76,12 @@ export function ActivateJailDialog({
const [logpath, setLogpath] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recoveryStatus, setRecoveryStatus] = useState<"recovered" | "unrecovered" | null>(null);
// Pre-activation validation state
const [validating, setValidating] = useState(false);
const [validationIssues, setValidationIssues] = useState<JailValidationIssue[]>([]);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
const resetForm = (): void => {
setBantime("");
@@ -76,8 +90,32 @@ export function ActivateJailDialog({
setPort("");
setLogpath("");
setError(null);
setRecoveryStatus(null);
setValidationIssues([]);
setValidationWarnings([]);
};
// Run pre-validation whenever the dialog opens for a jail.
useEffect(() => {
if (!open || !jail) return;
setValidating(true);
setValidationIssues([]);
setValidationWarnings([]);
validateJailConfig(jail.name)
.then((result) => {
setValidationIssues(result.issues);
})
.catch(() => {
// Validation failure is non-blocking — proceed to allow the user to
// attempt activation and let the server decide.
})
.finally(() => {
setValidating(false);
});
}, [open, jail]);
const handleClose = (): void => {
if (submitting) return;
resetForm();
@@ -106,7 +144,24 @@ export function ActivateJailDialog({
setError(null);
activateJail(jail.name, overrides)
.then(() => {
.then((result) => {
if (!result.active) {
if (result.recovered === true) {
// Activation failed but the system rolled back automatically.
setRecoveryStatus("recovered");
} else if (result.recovered === false) {
// Activation failed and rollback also failed.
setRecoveryStatus("unrecovered");
} else {
// Backend rejected before writing (e.g. missing logpath or filter).
// Show the server's message and keep the dialog open.
setError(result.message);
}
return;
}
if (result.validation_warnings.length > 0) {
setValidationWarnings(result.validation_warnings);
}
resetForm();
onActivated();
})
@@ -126,6 +181,10 @@ export function ActivateJailDialog({
if (!jail) return <></>;
// All validation issues block activation — logpath errors are now critical.
const blockingIssues = validationIssues;
const advisoryIssues: JailValidationIssue[] = [];
return (
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) handleClose(); }}>
<DialogSurface>
@@ -137,6 +196,60 @@ export function ActivateJailDialog({
<code>jail.d/{jail.name}.local</code> and reload fail2ban. The
jail will start monitoring immediately.
</Text>
{/* Pre-validation results */}
{validating && (
<div style={{ marginBottom: tokens.spacingVerticalS, display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
<Spinner size="tiny" />
<Text size={200}>Validating configuration</Text>
</div>
)}
{!validating && blockingIssues.length > 0 && (
<MessageBar
intent="error"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>
<strong>Configuration errors detected:</strong>
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
{blockingIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{!validating && advisoryIssues.length > 0 && (
<MessageBar
intent="warning"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>
<strong>Advisory warnings:</strong>
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
{advisoryIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{validationWarnings.length > 0 && (
<MessageBar
intent="warning"
style={{ marginBottom: tokens.spacingVerticalS }}
>
<MessageBarBody>
<strong>Post-activation warnings:</strong>
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
{validationWarnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
<Text block weight="semibold" style={{ marginBottom: tokens.spacingVerticalS }}>
Override values (leave blank to use config defaults)
</Text>
@@ -207,6 +320,34 @@ export function ActivateJailDialog({
onChange={(_e, d) => { setLogpath(d.value); }}
/>
</Field>
{recoveryStatus === "recovered" && (
<MessageBar
intent="warning"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed Configuration Rolled Back</MessageBarTitle>
The configuration for jail &ldquo;{jail.name}&rdquo; has been
rolled back to its previous state and fail2ban is running
normally. Review the configuration and try activating again.
</MessageBarBody>
</MessageBar>
)}
{recoveryStatus === "unrecovered" && (
<MessageBar
intent="error"
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed Rollback Unsuccessful</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed and the
automatic rollback did not complete. The file{" "}
<code>jail.d/{jail.name}.local</code> may still contain{" "}
<code>enabled = true</code>. Check the fail2ban logs, correct
the file manually, and restart fail2ban.
</MessageBarBody>
</MessageBar>
)}
{error && (
<MessageBar
intent="error"
@@ -227,10 +368,10 @@ export function ActivateJailDialog({
<Button
appearance="primary"
onClick={handleConfirm}
disabled={submitting}
disabled={submitting || validating || blockingIssues.length > 0}
icon={submitting ? <Spinner size="tiny" /> : undefined}
>
{submitting ? "Activating…" : "Activate"}
{submitting ? "Activating and verifying…" : "Activate"}
</Button>
</DialogActions>
</DialogBody>

View File

@@ -1,142 +0,0 @@
/**
* GlobalTab — global fail2ban settings editor.
*
* Provides form fields for log level, log target, database purge age,
* and database max matches.
*/
import { useEffect, useMemo, useState } from "react";
import {
Field,
Input,
MessageBar,
MessageBarBody,
Select,
Spinner,
} from "@fluentui/react-components";
import type { GlobalConfigUpdate } from "../../types/config";
import { useGlobalConfig } from "../../hooks/useConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
/** Available fail2ban log levels in descending severity order. */
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
/**
* Tab component for editing global fail2ban configuration.
*
* @returns JSX element.
*/
export function GlobalTab(): React.JSX.Element {
const styles = useConfigStyles();
const { config, loading, error, updateConfig } = useGlobalConfig();
const [logLevel, setLogLevel] = useState("");
const [logTarget, setLogTarget] = useState("");
const [dbPurgeAge, setDbPurgeAge] = useState("");
const [dbMaxMatches, setDbMaxMatches] = useState("");
// Sync local state when config loads for the first time.
useEffect(() => {
if (config && logLevel === "") {
setLogLevel(config.log_level);
setLogTarget(config.log_target);
setDbPurgeAge(String(config.db_purge_age));
setDbMaxMatches(String(config.db_max_matches));
}
// Only run on first config load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const effectiveLogLevel = logLevel || config?.log_level || "";
const effectiveLogTarget = logTarget || config?.log_target || "";
const effectiveDbPurgeAge =
dbPurgeAge || (config ? String(config.db_purge_age) : "");
const effectiveDbMaxMatches =
dbMaxMatches || (config ? String(config.db_max_matches) : "");
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
const update: GlobalConfigUpdate = {};
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
if (effectiveDbPurgeAge)
update.db_purge_age = Number(effectiveDbPurgeAge);
if (effectiveDbMaxMatches)
update.db_max_matches = Number(effectiveDbMaxMatches);
return update;
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(updatePayload, updateConfig);
if (loading) return <Spinner label="Loading global config…" />;
if (error)
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
return (
<div>
<div className={styles.sectionCard}>
<AutoSaveIndicator
status={saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
<div className={styles.fieldRow}>
<Field label="Log Level">
<Select
value={effectiveLogLevel}
onChange={(_e, d) => {
setLogLevel(d.value);
}}
>
{LOG_LEVELS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</Select>
</Field>
<Field label="Log Target">
<Input
value={effectiveLogTarget}
placeholder="STDOUT / /var/log/fail2ban.log"
onChange={(_e, d) => {
setLogTarget(d.value);
}}
/>
</Field>
</div>
<div className={styles.fieldRow}>
<Field
label="DB Purge Age (s)"
hint="Ban records older than this are removed from the fail2ban database."
>
<Input
type="number"
value={effectiveDbPurgeAge}
onChange={(_e, d) => {
setDbPurgeAge(d.value);
}}
/>
</Field>
<Field
label="DB Max Matches"
hint="Maximum number of log-line matches stored per ban record."
>
<Input
type="number"
value={effectiveDbMaxMatches}
onChange={(_e, d) => {
setDbMaxMatches(d.value);
}}
/>
</Field>
</div>
</div>
</div>
);
}

View File

@@ -19,6 +19,7 @@ import {
Select,
Skeleton,
SkeletonItem,
Spinner,
Switch,
Text,
tokens,
@@ -34,10 +35,12 @@ import { ApiError } from "../../api/client";
import {
addLogPath,
deactivateJail,
deleteJailLocalOverride,
deleteLogPath,
fetchInactiveJails,
fetchJailConfigFileContent,
updateJailConfigFile,
validateJailConfig,
} from "../../api/config";
import type {
AddLogPathRequest,
@@ -45,6 +48,8 @@ import type {
InactiveJail,
JailConfig,
JailConfigUpdate,
JailValidationIssue,
JailValidationResult,
} from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
@@ -99,6 +104,10 @@ interface JailConfigDetailProps {
readOnly?: boolean;
/** When provided (and readOnly=true) shows an Activate Jail button. */
onActivate?: () => void;
/** When provided (and readOnly=true) shows a Validate Config button. */
onValidate?: () => void;
/** Whether validation is currently running (shows spinner on Validate button). */
validating?: boolean;
}
/**
@@ -116,6 +125,8 @@ function JailConfigDetail({
onDeactivate,
readOnly = false,
onActivate,
onValidate,
validating = false,
}: JailConfigDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [banTime, setBanTime] = useState(String(jail.ban_time));
@@ -563,15 +574,36 @@ function JailConfigDetail({
</div>
)}
{readOnly && onActivate !== undefined && (
<div style={{ marginTop: tokens.spacingVerticalM }}>
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
{onValidate !== undefined && (
<Button
appearance="secondary"
icon={validating ? <Spinner size="tiny" /> : undefined}
onClick={onValidate}
disabled={validating}
>
{validating ? "Validating…" : "Validate Config"}
</Button>
)}
{onDeactivate !== undefined && (
<Button
appearance="secondary"
icon={<LockOpen24Regular />}
onClick={onDeactivate}
>
Deactivate Jail
</Button>
)}
{onActivate !== undefined && (
<Button
appearance="primary"
icon={<Play24Regular />}
onClick={onActivate}
>
Activate Jail
</Button>
)}
</div>
)}
@@ -596,6 +628,8 @@ function JailConfigDetail({
interface InactiveJailDetailProps {
jail: InactiveJail;
onActivate: () => void;
/** Called when the user requests removal of the .local override file. */
onDeactivate?: () => void;
}
/**
@@ -612,8 +646,25 @@ interface InactiveJailDetailProps {
function InactiveJailDetail({
jail,
onActivate,
onDeactivate,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null);
const handleValidate = useCallback((): void => {
setValidating(true);
setValidationResult(null);
validateJailConfig(jail.name)
.then((result) => { setValidationResult(result); })
.catch(() => { /* validation call failed — ignore */ })
.finally(() => { setValidating(false); });
}, [jail.name]);
const blockingIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field !== "logpath") ?? [];
const advisoryIssues: JailValidationIssue[] =
validationResult?.issues.filter((i) => i.field === "logpath") ?? [];
const jailConfig = useMemo<JailConfig>(
() => ({
@@ -648,11 +699,50 @@ function InactiveJailDetail({
<Field label="Source file" style={{ marginBottom: tokens.spacingVerticalM }}>
<Input readOnly value={jail.source_file} className={styles.codeFont} />
</Field>
{/* Validation result panel */}
{validationResult !== null && (
<div style={{ marginBottom: tokens.spacingVerticalM }}>
{blockingIssues.length === 0 && advisoryIssues.length === 0 ? (
<MessageBar intent="success">
<MessageBarBody>Configuration is valid.</MessageBarBody>
</MessageBar>
) : null}
{blockingIssues.length > 0 && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalXS }}>
<MessageBarBody>
<strong>Errors:</strong>
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
{blockingIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
{advisoryIssues.length > 0 && (
<MessageBar intent="warning">
<MessageBarBody>
<strong>Warnings:</strong>
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
{advisoryIssues.map((issue, idx) => (
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
))}
</ul>
</MessageBarBody>
</MessageBar>
)}
</div>
)}
<JailConfigDetail
jail={jailConfig}
onSave={async () => { /* read-only — never called */ }}
readOnly
onActivate={onActivate}
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
onValidate={handleValidate}
validating={validating}
/>
</div>
);
@@ -703,6 +793,15 @@ export function JailsTab(): React.JSX.Element {
.catch(() => { /* non-critical — list refreshes on next load */ });
}, [refresh, loadInactive]);
const handleDeactivateInactive = useCallback((name: string): void => {
deleteJailLocalOverride(name)
.then(() => {
setSelectedName(null);
loadInactive();
})
.catch(() => { /* non-critical — list refreshes on next load */ });
}, [loadInactive]);
const handleActivated = useCallback((): void => {
setActivateTarget(null);
setSelectedName(null);
@@ -799,14 +898,21 @@ export function JailsTab(): React.JSX.Element {
>
{selectedActiveJail !== undefined ? (
<JailConfigDetail
key={selectedActiveJail.name}
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
key={selectedInactiveJail.name}
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
onDeactivate={
selectedInactiveJail.has_local_override
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
: undefined
}
/>
) : null}
</ConfigListDetail>

View File

@@ -1,212 +0,0 @@
/**
* MapTab — world map color threshold configuration editor.
*
* Allows the user to set the low / medium / high ban-count thresholds
* that drive country fill colors on the World Map page.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Field,
Input,
MessageBar,
MessageBarBody,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import { ApiError } from "../../api/client";
import {
fetchMapColorThresholds,
updateMapColorThresholds,
} from "../../api/config";
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
import { useAutoSave } from "../../hooks/useAutoSave";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { useConfigStyles } from "./configStyles";
// ---------------------------------------------------------------------------
// Inner form — only mounted after data is loaded.
// ---------------------------------------------------------------------------
interface MapFormProps {
initial: MapColorThresholdsResponse;
}
function MapForm({ initial }: MapFormProps): React.JSX.Element {
const styles = useConfigStyles();
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
const high = Number(thresholdHigh);
const medium = Number(thresholdMedium);
const low = Number(thresholdLow);
const validationError = useMemo<string | null>(() => {
if (isNaN(high) || isNaN(medium) || isNaN(low))
return "All thresholds must be valid numbers.";
if (high <= 0 || medium <= 0 || low <= 0)
return "All thresholds must be positive integers.";
if (!(high > medium && medium > low))
return "Thresholds must satisfy: high > medium > low.";
return null;
}, [high, medium, low]);
// Only pass a new payload to useAutoSave when all values are valid.
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
threshold_high: initial.threshold_high,
threshold_medium: initial.threshold_medium,
threshold_low: initial.threshold_low,
});
useEffect(() => {
if (validationError !== null) return;
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
}, [high, medium, low, validationError]);
const saveThresholds = useCallback(
async (payload: MapColorThresholdsUpdate): Promise<void> => {
await updateMapColorThresholds(payload);
},
[],
);
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
useAutoSave(validPayload, saveThresholds);
return (
<div>
<div className={styles.sectionCard}>
<Text as="h3" size={500} weight="semibold" block>
Map Color Thresholds
</Text>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
Configure the ban count thresholds that determine country fill colors on
the World Map. Countries with zero bans remain transparent. Colors
smoothly interpolate between thresholds.
</Text>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={validationError ? "idle" : saveStatus}
errorText={saveErrorText}
onRetry={retrySave}
/>
</div>
{validationError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{validationError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.fieldRowThree}>
<Field label="Low Threshold (Green)" required>
<Input
type="number"
value={thresholdLow}
onChange={(_, d) => {
setThresholdLow(d.value);
}}
min={1}
/>
</Field>
<Field label="Medium Threshold (Yellow)" required>
<Input
type="number"
value={thresholdMedium}
onChange={(_, d) => {
setThresholdMedium(d.value);
}}
min={1}
/>
</Field>
<Field label="High Threshold (Red)" required>
<Input
type="number"
value={thresholdHigh}
onChange={(_, d) => {
setThresholdHigh(d.value);
}}
min={1}
/>
</Field>
</div>
<Text
as="p"
size={200}
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalS }}
>
1 to {thresholdLow}: Light green Full green
<br /> {thresholdLow} to {thresholdMedium}: Green Yellow
<br /> {thresholdMedium} to {thresholdHigh}: Yellow Red
<br /> {thresholdHigh}+: Solid red
</Text>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Outer loader component.
// ---------------------------------------------------------------------------
/**
* Tab component for editing world-map ban-count color thresholds.
*
* @returns JSX element.
*/
export function MapTab(): React.JSX.Element {
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
try {
const data = await fetchMapColorThresholds();
setThresholds(data);
} catch (err) {
setLoadError(
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
);
}
}, []);
useEffect(() => {
void load();
}, [load]);
if (!thresholds && !loadError) {
return (
<Skeleton aria-label="Loading map settings…">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<SkeletonItem size={32} />
<SkeletonItem size={32} />
<SkeletonItem size={32} />
</div>
</Skeleton>
);
}
if (loadError)
return (
<MessageBar intent="error">
<MessageBarBody>{loadError}</MessageBarBody>
</MessageBar>
);
if (!thresholds) return <></>;
return <MapForm initial={thresholds} />;
}

View File

@@ -0,0 +1,512 @@
/**
* ServerHealthSection — service health panel and log viewer for ServerTab.
*
* 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
// ---------------------------------------------------------------------------
/**
* Server health panel and log viewer section for ServerTab.
*
* @returns JSX element.
*/
export function ServerHealthSection(): 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 (
<>
{/* 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>
</>
);
}

View File

@@ -2,10 +2,12 @@
* ServerTab — fail2ban server-level settings editor.
*
* Provides form fields for live server settings (log level, log target,
* DB purge age, DB max matches) and a "Flush Logs" action button.
* DB purge age, DB max matches), action buttons (flush logs, reload fail2ban,
* restart fail2ban), world map color threshold configuration, and service
* health + log viewer.
*/
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Button,
Field,
@@ -15,16 +17,25 @@ import {
Select,
Skeleton,
SkeletonItem,
Text,
tokens,
} from "@fluentui/react-components";
import {
DocumentArrowDown24Regular,
ArrowSync24Regular,
} from "@fluentui/react-icons";
import { ApiError } from "../../api/client";
import type { ServerSettingsUpdate } from "../../types/config";
import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
import { useServerSettings } from "../../hooks/useConfig";
import { useAutoSave } from "../../hooks/useAutoSave";
import {
fetchMapColorThresholds,
updateMapColorThresholds,
reloadConfig,
restartFail2Ban,
} from "../../api/config";
import { AutoSaveIndicator } from "./AutoSaveIndicator";
import { ServerHealthSection } from "./ServerHealthSection";
import { useConfigStyles } from "./configStyles";
/** Available fail2ban log levels in descending severity order. */
@@ -46,6 +57,17 @@ export function ServerTab(): React.JSX.Element {
const [flushing, setFlushing] = useState(false);
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
// Reload/Restart state
const [isReloading, setIsReloading] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
// Map color thresholds
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
const [mapThresholdLow, setMapThresholdLow] = useState("");
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
const effectiveLogLevel = logLevel || settings?.log_level || "";
const effectiveLogTarget = logTarget || settings?.log_target || "";
const effectiveDbPurgeAge =
@@ -83,6 +105,99 @@ export function ServerTab(): React.JSX.Element {
}
}, [flush]);
const handleReload = useCallback(async () => {
setIsReloading(true);
setMsg(null);
try {
await reloadConfig();
setMsg({ text: "fail2ban reloaded successfully", ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Reload failed.",
ok: false,
});
} finally {
setIsReloading(false);
}
}, []);
const handleRestart = useCallback(async () => {
setIsRestarting(true);
setMsg(null);
try {
await restartFail2Ban();
setMsg({ text: "fail2ban restart initiated", ok: true });
} catch (err: unknown) {
setMsg({
text: err instanceof ApiError ? err.message : "Restart failed.",
ok: false,
});
} finally {
setIsRestarting(false);
}
}, []);
// Load map color thresholds on mount.
const loadMapThresholds = useCallback(async (): Promise<void> => {
try {
const data = await fetchMapColorThresholds();
setMapThresholds(data);
setMapThresholdHigh(String(data.threshold_high));
setMapThresholdMedium(String(data.threshold_medium));
setMapThresholdLow(String(data.threshold_low));
setMapLoadError(null);
} catch (err) {
setMapLoadError(
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
);
}
}, []);
useEffect(() => {
void loadMapThresholds();
}, [loadMapThresholds]);
// Map threshold validation and auto-save.
const mapHigh = Number(mapThresholdHigh);
const mapMedium = Number(mapThresholdMedium);
const mapLow = Number(mapThresholdLow);
const mapValidationError = useMemo<string | null>(() => {
if (!mapThresholds) return null;
if (isNaN(mapHigh) || isNaN(mapMedium) || isNaN(mapLow))
return "All thresholds must be valid numbers.";
if (mapHigh <= 0 || mapMedium <= 0 || mapLow <= 0)
return "All thresholds must be positive integers.";
if (!(mapHigh > mapMedium && mapMedium > mapLow))
return "Thresholds must satisfy: high > medium > low.";
return null;
}, [mapHigh, mapMedium, mapLow, mapThresholds]);
const [mapValidPayload, setMapValidPayload] = useState<MapColorThresholdsUpdate>({
threshold_high: mapThresholds?.threshold_high ?? 0,
threshold_medium: mapThresholds?.threshold_medium ?? 0,
threshold_low: mapThresholds?.threshold_low ?? 0,
});
useEffect(() => {
if (mapValidationError !== null || !mapThresholds) return;
setMapValidPayload({
threshold_high: mapHigh,
threshold_medium: mapMedium,
threshold_low: mapLow,
});
}, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]);
const saveMapThresholds = useCallback(
async (payload: MapColorThresholdsUpdate): Promise<void> => {
await updateMapColorThresholds(payload);
},
[],
);
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
useAutoSave(mapValidPayload, saveMapThresholds);
if (loading) {
return (
<Skeleton aria-label="Loading server settings…">
@@ -104,6 +219,10 @@ export function ServerTab(): React.JSX.Element {
return (
<div>
{/* Service Health & Log Viewer section — shown first so users can
immediately see whether fail2ban is reachable before editing settings. */}
<ServerHealthSection />
<div className={styles.sectionCard}>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
@@ -154,7 +273,10 @@ export function ServerTab(): React.JSX.Element {
</Field>
</div>
<div className={styles.fieldRow}>
<Field label="DB Purge Age (s)">
<Field
label="DB Purge Age (s)"
hint="Ban records older than this are removed from the fail2ban database."
>
<Input
type="number"
value={effectiveDbPurgeAge}
@@ -163,7 +285,10 @@ export function ServerTab(): React.JSX.Element {
}}
/>
</Field>
<Field label="DB Max Matches">
<Field
label="DB Max Matches"
hint="Maximum number of log-line matches stored per ban record."
>
<Input
type="number"
value={effectiveDbMaxMatches}
@@ -182,6 +307,22 @@ export function ServerTab(): React.JSX.Element {
>
{flushing ? "Flushing…" : "Flush Logs"}
</Button>
<Button
appearance="secondary"
icon={<ArrowSync24Regular />}
disabled={isReloading}
onClick={() => void handleReload()}
>
{isReloading ? "Reloading…" : "Reload fail2ban"}
</Button>
<Button
appearance="secondary"
icon={<ArrowSync24Regular />}
disabled={isRestarting}
onClick={() => void handleRestart()}
>
{isRestarting ? "Restarting…" : "Restart fail2ban"}
</Button>
</div>
{msg && (
<MessageBar intent={msg.ok ? "success" : "error"}>
@@ -189,6 +330,92 @@ export function ServerTab(): React.JSX.Element {
</MessageBar>
)}
</div>
{/* Map Color Thresholds section */}
{mapLoadError ? (
<div className={styles.sectionCard}>
<MessageBar intent="error">
<MessageBarBody>{mapLoadError}</MessageBarBody>
</MessageBar>
</div>
) : mapThresholds ? (
<div className={styles.sectionCard}>
<Text as="h3" size={500} weight="semibold" block>
Map Color Thresholds
</Text>
<Text
as="p"
size={300}
className={styles.infoText}
block
style={{ marginBottom: tokens.spacingVerticalM }}
>
Configure the ban count thresholds that determine country fill colors on
the World Map. Countries with zero bans remain transparent. Colors
smoothly interpolate between thresholds.
</Text>
<div style={{ marginBottom: tokens.spacingVerticalS }}>
<AutoSaveIndicator
status={mapValidationError ? "idle" : mapSaveStatus}
errorText={mapSaveErrorText}
onRetry={retryMapSave}
/>
</div>
{mapValidationError && (
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
<MessageBarBody>{mapValidationError}</MessageBarBody>
</MessageBar>
)}
<div className={styles.fieldRowThree}>
<Field label="Low Threshold (Green)" required>
<Input
type="number"
value={mapThresholdLow}
onChange={(_, d) => {
setMapThresholdLow(d.value);
}}
min={1}
/>
</Field>
<Field label="Medium Threshold (Yellow)" required>
<Input
type="number"
value={mapThresholdMedium}
onChange={(_, d) => {
setMapThresholdMedium(d.value);
}}
min={1}
/>
</Field>
<Field label="High Threshold (Red)" required>
<Input
type="number"
value={mapThresholdHigh}
onChange={(_, d) => {
setMapThresholdHigh(d.value);
}}
min={1}
/>
</Field>
</div>
<Text
as="p"
size={200}
className={styles.infoText}
style={{ marginTop: tokens.spacingVerticalS }}
>
1 to {mapThresholdLow}: Light green Full green
<br /> {mapThresholdLow} to {mapThresholdMedium}: Green Yellow
<br /> {mapThresholdMedium} to {mapThresholdHigh}: Yellow Red
<br /> {mapThresholdHigh}+: Solid red
</Text>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,202 @@
/**
* Tests for ActivateJailDialog (Task 7).
*
* Covers:
* - "Activate" button is disabled when pre-validation returns blocking issues.
* - "Activate" button is enabled when validation passes.
* - Dialog stays open and shows an error when the backend returns active=false.
* - `onActivated` is called and dialog closes when backend returns active=true.
*/
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 { ActivateJailDialog } from "../ActivateJailDialog";
import type { InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
activateJail: vi.fn(),
validateJailConfig: vi.fn(),
}));
import { activateJail, validateJailConfig } from "../../../api/config";
const mockActivateJail = vi.mocked(activateJail);
const mockValidateJailConfig = vi.mocked(validateJailConfig);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const baseJail: InactiveJail = {
name: "airsonic-auth",
filter: "airsonic-auth",
actions: ["iptables-multiport"],
port: "8080",
logpath: ["/var/log/airsonic/airsonic.log"],
bantime: "10m",
findtime: "10m",
maxretry: 5,
ban_time_seconds: 600,
find_time_seconds: 600,
log_encoding: "auto",
backend: "auto",
date_pattern: null,
use_dns: "warn",
prefregex: "",
fail_regex: ["Failed login.*from <HOST>"],
ignore_regex: [],
bantime_escalation: null,
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
enabled: false,
has_local_override: false,
};
/** Successful activation response. */
const successResponse: JailActivationResponse = {
name: "airsonic-auth",
active: true,
message: "Jail activated successfully.",
fail2ban_running: true,
validation_warnings: [],
};
/** Response when backend blocks activation (e.g. missing logpath). */
const blockedResponse: JailActivationResponse = {
name: "airsonic-auth",
active: false,
message: "Jail 'airsonic-auth' cannot be activated: logpath does not exist.",
fail2ban_running: true,
validation_warnings: ["logpath: /var/log/airsonic/airsonic.log does not exist"],
};
/** Validation result with a logpath issue (should block the button). */
const validationWithLogpathIssue: JailValidationResult = {
jail_name: "airsonic-auth",
valid: false,
issues: [{ field: "logpath", message: "/var/log/airsonic/airsonic.log does not exist" }],
};
/** Validation result with no issues. */
const validationPassed: JailValidationResult = {
jail_name: "airsonic-auth",
valid: true,
issues: [],
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface DialogProps {
jail?: InactiveJail | null;
open?: boolean;
onClose?: () => void;
onActivated?: () => void;
}
function renderDialog({
jail = baseJail,
open = true,
onClose = vi.fn(),
onActivated = vi.fn(),
}: DialogProps = {}) {
return render(
<FluentProvider theme={webLightTheme}>
<ActivateJailDialog
jail={jail}
open={open}
onClose={onClose}
onActivated={onActivated}
/>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ActivateJailDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("disables the Activate button when pre-validation returns blocking issues", async () => {
mockValidateJailConfig.mockResolvedValue(validationWithLogpathIssue);
renderDialog();
// Wait for validation to complete and the error message to appear.
await waitFor(() => {
expect(screen.getByText(/configuration errors detected/i)).toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
expect(activateBtn).toBeDisabled();
});
it("enables the Activate button when validation passes", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
renderDialog();
// Wait for validation spinner to disappear.
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
expect(activateBtn).not.toBeDisabled();
});
it("keeps the dialog open and shows an error when backend returns active=false", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
mockActivateJail.mockResolvedValue(blockedResponse);
const onActivated = vi.fn();
renderDialog({ onActivated });
// Wait for validation to finish.
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
await userEvent.click(activateBtn);
// The server's error message should appear.
await waitFor(() => {
expect(
screen.getByText(/cannot be activated/i),
).toBeInTheDocument();
});
// onActivated must NOT have been called.
expect(onActivated).not.toHaveBeenCalled();
});
it("calls onActivated when backend returns active=true", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
mockActivateJail.mockResolvedValue(successResponse);
const onActivated = vi.fn();
renderDialog({ onActivated });
await waitFor(() => {
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
});
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
await userEvent.click(activateBtn);
await waitFor(() => {
expect(onActivated).toHaveBeenCalledOnce();
});
});
});

View File

@@ -30,15 +30,14 @@ export { ExportTab } from "./ExportTab";
export { FilterForm } from "./FilterForm";
export type { FilterFormProps } from "./FilterForm";
export { FiltersTab } from "./FiltersTab";
export { GlobalTab } from "./GlobalTab";
export { JailFilesTab } from "./JailFilesTab";
export { JailFileForm } from "./JailFileForm";
export { JailsTab } from "./JailsTab";
export { MapTab } from "./MapTab";
export { RawConfigSection } from "./RawConfigSection";
export type { RawConfigSectionProps } from "./RawConfigSection";
export { RegexList } from "./RegexList";
export type { RegexListProps } from "./RegexList";
export { RegexTesterTab } from "./RegexTesterTab";
export { ServerTab } from "./ServerTab";
export { ServerHealthSection } from "./ServerHealthSection";
export { useConfigStyles } from "./configStyles";

View File

@@ -0,0 +1,466 @@
/**
* `BannedIpsSection` component.
*
* Displays a paginated table of IPs currently banned in a specific fail2ban
* jail. Supports server-side search filtering (debounced), page navigation,
* page-size selection, and per-row unban actions.
*
* Only the current page is geo-enriched by the backend, so the component
* remains fast even when a jail contains thousands of banned IPs.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Badge,
Button,
DataGrid,
DataGridBody,
DataGridCell,
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Dropdown,
Field,
Input,
MessageBar,
MessageBarBody,
Option,
Spinner,
Text,
Tooltip,
makeStyles,
tokens,
type TableColumnDefinition,
createTableColumn,
} from "@fluentui/react-components";
import {
ArrowClockwiseRegular,
ChevronLeftRegular,
ChevronRightRegular,
DismissRegular,
SearchRegular,
} from "@fluentui/react-icons";
import { fetchJailBannedIps, unbanIp } from "../../api/jails";
import type { ActiveBan } from "../../types/jail";
import { ApiError } from "../../api/client";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Debounce delay in milliseconds for the search input. */
const SEARCH_DEBOUNCE_MS = 300;
/** Available page-size options. */
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
backgroundColor: tokens.colorNeutralBackground1,
borderRadius: tokens.borderRadiusMedium,
borderTopWidth: "1px",
borderTopStyle: "solid",
borderTopColor: tokens.colorNeutralStroke2,
borderRightWidth: "1px",
borderRightStyle: "solid",
borderRightColor: tokens.colorNeutralStroke2,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
borderLeftWidth: "1px",
borderLeftStyle: "solid",
borderLeftColor: tokens.colorNeutralStroke2,
padding: tokens.spacingVerticalM,
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalM,
paddingBottom: tokens.spacingVerticalS,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: tokens.colorNeutralStroke2,
},
headerLeft: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalM,
},
toolbar: {
display: "flex",
alignItems: "flex-end",
gap: tokens.spacingHorizontalS,
flexWrap: "wrap",
},
searchField: {
minWidth: "200px",
flexGrow: 1,
},
tableWrapper: {
overflowX: "auto",
},
centred: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: tokens.spacingVerticalXXL,
},
pagination: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalS,
flexWrap: "wrap",
},
pageSizeWrapper: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
},
mono: {
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Format an ISO 8601 timestamp for compact display.
*
* @param iso - ISO 8601 string or `null`.
* @returns A locale time string, or `"—"` when `null`.
*/
function fmtTime(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** A row item augmented with an `onUnban` callback for the row action. */
interface BanRow {
ban: ActiveBan;
onUnban: (ip: string) => void;
}
const columns: TableColumnDefinition<BanRow>[] = [
createTableColumn<BanRow>({
columnId: "ip",
renderHeaderCell: () => "IP Address",
renderCell: ({ ban }) => (
<Text
style={{
fontFamily: "Consolas, 'Courier New', monospace",
fontSize: tokens.fontSizeBase200,
}}
>
{ban.ip}
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: ({ ban }) =>
ban.country ? (
<Text size={200}>{ban.country}</Text>
) : (
<Text size={200} style={{ color: tokens.colorNeutralForeground4 }}>
</Text>
),
}),
createTableColumn<BanRow>({
columnId: "banned_at",
renderHeaderCell: () => "Banned At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.banned_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "expires_at",
renderHeaderCell: () => "Expires At",
renderCell: ({ ban }) => <Text size={200}>{fmtTime(ban.expires_at)}</Text>,
}),
createTableColumn<BanRow>({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: ({ ban, onUnban }) => (
<Tooltip content={`Unban ${ban.ip}`} relationship="label">
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(ban.ip);
}}
aria-label={`Unban ${ban.ip}`}
/>
</Tooltip>
),
}),
];
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
/** Props for {@link BannedIpsSection}. */
export interface BannedIpsSectionProps {
/** The jail name whose banned IPs are displayed. */
jailName: string;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Paginated section showing currently banned IPs for a single jail.
*
* @param props - {@link BannedIpsSectionProps}
*/
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
const styles = useStyles();
const [items, setItems] = useState<ActiveBan[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<number>(25);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [opError, setOpError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounce the search input so we don't spam the backend on every keystroke.
useEffect(() => {
if (debounceRef.current !== null) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout((): void => {
setDebouncedSearch(search);
setPage(1);
}, SEARCH_DEBOUNCE_MS);
return (): void => {
if (debounceRef.current !== null) clearTimeout(debounceRef.current);
};
}, [search]);
const load = useCallback(() => {
setLoading(true);
setError(null);
fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined)
.then((resp) => {
setItems(resp.items);
setTotal(resp.total);
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setError(msg);
})
.finally(() => {
setLoading(false);
});
}, [jailName, page, pageSize, debouncedSearch]);
useEffect(() => {
load();
}, [load]);
const handleUnban = (ip: string): void => {
setOpError(null);
unbanIp(ip, jailName)
.then(() => {
load();
})
.catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
};
const rows: BanRow[] = items.map((ban) => ({
ban,
onUnban: handleUnban,
}));
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
return (
<div className={styles.root}>
{/* Section header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<Text as="h2" size={500} weight="semibold">
Currently Banned IPs
</Text>
<Badge appearance="tint">{String(total)}</Badge>
</div>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={load}
aria-label="Refresh banned IPs"
/>
</div>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.searchField}>
<Field label="Search by IP">
<Input
aria-label="Search by IP"
contentBefore={<SearchRegular />}
placeholder="e.g. 192.168"
value={search}
onChange={(_, d) => {
setSearch(d.value);
}}
/>
</Field>
</div>
</div>
{/* Error bars */}
{error && (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
)}
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{/* Table */}
{loading ? (
<div className={styles.centred}>
<Spinner label="Loading banned IPs…" />
</div>
) : items.length === 0 ? (
<div className={styles.centred}>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
No IPs currently banned in this jail.
</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<DataGrid
items={rows}
columns={columns}
getRowId={(row: BanRow) => row.ban.ip}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<BanRow>>
{({ item, rowId }) => (
<DataGridRow<BanRow> key={rowId}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
{/* Pagination */}
{total > 0 && (
<div className={styles.pagination}>
<div className={styles.pageSizeWrapper}>
<Text size={200}>Rows per page:</Text>
<Dropdown
aria-label="Rows per page"
value={String(pageSize)}
selectedOptions={[String(pageSize)]}
onOptionSelect={(_, d) => {
const newSize = Number(d.optionValue);
if (!Number.isNaN(newSize)) {
setPageSize(newSize);
setPage(1);
}
}}
style={{ minWidth: "80px" }}
>
{PAGE_SIZE_OPTIONS.map((n) => (
<Option key={n} value={String(n)}>
{String(n)}
</Option>
))}
</Dropdown>
</div>
<Text size={200}>
{String((page - 1) * pageSize + 1)}
{String(Math.min(page * pageSize, total))} of {String(total)}
</Text>
<Button
size="small"
appearance="subtle"
icon={<ChevronLeftRegular />}
disabled={page <= 1}
onClick={() => {
setPage((p) => Math.max(1, p - 1));
}}
aria-label="Previous page"
/>
<Button
size="small"
appearance="subtle"
icon={<ChevronRightRegular />}
disabled={page >= totalPages}
onClick={() => {
setPage((p) => p + 1);
}}
aria-label="Next page"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,251 @@
/**
* Tests for the `BannedIpsSection` component.
*
* Verifies:
* - Renders the section header and total count badge.
* - Shows a spinner while loading.
* - Renders a table with IP rows on success.
* - Shows an empty-state message when there are no banned IPs.
* - Displays an error message bar when the API call fails.
* - Search input re-fetches with the search parameter after debounce.
* - Unban button calls `unbanIp` and refreshes the list.
* - Pagination buttons are shown and change the page.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BannedIpsSection } from "../BannedIpsSection";
import type { JailBannedIpsResponse } from "../../../types/jail";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({
mockFetchJailBannedIps: vi.fn<
(
jailName: string,
page?: number,
pageSize?: number,
search?: string,
) => Promise<JailBannedIpsResponse>
>(),
mockUnbanIp: vi.fn<
(ip: string, jail?: string) => Promise<{ message: string; jail: string }>
>(),
}));
vi.mock("../../../api/jails", () => ({
fetchJailBannedIps: mockFetchJailBannedIps,
unbanIp: mockUnbanIp,
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeBan(ip: string) {
return {
ip,
jail: "sshd",
banned_at: "2025-01-01T10:00:00+00:00",
expires_at: "2025-01-01T10:10:00+00:00",
ban_count: 1,
country: "US",
};
}
function makeResponse(
ips: string[] = ["1.2.3.4", "5.6.7.8"],
total = 2,
): JailBannedIpsResponse {
return {
items: ips.map(makeBan),
total,
page: 1,
page_size: 25,
};
}
const EMPTY_RESPONSE: JailBannedIpsResponse = {
items: [],
total: 0,
page: 1,
page_size: 25,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderSection(jailName = "sshd") {
return render(
<FluentProvider theme={webLightTheme}>
<BannedIpsSection jailName={jailName} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("BannedIpsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
});
it("renders section header with 'Currently Banned IPs' title", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection();
await waitFor(() => {
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
});
});
it("shows the total count badge", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"], 2));
renderSection();
await waitFor(() => {
expect(screen.getByText("2")).toBeTruthy();
});
});
it("shows a spinner while loading", () => {
// Never resolves during this test so we see the spinner.
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
renderSection();
expect(screen.getByText("Loading banned IPs…")).toBeTruthy();
});
it("renders IP rows when banned IPs exist", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"]));
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
expect(screen.getByText("5.6.7.8")).toBeTruthy();
});
});
it("shows empty-state message when no IPs are banned", async () => {
mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE);
renderSection();
await waitFor(() => {
expect(
screen.getByText("No IPs currently banned in this jail."),
).toBeTruthy();
});
});
it("shows an error message bar on API failure", async () => {
mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead"));
renderSection();
await waitFor(() => {
expect(screen.getByText(/socket dead/i)).toBeTruthy();
});
});
it("calls fetchJailBannedIps with the jail name", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection("nginx");
await waitFor(() => {
expect(mockFetchJailBannedIps).toHaveBeenCalledWith(
"nginx",
expect.any(Number),
expect.any(Number),
undefined,
);
});
});
it("search input re-fetches after debounce with the search term", async () => {
vi.useFakeTimers();
mockFetchJailBannedIps.mockResolvedValue(makeResponse());
renderSection();
// Flush pending async work from the initial render (no timer advancement needed).
await act(async () => {});
mockFetchJailBannedIps.mockClear();
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4"], 1),
);
// fireEvent is synchronous — avoids hanging with fake timers.
const input = screen.getByPlaceholderText("e.g. 192.168");
act(() => {
fireEvent.change(input, { target: { value: "1.2.3" } });
});
// Advance just past the 300ms debounce delay and flush promises.
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(mockFetchJailBannedIps).toHaveBeenLastCalledWith(
"sshd",
expect.any(Number),
expect.any(Number),
"1.2.3",
);
vi.useRealTimers();
});
it("calls unbanIp when the unban button is clicked", async () => {
mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4"]));
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
});
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
expect(mockUnbanIp).toHaveBeenCalledWith("1.2.3.4", "sshd");
});
it("refreshes list after successful unban", async () => {
mockFetchJailBannedIps
.mockResolvedValueOnce(makeResponse(["1.2.3.4"]))
.mockResolvedValue(EMPTY_RESPONSE);
mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" });
renderSection();
await waitFor(() => {
expect(screen.getByText("1.2.3.4")).toBeTruthy();
});
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
await userEvent.click(unbanBtn);
await waitFor(() => {
expect(mockFetchJailBannedIps).toHaveBeenCalledTimes(2);
});
});
it("shows pagination controls when total > 0", async () => {
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4", "5.6.7.8"], 50),
);
renderSection();
await waitFor(() => {
expect(screen.getByLabelText("Next page")).toBeTruthy();
expect(screen.getByLabelText("Previous page")).toBeTruthy();
});
});
it("previous page button is disabled on page 1", async () => {
mockFetchJailBannedIps.mockResolvedValue(
makeResponse(["1.2.3.4"], 50),
);
renderSection();
await waitFor(() => {
const prevBtn = screen.getByLabelText("Previous page");
expect(prevBtn).toHaveAttribute("disabled");
});
});
});

View File

@@ -20,6 +20,7 @@ import {
setJailIdle,
startJail,
stopJail,
toggleIgnoreSelf as toggleIgnoreSelfApi,
unbanAllBans,
unbanIp,
} from "../api/jails";
@@ -150,6 +151,8 @@ export interface UseJailDetailResult {
addIp: (ip: string) => Promise<void>;
/** Remove an IP from the ignore list. */
removeIp: (ip: string) => Promise<void>;
/** Enable or disable the ignoreself option for this jail. */
toggleIgnoreSelf: (on: boolean) => Promise<void>;
}
/**
@@ -208,6 +211,11 @@ export function useJailDetail(name: string): UseJailDetailResult {
load();
};
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
};
return {
jail,
ignoreList,
@@ -217,6 +225,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
refresh: load,
addIp,
removeIp,
toggleIgnoreSelf,
};
}

View File

@@ -145,6 +145,16 @@ const useStyles = makeStyles({
padding: tokens.spacingVerticalS,
flexShrink: 0,
},
versionText: {
display: "block",
color: tokens.colorNeutralForeground4,
fontSize: "11px",
paddingLeft: tokens.spacingHorizontalS,
paddingRight: tokens.spacingHorizontalS,
paddingBottom: tokens.spacingVerticalXS,
whiteSpace: "nowrap",
overflow: "hidden",
},
// Main content
main: {
@@ -184,9 +194,9 @@ const NAV_ITEMS: NavItem[] = [
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
{ label: "World Map", to: "/map", icon: <MapRegular /> },
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
{ label: "History", to: "/history", icon: <HistoryRegular /> },
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
];
// ---------------------------------------------------------------------------
@@ -301,6 +311,11 @@ export function MainLayout(): React.JSX.Element {
{/* Footer — Logout */}
<div className={styles.sidebarFooter}>
{!collapsed && (
<Text className={styles.versionText}>
BanGUI v{__APP_VERSION__}
</Text>
)}
<Tooltip
content={collapsed ? "Sign out" : ""}
relationship="label"

View File

@@ -0,0 +1,78 @@
/**
* Tests for the MainLayout component.
*
* Covers:
* - BanGUI application version displayed in the footer when the sidebar is expanded.
* - Version text hidden when the sidebar is collapsed.
* - Navigation items rendered correctly.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MainLayout } from "../../layouts/MainLayout";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../providers/AuthProvider", () => ({
useAuth: () => ({ logout: vi.fn() }),
}));
vi.mock("../../hooks/useServerStatus", () => ({
useServerStatus: () => ({
status: { online: true, version: "1.0.0", active_jails: 1, total_bans: 0, total_failures: 0 },
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
vi.mock("../../hooks/useBlocklist", () => ({
useBlocklistStatus: () => ({ hasErrors: false }),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderLayout(): void {
render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/"]}>
<MainLayout />
</MemoryRouter>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("MainLayout", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the navigation sidebar", () => {
renderLayout();
expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument();
});
it("shows the BanGUI version in the sidebar footer when expanded", () => {
renderLayout();
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
expect(screen.getByText("BanGUI v0.0.0-test")).toBeInTheDocument();
});
it("hides the BanGUI version text when the sidebar is collapsed", async () => {
renderLayout();
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
await userEvent.click(toggleButton);
expect(screen.queryByText("BanGUI v0.0.0-test")).not.toBeInTheDocument();
});
});

View File

@@ -8,9 +8,7 @@
* Jails — per-jail config accordion with inline editing
* Filters — structured filter.d form editor
* Actions — structured action.d form editor
* Globalglobal fail2ban settings (log level, DB config)
* Server — server-level settings + flush logs
* Map — map color threshold configuration
* Serverserver-level settings, map thresholds, service health + log viewer
* Regex Tester — live pattern tester
* Export — raw file editors for jail, filter, and action files
*/
@@ -20,9 +18,7 @@ import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-componen
import {
ActionsTab,
FiltersTab,
GlobalTab,
JailsTab,
MapTab,
RegexTesterTab,
ServerTab,
} from "../components/config";
@@ -57,9 +53,7 @@ type TabValue =
| "jails"
| "filters"
| "actions"
| "global"
| "server"
| "map"
| "regex";
export function ConfigPage(): React.JSX.Element {
@@ -87,9 +81,7 @@ export function ConfigPage(): React.JSX.Element {
<Tab value="jails">Jails</Tab>
<Tab value="filters">Filters</Tab>
<Tab value="actions">Actions</Tab>
<Tab value="global">Global</Tab>
<Tab value="server">Server</Tab>
<Tab value="map">Map</Tab>
<Tab value="regex">Regex Tester</Tab>
</TabList>
@@ -97,9 +89,7 @@ export function ConfigPage(): React.JSX.Element {
{tab === "jails" && <JailsTab />}
{tab === "filters" && <FiltersTab />}
{tab === "actions" && <ActionsTab />}
{tab === "global" && <GlobalTab />}
{tab === "server" && <ServerTab />}
{tab === "map" && <MapTab />}
{tab === "regex" && <RegexTesterTab />}
</div>
</div>

View File

@@ -12,7 +12,6 @@ import { BanTable } from "../components/BanTable";
import { BanTrendChart } from "../components/BanTrendChart";
import { ChartStateWrapper } from "../components/ChartStateWrapper";
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { JailDistributionChart } from "../components/JailDistributionChart";
import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
@@ -160,20 +159,6 @@ export function DashboardPage(): React.JSX.Element {
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Jail Distribution section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Jail Distribution
</Text>
</div>
<div className={styles.tabContent}>
<JailDistributionChart timeRange={timeRange} origin={originFilter} />
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Ban list section */}
{/* ------------------------------------------------------------------ */}

View File

@@ -17,6 +17,7 @@ import {
MessageBar,
MessageBarBody,
Spinner,
Switch,
Text,
Tooltip,
makeStyles,
@@ -41,6 +42,7 @@ import {
import { useJailDetail } from "../hooks/useJails";
import type { Jail } from "../types/jail";
import { ApiError } from "../api/client";
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
// ---------------------------------------------------------------------------
// Styles
@@ -442,6 +444,7 @@ interface IgnoreListSectionProps {
ignoreSelf: boolean;
onAdd: (ip: string) => Promise<void>;
onRemove: (ip: string) => Promise<void>;
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
}
function IgnoreListSection({
@@ -450,6 +453,7 @@ function IgnoreListSection({
ignoreSelf,
onAdd,
onRemove,
onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element {
const styles = useStyles();
const [inputVal, setInputVal] = useState("");
@@ -493,17 +497,27 @@ function IgnoreListSection({
<Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist)
</Text>
{ignoreSelf && (
<Tooltip content="This jail ignores the server's own IP addresses" relationship="label">
<Badge appearance="tint" color="informative">
ignore self
</Badge>
</Tooltip>
)}
</div>
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
</div>
{/* Ignore-self toggle */}
<Switch
label="Ignore self — exclude this server's own IP addresses from banning"
checked={ignoreSelf}
onChange={(_e, data): void => {
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
}}
/>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
@@ -578,7 +592,7 @@ function IgnoreListSection({
export function JailDetailPage(): React.JSX.Element {
const styles = useStyles();
const { name = "" } = useParams<{ name: string }>();
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp } =
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf } =
useJailDetail(name);
if (loading && !jail) {
@@ -624,6 +638,7 @@ export function JailDetailPage(): React.JSX.Element {
</div>
<JailInfoSection jail={jail} onRefresh={refresh} />
<BannedIpsSection jailName={name} />
<PatternsSection jail={jail} />
<BantimeEscalationSection jail={jail} />
<IgnoreListSection
@@ -632,6 +647,7 @@ export function JailDetailPage(): React.JSX.Element {
ignoreSelf={ignoreSelf}
onAdd={addIp}
onRemove={removeIp}
onToggleIgnoreSelf={toggleIgnoreSelf}
/>
</div>
);

View File

@@ -1,12 +1,11 @@
/**
* Jails management page.
*
* Provides four sections in a vertically-stacked layout:
* Provides three sections in a vertically-stacked layout:
* 1. **Jail Overview** — table of all jails with quick status badges and
* per-row start/stop/idle/reload controls.
* 2. **Ban / Unban IP** — form to manually ban or unban an IP address.
* 3. **Currently Banned IPs** — live table of all active bans.
* 4. **IP Lookup** — check whether an IP is currently banned and view its
* 3. **IP Lookup** — check whether an IP is currently banned and view its
* geo-location details.
*/
@@ -20,12 +19,6 @@ import {
DataGridHeader,
DataGridHeaderCell,
DataGridRow,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Field,
Input,
MessageBar,
@@ -42,8 +35,6 @@ import {
import {
ArrowClockwiseRegular,
ArrowSyncRegular,
DeleteRegular,
DismissRegular,
LockClosedRegular,
LockOpenRegular,
PauseRegular,
@@ -53,7 +44,7 @@ import {
} from "@fluentui/react-icons";
import { Link } from "react-router-dom";
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
import type { ActiveBan, JailSummary } from "../types/jail";
import type { JailSummary } from "../types/jail";
import { ApiError } from "../api/client";
// ---------------------------------------------------------------------------
@@ -160,21 +151,6 @@ function fmtSeconds(s: number): string {
return `${String(Math.round(s / 3600))}h`;
}
function fmtTimestamp(iso: string | null): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Jail overview columns
// ---------------------------------------------------------------------------
@@ -236,80 +212,6 @@ const jailColumns: TableColumnDefinition<JailSummary>[] = [
}),
];
// ---------------------------------------------------------------------------
// Active bans columns
// ---------------------------------------------------------------------------
function buildBanColumns(
onUnban: (ip: string, jail: string) => void,
): TableColumnDefinition<ActiveBan>[] {
return [
createTableColumn<ActiveBan>({
columnId: "ip",
renderHeaderCell: () => "IP",
renderCell: (b) => (
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
{b.ip}
</Text>
),
}),
createTableColumn<ActiveBan>({
columnId: "jail",
renderHeaderCell: () => "Jail",
renderCell: (b) => <Text size={200}>{b.jail}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "country",
renderHeaderCell: () => "Country",
renderCell: (b) => <Text size={200}>{b.country ?? "—"}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "bannedAt",
renderHeaderCell: () => "Banned At",
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.banned_at)}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "expiresAt",
renderHeaderCell: () => "Expires At",
renderCell: (b) => <Text size={200}>{fmtTimestamp(b.expires_at)}</Text>,
}),
createTableColumn<ActiveBan>({
columnId: "count",
renderHeaderCell: () => "Count",
renderCell: (b) => (
<Tooltip
content={`Banned ${String(b.ban_count)} time${b.ban_count === 1 ? "" : "s"}`}
relationship="label"
>
<Badge
appearance="filled"
color={b.ban_count > 3 ? "danger" : b.ban_count > 1 ? "warning" : "informative"}
>
{String(b.ban_count)}
</Badge>
</Tooltip>
),
}),
createTableColumn<ActiveBan>({
columnId: "unban",
renderHeaderCell: () => "",
renderCell: (b) => (
<Button
size="small"
appearance="subtle"
icon={<DismissRegular />}
onClick={() => {
onUnban(b.ip, b.jail);
}}
aria-label={`Unban ${b.ip} from ${b.jail}`}
>
Unban
</Button>
),
}),
];
}
// ---------------------------------------------------------------------------
// Sub-component: Jail overview section
// ---------------------------------------------------------------------------
@@ -646,177 +548,6 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
);
}
// ---------------------------------------------------------------------------
// Sub-component: Active bans section
// ---------------------------------------------------------------------------
function ActiveBansSection(): React.JSX.Element {
const styles = useStyles();
const { bans, total, loading, error, refresh, unbanIp, unbanAll } = useActiveBans();
const [opError, setOpError] = useState<string | null>(null);
const [opSuccess, setOpSuccess] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [clearing, setClearing] = useState(false);
const handleUnban = (ip: string, jail: string): void => {
setOpError(null);
setOpSuccess(null);
unbanIp(ip, jail).catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
});
};
const handleClearAll = (): void => {
setClearing(true);
setOpError(null);
setOpSuccess(null);
unbanAll()
.then((res) => {
setOpSuccess(res.message);
setConfirmOpen(false);
})
.catch((err: unknown) => {
setOpError(err instanceof Error ? err.message : String(err));
setConfirmOpen(false);
})
.finally(() => {
setClearing(false);
});
};
const banColumns = buildBanColumns(handleUnban);
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Currently Banned IPs
{total > 0 && (
<Badge appearance="filled" color="danger" style={{ marginLeft: "8px" }}>
{String(total)}
</Badge>
)}
</Text>
<div style={{ display: "flex", gap: tokens.spacingHorizontalS }}>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={refresh}
>
Refresh
</Button>
{total > 0 && (
<Button
size="small"
appearance="outline"
icon={<DeleteRegular />}
onClick={() => {
setConfirmOpen(true);
}}
>
Clear All Bans
</Button>
)}
</div>
</div>
{/* Confirmation dialog */}
<Dialog
open={confirmOpen}
onOpenChange={(_ev, data) => {
if (!data.open) setConfirmOpen(false);
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>Clear All Bans</DialogTitle>
<DialogContent>
<Text>
This will immediately unban <strong>all {String(total)} IP
{total !== 1 ? "s" : ""}</strong> across every jail. This
action cannot be undone fail2ban will no longer block any
of those addresses until they trigger the rate-limit again.
</Text>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={() => {
setConfirmOpen(false);
}}
disabled={clearing}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleClearAll}
disabled={clearing}
icon={clearing ? <Spinner size="tiny" /> : <DeleteRegular />}
>
{clearing ? "Clearing…" : "Clear All Bans"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{opSuccess && (
<MessageBar intent="success">
<MessageBarBody>{opSuccess}</MessageBarBody>
</MessageBar>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>
</MessageBar>
)}
{loading && bans.length === 0 ? (
<div className={styles.centred}>
<Spinner label="Loading active bans…" />
</div>
) : bans.length === 0 ? (
<div className={styles.centred}>
<Text size={300}>No IPs are currently banned.</Text>
</div>
) : (
<div className={styles.tableWrapper}>
<DataGrid
items={bans}
columns={banColumns}
getRowId={(b: ActiveBan) => `${b.jail}:${b.ip}`}
focusMode="composite"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody<ActiveBan>>
{({ item }) => (
<DataGridRow<ActiveBan> key={`${item.jail}:${item.ip}`}>
{({ renderCell }) => (
<DataGridCell>{renderCell(item)}</DataGridCell>
)}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sub-component: IP Lookup section
// ---------------------------------------------------------------------------
@@ -935,8 +666,7 @@ function IpLookupSection(): React.JSX.Element {
/**
* Jails management page.
*
* Renders four sections: Jail Overview, Ban/Unban IP, Currently Banned IPs,
* and IP Lookup.
* Renders three sections: Jail Overview, Ban/Unban IP, and IP Lookup.
*/
export function JailsPage(): React.JSX.Element {
const styles = useStyles();
@@ -955,8 +685,6 @@ export function JailsPage(): React.JSX.Element {
<BanUnbanForm jailNames={jailNames} onBan={banIp} onUnban={unbanIp} />
<ActiveBansSection />
<IpLookupSection />
</div>
);

View File

@@ -22,7 +22,6 @@ import { useNavigate } from "react-router-dom";
import type { ChangeEvent, FormEvent } from "react";
import { ApiError } from "../api/client";
import { getSetupStatus, submitSetup } from "../api/setup";
import { sha256Hex } from "../utils/crypto";
// ---------------------------------------------------------------------------
// Styles
@@ -101,20 +100,36 @@ export function SetupPage(): React.JSX.Element {
const styles = useStyles();
const navigate = useNavigate();
const [checking, setChecking] = useState(true);
const [values, setValues] = useState<FormValues>(DEFAULT_VALUES);
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
const [apiError, setApiError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Redirect to /login if setup has already been completed.
// Show a full-screen spinner while the check is in flight to prevent
// the form from flashing before the redirect fires.
useEffect(() => {
let cancelled = false;
getSetupStatus()
.then((res) => {
if (res.completed) navigate("/login", { replace: true });
if (!cancelled) {
if (res.completed) {
navigate("/login", { replace: true });
} else {
setChecking(false);
}
}
})
.catch(() => {
/* ignore — stay on setup page */
// Failed check: the backend may still be starting up. Stay on this
// page so the user can attempt setup once the backend is ready.
console.warn("SetupPage: setup status check failed — rendering setup form");
if (!cancelled) setChecking(false);
});
return (): void => {
cancelled = true;
};
}, [navigate]);
// ---------------------------------------------------------------------------
@@ -161,11 +176,8 @@ export function SetupPage(): React.JSX.Element {
setSubmitting(true);
try {
// Hash the password client-side before transmission — the plaintext
// never leaves the browser. The backend bcrypt-hashes the received hash.
const hashedPassword = await sha256Hex(values.masterPassword);
await submitSetup({
master_password: hashedPassword,
master_password: values.masterPassword,
database_path: values.databasePath,
fail2ban_socket: values.fail2banSocket,
timezone: values.timezone,
@@ -187,6 +199,21 @@ export function SetupPage(): React.JSX.Element {
// Render
// ---------------------------------------------------------------------------
if (checking) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<Spinner size="large" label="Loading…" />
</div>
);
}
return (
<div className={styles.root}>
<div className={styles.card}>

View File

@@ -9,9 +9,7 @@ vi.mock("../../components/config", () => ({
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
MapTab: () => <div data-testid="map-tab">MapTab</div>,
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
}));
@@ -45,12 +43,6 @@ describe("ConfigPage", () => {
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
});
it("switches to Global tab when Global tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
});
it("switches to Server tab when Server tab is clicked", () => {
renderPage();
fireEvent.click(screen.getByRole("tab", { name: /server/i }));

View File

@@ -0,0 +1,188 @@
/**
* Tests for the "Ignore self" toggle in `JailDetailPage`.
*
* Verifies that:
* - The switch is checked when `ignoreSelf` is `true`.
* - The switch is unchecked when `ignoreSelf` is `false`.
* - Toggling the switch calls `toggleIgnoreSelf` with the correct boolean.
* - A failed toggle shows an error message bar.
*/
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 { MemoryRouter, Route, Routes } from "react-router-dom";
import { JailDetailPage } from "../JailDetailPage";
import type { Jail } from "../../types/jail";
import type { UseJailDetailResult } from "../../hooks/useJails";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
/**
* Stable mock function refs created before vi.mock() is hoisted.
* We need `mockToggleIgnoreSelf` to be a vi.fn() that tests can inspect
* and the rest to be no-ops that prevent real network calls.
*/
const {
mockToggleIgnoreSelf,
mockAddIp,
mockRemoveIp,
mockRefresh,
} = vi.hoisted(() => ({
mockToggleIgnoreSelf: vi.fn<(on: boolean) => Promise<void>>(),
mockAddIp: vi.fn<(ip: string) => Promise<void>>().mockResolvedValue(undefined),
mockRemoveIp: vi.fn<(ip: string) => Promise<void>>().mockResolvedValue(undefined),
mockRefresh: vi.fn(),
}));
// Mock the jail detail hook — tests control the returned state directly.
vi.mock("../../hooks/useJails", () => ({
useJailDetail: vi.fn(),
}));
// Mock API functions used by JailInfoSection control buttons to avoid side effects.
vi.mock("../../api/jails", () => ({
startJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
stopJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
reloadJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
setJailIdle: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
toggleIgnoreSelf: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
}));
// Stub BannedIpsSection to prevent its own fetchJailBannedIps calls.
vi.mock("../../components/jail/BannedIpsSection", () => ({
BannedIpsSection: () => <div data-testid="banned-ips-stub" />,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { useJailDetail } from "../../hooks/useJails";
/** Minimal `Jail` fixture. */
function makeJail(): Jail {
return {
name: "sshd",
running: true,
idle: false,
backend: "systemd",
log_paths: ["/var/log/auth.log"],
fail_regex: ["^Failed .+ from <HOST>"],
ignore_regex: [],
date_pattern: "",
log_encoding: "UTF-8",
actions: ["iptables-multiport"],
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: {
currently_banned: 2,
total_banned: 10,
currently_failed: 0,
total_failed: 50,
},
bantime_escalation: null,
};
}
/** Wire `useJailDetail` to return the given `ignoreSelf` value. */
function mockHook(ignoreSelf: boolean): void {
const result: UseJailDetailResult = {
jail: makeJail(),
ignoreList: ["10.0.0.0/8"],
ignoreSelf,
loading: false,
error: null,
refresh: mockRefresh,
addIp: mockAddIp,
removeIp: mockRemoveIp,
toggleIgnoreSelf: mockToggleIgnoreSelf,
};
vi.mocked(useJailDetail).mockReturnValue(result);
}
/** Render the JailDetailPage with a fake `/jails/sshd` route. */
function renderPage() {
return render(
<MemoryRouter initialEntries={["/jails/sshd"]}>
<FluentProvider theme={webLightTheme}>
<Routes>
<Route path="/jails/:name" element={<JailDetailPage />} />
</Routes>
</FluentProvider>
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("JailDetailPage — ignore self toggle", () => {
beforeEach(() => {
vi.clearAllMocks();
mockToggleIgnoreSelf.mockResolvedValue(undefined);
});
it("renders the switch checked when ignoreSelf is true", async () => {
mockHook(true);
renderPage();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
expect(switchEl).toBeChecked();
});
it("renders the switch unchecked when ignoreSelf is false", async () => {
mockHook(false);
renderPage();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
expect(switchEl).not.toBeChecked();
});
it("calls toggleIgnoreSelf(false) when switch is toggled off", async () => {
mockHook(true);
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce();
expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(false);
});
});
it("calls toggleIgnoreSelf(true) when switch is toggled on", async () => {
mockHook(false);
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce();
expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(true);
});
});
it("shows an error message bar when toggleIgnoreSelf rejects", async () => {
mockHook(false);
mockToggleIgnoreSelf.mockRejectedValue(new Error("Connection refused"));
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(screen.getByText(/Connection refused/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { SetupPage } from "../SetupPage";
// Mock the setup API so tests never hit a real network.
vi.mock("../../api/setup", () => ({
getSetupStatus: vi.fn(),
submitSetup: vi.fn(),
}));
import { getSetupStatus } from "../../api/setup";
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
function renderPage() {
return render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/setup"]}>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route
path="/login"
element={<div data-testid="login-page">Login</div>}
/>
</Routes>
</MemoryRouter>
</FluentProvider>,
);
}
describe("SetupPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a full-screen spinner while the setup status check is in flight", () => {
// getSetupStatus never resolves — spinner should be visible immediately.
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
renderPage();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
// Form should NOT be visible yet.
expect(
screen.queryByRole("heading", { name: /bangui setup/i }),
).not.toBeInTheDocument();
});
it("renders the setup form once the status check resolves (not complete)", async () => {
// Task 0.4: form must not flash before the check resolves.
mockedGetSetupStatus.mockResolvedValue({ completed: false });
renderPage();
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /bangui setup/i }),
).toBeInTheDocument();
});
// Spinner should be gone.
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
it("redirects to /login when setup is already complete", async () => {
mockedGetSetupStatus.mockResolvedValue({ completed: true });
renderPage();
await waitFor(() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
});
});
it("renders the form and logs a warning when the status check fails", async () => {
// Task 0.4: catch block must log a warning and keep the form visible.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockedGetSetupStatus.mockRejectedValue(new Error("Connection refused"));
renderPage();
await waitFor(() => {
expect(
screen.getByRole("heading", { name: /bangui setup/i }),
).toBeInTheDocument();
});
expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore();
});
});

View File

@@ -524,6 +524,11 @@ export interface InactiveJail {
source_file: string;
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
enabled: boolean;
/**
* True when a ``jail.d/{name}.local`` override file exists for this jail.
* Indicates that a "Deactivate Jail" cleanup action is available.
*/
has_local_override: boolean;
}
export interface InactiveJailListResponse {
@@ -549,6 +554,45 @@ export interface JailActivationResponse {
active: boolean;
/** Human-readable result message. */
message: string;
/** Whether fail2ban was still running after the reload. Defaults to true. */
fail2ban_running: boolean;
/** Non-fatal pre-activation validation warnings (e.g. missing log path). */
validation_warnings: string[];
/**
* Set when activation failed after the config file was already written.
* `true` = the system rolled back and recovered automatically.
* `false` = rollback also failed — manual intervention required.
* `undefined` = activation succeeded or failed before the file was written.
*/
recovered?: boolean;
}
// ---------------------------------------------------------------------------
// Jail config recovery models (Task 3)
// ---------------------------------------------------------------------------
/** A single validation issue found in a jail's config. */
export interface JailValidationIssue {
/** Config field that has the issue, e.g. "filter", "failregex". */
field: string;
/** Human-readable description of the issue. */
message: string;
}
/** Full result of pre-activation validation for a single jail. */
export interface JailValidationResult {
jail_name: string;
valid: boolean;
issues: JailValidationIssue[];
}
/** Response from `POST /api/config/jails/{name}/rollback`. */
export interface RollbackResponse {
jail_name: string;
disabled: boolean;
fail2ban_running: boolean;
active_jails: number;
message: string;
}
// ---------------------------------------------------------------------------
@@ -592,3 +636,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;
}

View File

@@ -198,6 +198,26 @@ export interface UnbanAllResponse {
count: number;
}
// ---------------------------------------------------------------------------
// Jail-specific paginated bans
// ---------------------------------------------------------------------------
/**
* Paginated response from `GET /api/jails/{name}/banned`.
*
* Mirrors `JailBannedIpsResponse` from `backend/app/models/ban.py`.
*/
export interface JailBannedIpsResponse {
/** Active ban entries for the current page. */
items: ActiveBan[];
/** Total matching entries (after applying any search filter). */
total: number;
/** Current page number (1-based). */
page: number;
/** Number of items per page. */
page_size: number;
}
export interface GeoDetail {
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
country_code: string | null;

View File

@@ -30,7 +30,12 @@ import { tokens } from "@fluentui/react-components";
export function resolveFluentToken(tokenValue: string): string {
const match = /var\((--[^,)]+)/.exec(tokenValue);
if (match == null || match[1] == null) return tokenValue;
const resolved = getComputedStyle(document.documentElement)
// FluentProvider injects CSS custom properties on its own wrapper <div>,
// not on :root. Query that element so we resolve actual colour values.
const el =
document.querySelector(".fui-FluentProvider") ?? document.documentElement;
const resolved = getComputedStyle(el)
.getPropertyValue(match[1])
.trim();
return resolved !== "" ? resolved : tokenValue;

View File

@@ -1,23 +0,0 @@
/**
* Client-side cryptography utilities.
*
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
*/
/**
* Return the SHA-256 hex digest of `input`.
*
* Hashing passwords before transmission means the plaintext never leaves the
* browser, even when HTTPS is not enforced in a development environment.
* The backend then applies bcrypt on top of the received hash.
*
* @param input - The string to hash (e.g. the master password).
* @returns Lowercase hex-encoded SHA-256 digest.
*/
export async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

View File

@@ -7,3 +7,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** BanGUI application version — injected at build time via Vite define. */
declare const __APP_VERSION__: string;

View File

@@ -1,10 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
import { readFileSync } from "node:fs";
const pkg = JSON.parse(
readFileSync(resolve(__dirname, "package.json"), "utf-8"),
) as { version: string };
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
define: {
/** BanGUI application version injected at build time from package.json. */
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),

View File

@@ -4,6 +4,10 @@ import { resolve } from "path";
export default defineConfig({
plugins: [react()],
define: {
/** Stub app version for tests — mirrors the vite.config.ts define. */
__APP_VERSION__: JSON.stringify("0.0.0-test"),
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),