Compare commits

..

10 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
33 changed files with 1061 additions and 193 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,142 +4,56 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Bug Fix: "Raw Action Configuration" always empty — DONE
## Open Issues
**Summary:** Renamed `GET /actions/{name}` and `PUT /actions/{name}` in `file_config.py` to `GET /actions/{name}/raw` and `PUT /actions/{name}/raw` to eliminate the route-shadowing conflict with `config.py`. Added `configActionRaw` endpoint helper in `endpoints.ts` and updated `fetchActionFile` / `updateActionFile` in `config.ts` to call it. Added `TestGetActionFileRaw` and `TestUpdateActionFileRaw` test classes.
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done
**Problem**
When a user opens the *Actions* tab in the Config screen, selects any action, and expands the "Raw Action Configuration" accordion, the text area is always blank. The `fetchContent` callback makes a `GET /api/config/actions/{name}` request expecting a `ConfFileContent` response (`{ content: string, name: string, filename: string }`), but the backend returns an `ActionConfig` (the fully-parsed structured model) instead. The `content` field is therefore `undefined` in the browser, which the `RawConfigSection` component renders as an empty string.
**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`.
**Root cause**
Both `backend/app/routers/config.py` and `backend/app/routers/file_config.py` are mounted with the prefix `/api/config` (see lines 107 and 63 respectively). Both define a `GET /actions/{name}` route:
**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.
- `config.py` → returns `ActionConfig` (parsed detail)
- `file_config.py` → returns `ConfFileContent` (raw file text)
**Goal:** Make the distinction clear and expose the BanGUI application version.
In `backend/app/main.py`, `config.router` is registered on line 402 and `file_config.router` on line 403. FastAPI matches the first registered route, so the raw-content endpoint is permanently shadowed.
**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.
The filters feature already solved the same conflict by using distinct paths (`/filters/{name}/raw` for raw and `/filters/{name}` for parsed). Actions must follow the same pattern.
**Fix — backend (`backend/app/routers/file_config.py`)**
Rename the two action raw-file routes:
| Old path | New path |
|---|---|
| `GET /actions/{name}` | `GET /actions/{name}/raw` |
| `PUT /actions/{name}` | `PUT /actions/{name}/raw` |
Update the module-level docstring comment block at the top of `file_config.py` to reflect the new paths.
**Fix — frontend (`frontend/src/api/endpoints.ts`)**
Add a new helper alongside the existing `configAction` entry:
```ts
configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`,
```
**Fix — frontend (`frontend/src/api/config.ts`)**
Change `fetchActionFile` and `updateActionFile` to call `ENDPOINTS.configActionRaw(name)` instead of `ENDPOINTS.configAction(name)`.
**No changes needed elsewhere.** `ActionsTab.tsx` already passes `fetchActionFile` / `updateActionFile` into `RawConfigSection` via `fetchRaw` / `saveRaw`; the resolved URL is the only thing that needs to change.
**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`.
---
## Rename dev jail `bangui-sim` → `manual-Jail` — DONE
### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done
**Summary:** Renamed `jail.d/bangui-sim.conf``manual-Jail.conf` and `filter.d/bangui-sim.conf``manual-Jail.conf` (via `git mv`), updated all internal references. Updated `check_ban_status.sh`, `simulate_failed_logins.sh`, and `fail2ban-dev-config/README.md` to replace all `bangui-sim` references with `manual-Jail`.
**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.
**Scope**
This is purely a Docker development-environment change. The frontend never hardcodes jail names; it reads them dynamically from the API. Only the files listed below need editing.
**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.
**Files to update**
**Goal:** Replace the tooltip with accurate, self-explanatory wording.
1. **`Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf`**
- Rename the file to `manual-Jail.conf`.
- Change the section header from `[bangui-sim]` to `[manual-Jail]`.
- Change `filter = bangui-sim` to `filter = manual-Jail`.
- Update the file-header comment ("BanGUI — Simulated authentication failure jail" line and any other references to `bangui-sim`).
**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.
2. **`Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf`**
- Rename the file to `manual-Jail.conf`.
- Update any internal comments that mention `bangui-sim`.
3. **`Docker/check_ban_status.sh`**
- Change `readonly JAIL="bangui-sim"` to `readonly JAIL="manual-Jail"`.
- Update the file-header comment block that references `bangui-sim`.
4. **`Docker/simulate_failed_logins.sh`**
- Update all comments that mention `bangui-sim` or `bangui-auth` to refer to `manual-Jail` instead.
- Do **not** change the log-line format string (`bangui-auth: authentication failure from <IP>`) unless the filter's `failregex` in the renamed `manual-Jail.conf` is also updated to match the new prefix; keep them in sync.
5. **`Docker/fail2ban-dev-config/README.md`**
- Replace every occurrence of `bangui-sim` with `manual-Jail`.
After renaming, run `docker compose -f Docker/compose.debug.yml restart fail2ban` and verify with `bash Docker/check_ban_status.sh` that the jail is active under its new name.
**Files:** `frontend/src/components/ServerStatusBar.tsx`.
---
## Bug Fix: Config screen content pane does not update when switching jails — DONE
### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done
**Summary:** Added `key={selectedActiveJail.name}` to `JailConfigDetail` and `key={selectedInactiveJail.name}` to `InactiveJailDetail` in `JailsTab.tsx`, forcing React to unmount and remount the detail component on jail selection changes.
**Implemented:** In `frontend/src/components/config/ServerTab.tsx`, moved `<ServerHealthSection />` from the end of the JSX return to be the first element rendered inside the tab container, before all settings fields.
**Problem**
In the *Jails* tab of the Config screen, clicking a jail name in the left-hand list correctly highlights the new selection, but the right-hand content pane continues to show the previously selected jail (e.g. selecting `blocklist-import` after `manual-Jail` still displays `manual-Jail`'s configuration).
**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.
**Root cause**
In `frontend/src/components/config/JailsTab.tsx`, the child components rendered by `ConfigListDetail` are not given a `key` prop:
**Goal:** Move the `<ServerHealthSection />` block to the **top** of the `ServerTab` render output, before any settings fields.
```tsx
{selectedActiveJail !== undefined ? (
<JailConfigDetail
jail={selectedActiveJail} // no key prop
...
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
jail={selectedInactiveJail} // no key prop
...
/>
) : null}
```
**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.
When the user switches between two jails of the same type (both active or both inactive), React reuses the existing component instance and only updates its props. Any internal state derived from the previous jail — including the `loadedRef` guard inside every nested `RawConfigSection` — is never reset. As a result, forms still show the old jail's values and the raw-config section refuses to re-fetch because `loadedRef.current` is already `true`.
Compare with `ActionsTab.tsx`, where `ActionDetail` correctly uses `key={selectedAction.name}`:
```tsx
<ActionDetail
key={selectedAction.name} // ← forces remount on action change
action={selectedAction}
...
/>
```
**Fix — `frontend/src/components/config/JailsTab.tsx`**
Add `key` props to both detail components so React unmounts and remounts them whenever the selected jail changes:
```tsx
{selectedActiveJail !== undefined ? (
<JailConfigDetail
key={selectedActiveJail.name}
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
key={selectedInactiveJail.name}
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
onDeactivate={
selectedInactiveJail.has_local_override
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
: undefined
}
/>
) : null}
```
No other files need changing. The `key` change is the minimal, isolated fix.
**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

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

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

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

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

@@ -898,12 +898,14 @@ export function JailsTab(): React.JSX.Element {
>
{selectedActiveJail !== undefined ? (
<JailConfigDetail
key={selectedActiveJail.name}
jail={selectedActiveJail}
onSave={updateJail}
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
key={selectedInactiveJail.name}
jail={selectedInactiveJail}
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
onDeactivate={

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

@@ -145,6 +145,16 @@ const useStyles = makeStyles({
padding: tokens.spacingVerticalS,
flexShrink: 0,
},
versionText: {
display: "block",
color: tokens.colorNeutralForeground4,
fontSize: "11px",
paddingLeft: tokens.spacingHorizontalS,
paddingRight: tokens.spacingHorizontalS,
paddingBottom: tokens.spacingVerticalXS,
whiteSpace: "nowrap",
overflow: "hidden",
},
// Main content
main: {
@@ -301,6 +311,11 @@ export function MainLayout(): React.JSX.Element {
{/* Footer — Logout */}
<div className={styles.sidebarFooter}>
{!collapsed && (
<Text className={styles.versionText}>
BanGUI v{__APP_VERSION__}
</Text>
)}
<Tooltip
content={collapsed ? "Sign out" : ""}
relationship="label"

View File

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

View File

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

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