Compare commits

..

15 Commits

Author SHA1 Message Date
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
49 changed files with 1904 additions and 593 deletions

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

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() {

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

@@ -5,10 +5,10 @@
# 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

75
Docker/release.sh Normal file
View File

@@ -0,0 +1,75 @@
#!/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}"
# ---------------------------------------------------------------------------
# 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

@@ -4,136 +4,56 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Agent Operating Instructions
## Open Issues
These instructions apply to every AI agent working in this repository. Read them fully before touching any file.
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done
### Before You Begin
**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`.
1. Read [Instructions.md](Instructions.md) in full — it defines the project context, coding standards, and workflow rules. Every rule there is authoritative and takes precedence over any assumption you make.
2. Read [Architekture.md](Architekture.md) to understand the system structure before touching any component.
3. Read the development guide relevant to your task: [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) (or both).
4. Read [Features.md](Features.md) so you understand what the product is supposed to do and do not accidentally break intended behaviour.
**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.
### How to Work Through This Document
**Goal:** Make the distinction clear and expose the BanGUI application version.
- Tasks are grouped by feature area. Each group is self-contained.
- Work through tasks in the order they appear within a group; earlier tasks establish foundations for later ones.
- Mark a task **in-progress** before you start it and **completed** the moment it is done. Never batch completions.
- If a task depends on another task that is not yet complete, stop and complete the dependency first.
- If you are uncertain whether a change is correct, read the relevant documentation section again before proceeding. Do not guess.
**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.
### Code Quality Rules (Summary)
- No TODOs, no placeholders, no half-finished functions.
- Full type annotations on every function (Python) and full TypeScript types on every symbol (no `any`).
- Layered architecture: routers → services → repositories. No layer may skip another.
- All backend errors are raised as typed HTTP exceptions; all unexpected errors are logged via structlog before re-raising.
- All frontend state lives in typed hooks; no raw `fetch` calls outside of the `api/` layer.
- After every code change, run the full test suite (`make test`) and ensure it is green.
### Definition of Done
A task is done when:
- The code compiles and the test suite passes (`make test`).
- The feature works end-to-end in the dev stack (`make up`).
- No new lint errors are introduced.
- The change is consistent with all documentation rules.
**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`.
---
## Bug Fixes
### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done
**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:** 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.
**Goal:** Replace the tooltip with accurate, self-explanatory wording.
**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.
**Files:** `frontend/src/components/ServerStatusBar.tsx`.
---
### BUG-001 — fail2ban: `bangui-sim` jail fails to start due to missing `banaction`
### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done
**Status:** Done
**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.
**Summary:** `jail.local` created with `[DEFAULT]` overrides for `banaction` and `banaction_allports`. The container init script (`init-fail2ban-config`) overwrites `jail.conf` from the image's `/defaults/` on every start, so modifying `jail.conf` directly is ineffective. `jail.local` is not in the container's defaults and thus persists correctly. Additionally fixed a `TypeError` in `config_file_service.py` where `except jail_service.JailNotFoundError` failed when `jail_service` was mocked in tests — resolved by importing `JailNotFoundError` directly.
**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.
#### Error
**Goal:** Move the `<ServerHealthSection />` block to the **top** of the `ServerTab` render output, before any settings fields.
```
Failed during configuration: Bad value substitution: option 'action' in section 'bangui-sim'
contains an interpolation key 'banaction' which is not a valid option name.
Raw value: '%(action_)s'
```
**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.
#### Root Cause
fail2ban's interpolation system resolves option values at configuration load time by
substituting `%(key)s` placeholders with values from the same section or from `[DEFAULT]`.
The chain that fails is:
1. Every jail inherits `action = %(action_)s` from `[DEFAULT]` (no override in `bangui-sim.conf`).
2. `action_` is defined in `[DEFAULT]` as `%(banaction)s[port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]`.
3. `banaction` is **commented out** in `[DEFAULT]`:
```ini
# Docker/fail2ban-dev-config/fail2ban/jail.conf [DEFAULT]
#banaction = iptables-multiport ← this line is disabled
```
4. Because `banaction` is absent from the interpolation namespace, fail2ban cannot resolve
`action_`, which makes it unable to resolve `action`, and the jail fails to load.
The same root cause affects every jail in `jail.d/` that does not define its own `banaction`,
including `blocklist-import.conf`.
#### Fix
**File:** `Docker/fail2ban-dev-config/fail2ban/jail.conf`
Uncomment the `banaction` line inside the `[DEFAULT]` section so the value is globally
available to all jails:
```ini
banaction = iptables-multiport
banaction_allports = iptables-allports
```
This is safe: the dev compose (`Docker/compose.debug.yml`) already grants the fail2ban
container `NET_ADMIN` and `NET_RAW` capabilities, which are the prerequisites for
iptables-based banning.
#### Tasks
- [x] **BUG-001-T1 — Add `banaction` override via `jail.local` [DEFAULT]**
Open `Docker/fail2ban-dev-config/fail2ban/jail.conf`.
Find the two commented-out lines near the `action_` definition:
```ini
#banaction = iptables-multiport
#banaction_allports = iptables-allports
```
Remove the leading `#` from both lines so they become active options.
Do not change any other part of the file.
- [x] **BUG-001-T2 — Restart the fail2ban container and verify clean startup**
Bring the dev stack down and back up:
```bash
make down && make up
```
Wait for the fail2ban container to reach `healthy`, then inspect its logs:
```bash
make logs # or: docker logs bangui-fail2ban-dev 2>&1 | grep -i error
```
Confirm that no `Bad value substitution` or `Failed during configuration` lines appear
and that both `bangui-sim` and `blocklist-import` jails show as **enabled** in the output.
- [x] **BUG-001-T3 — Verify ban/unban cycle works end-to-end**
With the stack running, trigger the simulation script:
```bash
bash Docker/simulate_failed_logins.sh
```
Then confirm fail2ban has recorded a ban:
```bash
bash Docker/check_ban_status.sh
```
The script should report at least one banned IP in the `bangui-sim` jail.
Also verify that the BanGUI dashboard reflects the new ban entry.
**Files:** `frontend/src/components/config/ServerTab.tsx`.
---

View File

@@ -85,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() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars
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

@@ -807,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):

View File

@@ -40,9 +40,12 @@ 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,
@@ -97,6 +100,7 @@ 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
@@ -357,11 +361,17 @@ 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
@@ -381,24 +391,57 @@ async def restart_fail2ban(
) -> None:
"""Trigger a full fail2ban service restart.
The fail2ban daemon is completely stopped and then started again,
re-reading all configuration files in the process.
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: 502 when fail2ban is unreachable.
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:
# Perform restart by sending the restart command via the fail2ban socket.
# If fail2ban is not running, this will raise an exception, and we return 502.
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)
@@ -755,6 +798,60 @@ async def deactivate_jail(
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)
# ---------------------------------------------------------------------------

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

@@ -429,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.
@@ -436,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`.
@@ -513,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
),
)
@@ -740,7 +748,7 @@ async def _probe_fail2ban_running(socket_path: str) -> bool:
return False
async def _wait_for_fail2ban(
async def wait_for_fail2ban(
socket_path: str,
max_wait_seconds: float = 10.0,
poll_interval: float = 2.0,
@@ -764,7 +772,7 @@ async def _wait_for_fail2ban(
return False
async def _start_daemon(start_cmd_parts: list[str]) -> bool:
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)
@@ -1111,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",
@@ -1469,6 +1477,57 @@ 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,
@@ -1541,11 +1600,11 @@ async def rollback_jail(
log.info("jail_rolled_back_disabled", jail=name)
# Attempt to start the daemon.
started = await _start_daemon(start_cmd_parts)
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(
fail2ban_running = await wait_for_fail2ban(
socket_path, max_wait_seconds=10.0, poll_interval=2.0
)

View File

@@ -685,24 +685,29 @@ async def reload_all(
async def restart(socket_path: str) -> None:
"""Restart the fail2ban service (daemon).
"""Stop the fail2ban daemon via the Unix socket.
Sends the 'restart' command to the fail2ban daemon via the Unix socket.
All jails are stopped and the daemon is restarted, re-reading all
configuration from scratch.
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 operation failed.
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(["restart"]))
log.info("fail2ban_restarted")
_ok(await client.send(["stop"]))
log.info("fail2ban_stopped_for_restart")
except ValueError as exc:
raise JailOperationError(str(exc)) from exc

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

@@ -370,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

View File

@@ -377,6 +377,102 @@ class TestCreateActionFile:
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
# ---------------------------------------------------------------------------
# POST /api/config/jail-files
# ---------------------------------------------------------------------------

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

View File

@@ -21,6 +21,7 @@ from app.services.config_file_service import (
activate_jail,
deactivate_jail,
list_inactive_jails,
rollback_jail,
)
# ---------------------------------------------------------------------------
@@ -289,6 +290,28 @@ class TestBuildInactiveJail:
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
assert jail.enabled is True
def test_has_local_override_absent(self, tmp_path: Path) -> None:
"""has_local_override is False when no .local file exists."""
jail = _build_inactive_jail(
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
)
assert jail.has_local_override is False
def test_has_local_override_present(self, tmp_path: Path) -> None:
"""has_local_override is True when jail.d/{name}.local exists."""
local = tmp_path / "jail.d" / "sshd.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[sshd]\nenabled = false\n")
jail = _build_inactive_jail(
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
)
assert jail.has_local_override is True
def test_has_local_override_no_config_dir(self) -> None:
"""has_local_override is False when config_dir is not provided."""
jail = _build_inactive_jail("sshd", {}, "/etc/fail2ban/jail.conf")
assert jail.has_local_override is False
# ---------------------------------------------------------------------------
# _write_local_override_sync
@@ -424,6 +447,121 @@ class TestListInactiveJails:
assert "sshd" in names
assert "apache-auth" in names
async def test_has_local_override_true_when_local_file_exists(
self, tmp_path: Path
) -> None:
"""has_local_override is True for a jail whose jail.d .local file exists."""
_write(tmp_path / "jail.conf", JAIL_CONF)
local = tmp_path / "jail.d" / "apache-auth.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[apache-auth]\nenabled = false\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
jail = next(j for j in result.jails if j.name == "apache-auth")
assert jail.has_local_override is True
async def test_has_local_override_false_when_no_local_file(
self, tmp_path: Path
) -> None:
"""has_local_override is False when no jail.d .local file exists."""
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
jail = next(j for j in result.jails if j.name == "apache-auth")
assert jail.has_local_override is False
# ---------------------------------------------------------------------------
# delete_jail_local_override
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDeleteJailLocalOverride:
"""Tests for :func:`~app.services.config_file_service.delete_jail_local_override`."""
async def test_deletes_local_file(self, tmp_path: Path) -> None:
"""delete_jail_local_override removes the jail.d/.local file."""
from app.services.config_file_service import delete_jail_local_override
_write(tmp_path / "jail.conf", JAIL_CONF)
local = tmp_path / "jail.d" / "apache-auth.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[apache-auth]\nenabled = false\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
assert not local.exists()
async def test_no_error_when_local_file_missing(self, tmp_path: Path) -> None:
"""delete_jail_local_override succeeds silently when no .local file exists."""
from app.services.config_file_service import delete_jail_local_override
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
# Must not raise even though there is no .local file.
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
"""delete_jail_local_override raises JailNotFoundInConfigError for unknown jail."""
from app.services.config_file_service import (
JailNotFoundInConfigError,
delete_jail_local_override,
)
_write(tmp_path / "jail.conf", JAIL_CONF)
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
pytest.raises(JailNotFoundInConfigError),
):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "nonexistent")
async def test_raises_jail_already_active(self, tmp_path: Path) -> None:
"""delete_jail_local_override raises JailAlreadyActiveError when jail is running."""
from app.services.config_file_service import (
JailAlreadyActiveError,
delete_jail_local_override,
)
_write(tmp_path / "jail.conf", JAIL_CONF)
local = tmp_path / "jail.d" / "sshd.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[sshd]\nenabled = false\n")
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
pytest.raises(JailAlreadyActiveError),
):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "sshd")
async def test_raises_jail_name_error(self, tmp_path: Path) -> None:
"""delete_jail_local_override raises JailNameError for invalid jail names."""
from app.services.config_file_service import (
JailNameError,
delete_jail_local_override,
)
with pytest.raises(JailNameError):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "../evil")
# ---------------------------------------------------------------------------
# activate_jail
@@ -3173,5 +3311,209 @@ class TestActivateJailRollback:
# Verify the error message mentions logpath issues.
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
async def test_activate_jail_rollback_deletes_file_when_no_prior_local(
self, tmp_path: Path
) -> None:
"""Rollback deletes the .local file when none existed before activation.
When a jail had no .local override before activation, activate_jail
creates one with enabled = true. If reload then crashes, rollback must
delete that file (leaving the jail in the same state as before the
activation attempt).
Expects:
- The .local file is absent after rollback.
- The response indicates recovered=True.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
local_path = tmp_path / "jail.d" / "apache-auth.local"
# No .local file exists before activation.
assert not local_path.exists()
req = ActivateJailRequest()
reload_call_count = 0
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
nonlocal reload_call_count
reload_call_count += 1
if reload_call_count == 1:
raise RuntimeError("fail2ban crashed")
# Recovery reload succeeds.
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
patch("app.services.config_file_service.jail_service") as mock_js,
patch(
"app.services.config_file_service._probe_fail2ban_running",
new=AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is True
assert not local_path.exists()
# ---------------------------------------------------------------------------
# rollback_jail
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestRollbackJail:
"""Integration tests for :func:`~app.services.config_file_service.rollback_jail`."""
async def test_local_file_written_enabled_false(self, tmp_path: Path) -> None:
"""rollback_jail writes enabled=false to jail.d/{name}.local before any socket call."""
(tmp_path / "jail.d").mkdir()
with (
patch(
"app.services.config_file_service.start_daemon",
AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._get_active_jail_names",
AsyncMock(return_value={"sshd"}),
),
):
await rollback_jail(str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"])
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file(), "jail.d/sshd.local must be written"
content = local.read_text()
assert "enabled = false" in content
async def test_start_command_invoked_via_subprocess(self, tmp_path: Path) -> None:
"""rollback_jail invokes the daemon start command via start_daemon, not via socket."""
mock_start = AsyncMock(return_value=True)
with (
patch("app.services.config_file_service.start_daemon", mock_start),
patch(
"app.services.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._get_active_jail_names",
AsyncMock(return_value={"other"}),
),
):
await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
mock_start.assert_awaited_once_with(["fail2ban-client", "start"])
async def test_fail2ban_running_reflects_socket_probe_not_subprocess_exit(
self, tmp_path: Path
) -> None:
"""fail2ban_running in the response reflects the socket probe result.
Even when start_daemon returns True (subprocess exit 0), if the socket
probe returns False the response must report fail2ban_running=False.
"""
with (
patch(
"app.services.config_file_service.start_daemon",
AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=False), # socket still unresponsive
),
):
result = await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
assert result.fail2ban_running is False
async def test_active_jails_zero_when_fail2ban_not_running(
self, tmp_path: Path
) -> None:
"""active_jails is 0 in the response when fail2ban_running is False."""
with (
patch(
"app.services.config_file_service.start_daemon",
AsyncMock(return_value=False),
),
patch(
"app.services.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=False),
),
):
result = await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
assert result.active_jails == 0
async def test_active_jails_count_from_socket_when_running(
self, tmp_path: Path
) -> None:
"""active_jails reflects the actual jail count from the socket when fail2ban is up."""
with (
patch(
"app.services.config_file_service.start_daemon",
AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=True),
),
patch(
"app.services.config_file_service._get_active_jail_names",
AsyncMock(return_value={"sshd", "nginx", "apache-auth"}),
),
):
result = await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
assert result.active_jails == 3
async def test_fail2ban_down_at_start_still_succeeds_file_write(
self, tmp_path: Path
) -> None:
"""rollback_jail writes the local file even when fail2ban is down at call time."""
# fail2ban is down: start_daemon fails and wait_for_fail2ban returns False.
with (
patch(
"app.services.config_file_service.start_daemon",
AsyncMock(return_value=False),
),
patch(
"app.services.config_file_service.wait_for_fail2ban",
AsyncMock(return_value=False),
),
):
result = await rollback_jail(
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
)
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file(), "local file must be written even when fail2ban is down"
assert result.disabled is True
assert result.fail2ban_running is False

View File

@@ -441,6 +441,33 @@ class TestJailControls:
)
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):

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.3",
"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

@@ -39,10 +39,8 @@ import type {
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
PendingRecovery,
RegexTestRequest,
RegexTestResponse,
RollbackResponse,
ServerSettingsResponse,
ServerSettingsUpdate,
JailFileConfig,
@@ -265,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(
@@ -552,6 +550,18 @@ export async function deactivateJail(
);
}
/**
* 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)
// ---------------------------------------------------------------------------
@@ -593,21 +603,3 @@ export async function validateJailConfig(
): Promise<JailValidationResult> {
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
}
/**
* Fetch the pending crash-recovery record, if any.
*
* Returns null when fail2ban is healthy and no recovery is pending.
*/
export async function fetchPendingRecovery(): Promise<PendingRecovery | null> {
return get<PendingRecovery | null>(ENDPOINTS.configPendingRecovery);
}
/**
* Rollback a bad jail — disables it and attempts to restart fail2ban.
*
* @param name - Name of the jail to disable.
*/
export async function rollbackJail(name: string): Promise<RollbackResponse> {
return post<RollbackResponse>(ENDPOINTS.configJailRollback(name), undefined);
}

View File

@@ -71,11 +71,10 @@ 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`,
configJailRollback: (name: string): string =>
`/config/jails/${encodeURIComponent(name)}/rollback`,
configPendingRecovery: "/config/pending-recovery" as string,
configGlobal: "/config/global",
configReload: "/config/reload",
configRestart: "/config/restart",
@@ -105,6 +104,7 @@ 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`,

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

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

@@ -1,136 +0,0 @@
/**
* RecoveryBanner — full-width warning shown when fail2ban stopped responding
* shortly after a jail was activated (indicating the new jail config may be
* invalid).
*
* Polls ``GET /api/config/pending-recovery`` every 10 seconds and renders a
* dismissible ``MessageBar`` when an unresolved crash record is present.
* The "Disable & Restart" button calls the rollback endpoint to disable the
* offending jail and attempt to restart fail2ban.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
MessageBar,
MessageBarActions,
MessageBarBody,
MessageBarTitle,
Spinner,
tokens,
} from "@fluentui/react-components";
import { useNavigate } from "react-router-dom";
import { fetchPendingRecovery, rollbackJail } from "../../api/config";
import type { PendingRecovery } from "../../types/config";
const POLL_INTERVAL_MS = 10_000;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Recovery banner that polls for pending crash-recovery records.
*
* Mount this once at the layout level so it is visible across all pages
* while a recovery is pending.
*
* @returns A MessageBar element, or null when nothing is pending.
*/
export function RecoveryBanner(): React.JSX.Element | null {
const navigate = useNavigate();
const [pending, setPending] = useState<PendingRecovery | null>(null);
const [rolling, setRolling] = useState(false);
const [rollbackError, setRollbackError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const poll = useCallback((): void => {
fetchPendingRecovery()
.then((record) => {
// Hide the banner once fail2ban has recovered on its own.
if (record?.recovered) {
setPending(null);
} else {
setPending(record);
}
})
.catch(() => { /* ignore network errors — will retry */ });
}, []);
// Start polling on mount.
useEffect(() => {
poll();
timerRef.current = setInterval(poll, POLL_INTERVAL_MS);
return (): void => {
if (timerRef.current !== null) clearInterval(timerRef.current);
};
}, [poll]);
const handleRollback = useCallback((): void => {
if (!pending || rolling) return;
setRolling(true);
setRollbackError(null);
rollbackJail(pending.jail_name)
.then(() => {
setPending(null);
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setRollbackError(msg);
})
.finally(() => {
setRolling(false);
});
}, [pending, rolling]);
const handleViewDetails = useCallback((): void => {
navigate("/config");
}, [navigate]);
if (pending === null) return null;
return (
<div
style={{
flexShrink: 0,
paddingLeft: tokens.spacingHorizontalM,
paddingRight: tokens.spacingHorizontalM,
paddingTop: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalXS,
}}
role="alert"
>
<MessageBar intent="error">
<MessageBarBody>
<MessageBarTitle>fail2ban Stopped After Jail Activation</MessageBarTitle>
fail2ban stopped responding after activating jail{" "}
<strong>{pending.jail_name}</strong>. The jail&apos;s configuration
may be invalid.
{rollbackError && (
<div style={{ marginTop: tokens.spacingVerticalXS, color: tokens.colorStatusDangerForeground1 }}>
Rollback failed: {rollbackError}
</div>
)}
</MessageBarBody>
<MessageBarActions>
<Button
appearance="primary"
size="small"
icon={rolling ? <Spinner size="tiny" /> : undefined}
disabled={rolling}
onClick={handleRollback}
>
{rolling ? "Disabling…" : "Disable & Restart"}
</Button>
<Button
appearance="secondary"
size="small"
onClick={handleViewDetails}
>
View Logs
</Button>
</MessageBarActions>
</MessageBar>
</div>
);
}

View File

@@ -1,141 +0,0 @@
/**
* Tests for RecoveryBanner (Task 3).
*/
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 } from "react-router-dom";
import { RecoveryBanner } from "../RecoveryBanner";
import type { PendingRecovery } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchPendingRecovery: vi.fn(),
rollbackJail: vi.fn(),
}));
import { fetchPendingRecovery, rollbackJail } from "../../../api/config";
const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery);
const mockRollbackJail = vi.mocked(rollbackJail);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const pendingRecord: PendingRecovery = {
jail_name: "sshd",
activated_at: "2024-01-01T12:00:00Z",
detected_at: "2024-01-01T12:00:30Z",
recovered: false,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderBanner() {
return render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter>
<RecoveryBanner />
</MemoryRouter>
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("RecoveryBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders nothing when pending recovery is null", async () => {
mockFetchPendingRecovery.mockResolvedValue(null);
renderBanner();
await waitFor(() => {
expect(mockFetchPendingRecovery).toHaveBeenCalled();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("renders warning when there is an unresolved pending recovery", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
renderBanner();
await waitFor(() => {
expect(screen.getByText(/fail2ban stopped responding after activating jail/i)).toBeInTheDocument();
});
expect(screen.getByText(/sshd/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /view logs/i })).toBeInTheDocument();
});
it("hides the banner when recovery is marked as recovered", async () => {
const recoveredRecord: PendingRecovery = { ...pendingRecord, recovered: true };
mockFetchPendingRecovery.mockResolvedValue(recoveredRecord);
renderBanner();
await waitFor(() => {
expect(mockFetchPendingRecovery).toHaveBeenCalled();
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("calls rollbackJail and hides banner on successful rollback", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
mockRollbackJail.mockResolvedValue({
jail_name: "sshd",
disabled: true,
fail2ban_running: true,
active_jails: 0,
message: "Rolled back.",
});
renderBanner();
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
});
await userEvent.click(
screen.getByRole("button", { name: /disable & restart/i }),
);
expect(mockRollbackJail).toHaveBeenCalledWith("sshd");
});
it("shows rollback error when rollbackJail fails", async () => {
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
mockRollbackJail.mockRejectedValue(new Error("Connection refused"));
renderBanner();
await waitFor(() => {
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
});
await userEvent.click(
screen.getByRole("button", { name: /disable & restart/i }),
);
await waitFor(() => {
expect(screen.getByText(/rollback failed/i)).toBeInTheDocument();
});
});
});

View File

@@ -5,12 +5,8 @@
* findtime, maxretry, port and logpath. Calls the activate endpoint on
* confirmation and propagates the result via callbacks.
*
* Task 3 additions:
* - Runs pre-activation validation when the dialog opens and displays any
* warnings or blocking errors before the user confirms.
* - Extended spinner text during the post-reload probe phase.
* - Calls `onCrashDetected` when the activation response signals that
* fail2ban stopped responding after the reload.
* Runs pre-activation validation when the dialog opens and displays any
* warnings or blocking errors before the user confirms.
*/
import { useEffect, useState } from "react";
@@ -52,11 +48,6 @@ export interface ActivateJailDialogProps {
onClose: () => void;
/** Called after the jail has been successfully activated. */
onActivated: () => void;
/**
* Called when fail2ban stopped responding after the jail was activated.
* The recovery banner will surface this to the user.
*/
onCrashDetected?: () => void;
}
// ---------------------------------------------------------------------------
@@ -77,7 +68,6 @@ export function ActivateJailDialog({
open,
onClose,
onActivated,
onCrashDetected,
}: ActivateJailDialogProps): React.JSX.Element {
const [bantime, setBantime] = useState("");
const [findtime, setFindtime] = useState("");
@@ -173,9 +163,6 @@ export function ActivateJailDialog({
setValidationWarnings(result.validation_warnings);
}
resetForm();
if (!result.fail2ban_running) {
onCrashDetected?.();
}
onActivated();
})
.catch((err: unknown) => {
@@ -339,9 +326,10 @@ export function ActivateJailDialog({
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed System Recovered</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed. The server
has been automatically recovered.
<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>
)}
@@ -351,10 +339,12 @@ export function ActivateJailDialog({
style={{ marginTop: tokens.spacingVerticalS }}
>
<MessageBarBody>
<MessageBarTitle>Activation Failed Manual Intervention Required</MessageBarTitle>
Activation of jail &ldquo;{jail.name}&rdquo; failed and
automatic recovery was unsuccessful. Manual intervention is
required.
<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>
)}

View File

@@ -35,6 +35,7 @@ import { ApiError } from "../../api/client";
import {
addLogPath,
deactivateJail,
deleteJailLocalOverride,
deleteLogPath,
fetchInactiveJails,
fetchJailConfigFileContent,
@@ -573,7 +574,7 @@ function JailConfigDetail({
</div>
)}
{readOnly && (onActivate !== undefined || onValidate !== undefined) && (
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
{onValidate !== undefined && (
<Button
@@ -585,6 +586,15 @@ function JailConfigDetail({
{validating ? "Validating…" : "Validate Config"}
</Button>
)}
{onDeactivate !== undefined && (
<Button
appearance="secondary"
icon={<LockOpen24Regular />}
onClick={onDeactivate}
>
Deactivate Jail
</Button>
)}
{onActivate !== undefined && (
<Button
appearance="primary"
@@ -618,8 +628,8 @@ function JailConfigDetail({
interface InactiveJailDetailProps {
jail: InactiveJail;
onActivate: () => void;
/** Whether to show and call onCrashDetected on activation crash. */
onCrashDetected?: () => void;
/** Called when the user requests removal of the .local override file. */
onDeactivate?: () => void;
}
/**
@@ -636,6 +646,7 @@ interface InactiveJailDetailProps {
function InactiveJailDetail({
jail,
onActivate,
onDeactivate,
}: InactiveJailDetailProps): React.JSX.Element {
const styles = useConfigStyles();
const [validating, setValidating] = useState(false);
@@ -729,6 +740,7 @@ function InactiveJailDetail({
onSave={async () => { /* read-only — never called */ }}
readOnly
onActivate={onActivate}
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
onValidate={handleValidate}
validating={validating}
/>
@@ -746,12 +758,7 @@ function InactiveJailDetail({
*
* @returns JSX element.
*/
export interface JailsTabProps {
/** Called when fail2ban stopped responding after a jail was activated. */
onCrashDetected?: () => void;
}
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
export function JailsTab(): React.JSX.Element {
const styles = useConfigStyles();
const { jails, loading, error, refresh, updateJail } =
useJailConfigs();
@@ -786,6 +793,15 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
.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);
@@ -882,15 +898,21 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
>
{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); }}
onCrashDetected={onCrashDetected}
onDeactivate={
selectedInactiveJail.has_local_override
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
: undefined
}
/>
) : null}
</ConfigListDetail>
@@ -901,7 +923,6 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
open={activateTarget !== null}
onClose={() => { setActivateTarget(null); }}
onActivated={handleActivated}
onCrashDetected={onCrashDetected}
/>
<CreateJailDialog

View File

@@ -219,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
@@ -412,8 +416,6 @@ export function ServerTab(): React.JSX.Element {
</div>
) : null}
{/* Service Health & Log Viewer section */}
<ServerHealthSection />
</div>
);
}

View File

@@ -6,7 +6,6 @@
* - "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.
* - `onCrashDetected` is called when fail2ban_running is false after activation.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -55,6 +54,7 @@ const baseJail: InactiveJail = {
bantime_escalation: null,
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
enabled: false,
has_local_override: false,
};
/** Successful activation response. */
@@ -98,7 +98,6 @@ interface DialogProps {
open?: boolean;
onClose?: () => void;
onActivated?: () => void;
onCrashDetected?: () => void;
}
function renderDialog({
@@ -106,7 +105,6 @@ function renderDialog({
open = true,
onClose = vi.fn(),
onActivated = vi.fn(),
onCrashDetected = vi.fn(),
}: DialogProps = {}) {
return render(
<FluentProvider theme={webLightTheme}>
@@ -115,7 +113,6 @@ function renderDialog({
open={open}
onClose={onClose}
onActivated={onActivated}
onCrashDetected={onCrashDetected}
/>
</FluentProvider>,
);
@@ -202,28 +199,4 @@ describe("ActivateJailDialog", () => {
expect(onActivated).toHaveBeenCalledOnce();
});
});
it("calls onCrashDetected when fail2ban_running is false after activation", async () => {
mockValidateJailConfig.mockResolvedValue(validationPassed);
mockActivateJail.mockResolvedValue({
...successResponse,
fail2ban_running: false,
});
const onActivated = vi.fn();
const onCrashDetected = vi.fn();
renderDialog({ onActivated, onCrashDetected });
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(onCrashDetected).toHaveBeenCalledOnce();
});
expect(onActivated).toHaveBeenCalledOnce();
});
});

View File

@@ -33,7 +33,6 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../providers/AuthProvider";
import { useServerStatus } from "../hooks/useServerStatus";
import { useBlocklistStatus } from "../hooks/useBlocklist";
import { RecoveryBanner } from "../components/common/RecoveryBanner";
// ---------------------------------------------------------------------------
// Styles
@@ -146,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: {
@@ -302,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"
@@ -336,8 +350,6 @@ export function MainLayout(): React.JSX.Element {
</MessageBar>
</div>
)}
{/* Recovery banner — shown when fail2ban crashed after a jail activation */}
<RecoveryBanner />
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
{blocklistHasErrors && (
<div className={styles.warningBar} role="alert">

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

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

@@ -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 {
@@ -581,20 +586,6 @@ export interface JailValidationResult {
issues: JailValidationIssue[];
}
/**
* Recorded when fail2ban stops responding shortly after a jail activation.
* Surfaced by `GET /api/config/pending-recovery`.
*/
export interface PendingRecovery {
jail_name: string;
/** ISO-8601 datetime string. */
activated_at: string;
/** ISO-8601 datetime string. */
detected_at: string;
/** True once fail2ban comes back online after the crash. */
recovered: boolean;
}
/** Response from `POST /api/config/jails/{name}/rollback`. */
export interface RollbackResponse {
jail_name: string;

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"),