Compare commits

...

13 Commits

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

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

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

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

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

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

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

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

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

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

Tests: 18 backend tests pass; 117 frontend tests pass.
2026-03-15 18:05:53 +01:00
eb859af371 chore: bump version to 0.9.0 2026-03-15 17:13:11 +01:00
5a5c619a34 Fix JailsTab content pane not updating on jail switch
Add key={selectedActiveJail.name} and key={selectedInactiveJail.name} to
JailConfigDetail and InactiveJailDetail in JailsTab.tsx so React unmounts
and remounts the detail component whenever the selected jail changes,
resetting all internal state including the loadedRef guard.
2026-03-15 14:10:01 +01:00
00119ed68d Rename dev jail bangui-sim to manual-Jail
Rename fail2ban-dev-config jail.d/bangui-sim.conf and filter.d/bangui-sim.conf
to manual-Jail.conf. Update section header, filter reference, and comments in
both files. Update JAIL constant and header comment in check_ban_status.sh.
Update comments in simulate_failed_logins.sh. Replace all bangui-sim
occurrences in fail2ban-dev-config/README.md.
2026-03-15 14:09:49 +01:00
34 changed files with 1769 additions and 204 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.4

View File

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

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

86
Docker/release.sh Normal file
View File

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

View File

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

238
Docs/Refactoring.md Normal file
View File

@@ -0,0 +1,238 @@
# BanGUI — Refactoring Instructions for AI Agents
This document is the single source of truth for any AI agent performing a refactoring task on the BanGUI codebase.
Read it in full before writing a single line of code.
The authoritative description of every module, its responsibilities, and the allowed dependency direction is in [Architekture.md](Architekture.md). Always cross-reference it.
---
## 0. Golden Rules
1. **Architecture first.** Every change must comply with the layered architecture defined in [Architekture.md §2](Architekture.md). Dependencies flow inward: `routers → services → repositories`. Never add an import that reverses this direction.
2. **One concern per file.** Each module has an explicitly stated purpose in [Architekture.md](Architekture.md). Do not add responsibilities to a module that do not belong there.
3. **No behaviour change.** Refactoring must preserve all existing behaviour. If a function's public signature, return value, or side-effects must change, that is a feature — create a separate task for it.
4. **Tests stay green.** Run the full test suite (`pytest backend/`) before and after every change. Do not submit work that introduces new failures.
5. **Smallest diff wins.** Prefer targeted edits. Do not rewrite a file when a few lines suffice.
---
## 1. Before You Start
### 1.1 Understand the project
Read the following documents in order:
1. [Architekture.md](Architekture.md) — full system overview, component map, module purposes, dependency rules.
2. [Docs/Backend-Development.md](Backend-Development.md) — coding conventions, testing strategy, environment setup.
3. [Docs/Tasks.md](Tasks.md) — open issues and planned work; avoid touching areas that have pending conflicting changes.
### 1.2 Map the code to the architecture
Before editing, locate every file that is in scope:
```
backend/app/
routers/ HTTP layer — zero business logic
services/ Business logic — orchestrates repositories + clients
repositories/ Data access — raw SQL only
models/ Pydantic schemas
tasks/ APScheduler jobs
utils/ Pure helpers, no framework deps
main.py App factory, lifespan, middleware
config.py Pydantic settings
dependencies.py FastAPI Depends() wiring
frontend/src/
api/ Typed fetch wrappers + endpoint constants
components/ Presentational UI, no API calls
hooks/ All state, side-effects, API calls
pages/ Route components — orchestration only
providers/ React context
types/ TypeScript interfaces
utils/ Pure helpers
```
Confirm which layer every file you intend to touch belongs to. If unsure, consult [Architekture.md §2.2](Architekture.md) (backend) or [Architekture.md §3.2](Architekture.md) (frontend).
### 1.3 Run the baseline
```bash
# Backend
pytest backend/ -x --tb=short
# Frontend
cd frontend && npm run test
```
Record the number of passing tests. After refactoring, that number must be equal or higher.
---
## 2. Backend Refactoring
### 2.1 Routers (`app/routers/`)
**Allowed content:** request parsing, response serialisation, dependency injection via `Depends()`, delegation to a service, HTTP error mapping.
**Forbidden content:** SQL queries, business logic, direct use of `fail2ban_client`, any logic that would also make sense in a unit test without an HTTP request.
Checklist:
- [ ] Every handler calls exactly one service method per logical operation.
- [ ] No `if`/`elif` chains that implement business rules — move these to the service.
- [ ] No raw SQL or repository imports.
- [ ] All response models are Pydantic schemas from `app/models/`.
- [ ] HTTP status codes are consistent with API conventions (200 OK, 201 Created, 204 No Content, 400/422 for client errors, 404 for missing resources, 500 only for unexpected failures).
### 2.2 Services (`app/services/`)
**Allowed content:** business rules, coordination between repositories and external clients, validation that goes beyond Pydantic, fail2ban command orchestration.
**Forbidden content:** raw SQL, direct aiosqlite calls, FastAPI `HTTPException` (raise domain exceptions instead and let the router or exception handler convert them).
Checklist:
- [ ] Service classes / functions accept plain Python types or domain models — not `Request` or `Response` objects.
- [ ] No direct `aiosqlite` usage — go through a repository.
- [ ] No `HTTPException` — raise a custom domain exception or a plain `ValueError`/`RuntimeError` with a clear message.
- [ ] No circular imports between services — if two services need each other's logic, extract the shared logic to a utility or a third service.
### 2.3 Repositories (`app/repositories/`)
**Allowed content:** SQL queries, result mapping to domain models, transaction management.
**Forbidden content:** business logic, fail2ban calls, HTTP concerns, logging beyond debug-level traces.
Checklist:
- [ ] Every public method accepts a `db: aiosqlite.Connection` parameter — sessions are not managed internally.
- [ ] Methods return typed domain models or plain Python primitives, never raw `aiosqlite.Row` objects exposed to callers.
- [ ] No business rules (e.g., no "if this setting is missing, create a default" logic — that belongs in the service).
### 2.4 Models (`app/models/`)
- Keep **Request**, **Response**, and **Domain** model types clearly separated (see [Architekture.md §2.2](Architekture.md)).
- Do not use response models as function arguments inside service or repository code.
- Validators (`@field_validator`, `@model_validator`) belong in models only when they concern data shape, not business rules.
### 2.5 Tasks (`app/tasks/`)
- Tasks must be thin: fetch inputs → call one service method → log result.
- Error handling must be inside the task (APScheduler swallows unhandled exceptions — log them explicitly).
- No direct repository or `fail2ban_client` use; go through a service.
### 2.6 Utils (`app/utils/`)
- Must have zero framework dependencies (no FastAPI, no aiosqlite imports).
- Must be pure or near-pure functions.
- `fail2ban_client.py` is the single exception — it wraps the socket protocol but still has no service-layer logic.
### 2.7 Dependencies (`app/dependencies.py`)
- This file is the **only** place where service constructors are called and injected.
- Do not construct services inside router handlers; always receive them via `Depends()`.
---
## 3. Frontend Refactoring
### 3.1 Pages (`src/pages/`)
**Allowed content:** composing components and hooks, layout decisions, routing.
**Forbidden content:** direct `fetch`/`axios` calls, inline business logic, state management beyond what is needed to coordinate child components.
Checklist:
- [ ] All data fetching goes through a hook from `src/hooks/`.
- [ ] No API function from `src/api/` is called directly inside a page component.
### 3.2 Components (`src/components/`)
**Allowed content:** rendering, styling, event handlers that call prop callbacks.
**Forbidden content:** API calls, hook-level state (prefer lifting state to the page or a dedicated hook), direct use of `src/api/`.
Checklist:
- [ ] Components receive all data via props.
- [ ] Components emit changes via callback props (`onXxx`).
- [ ] No `useEffect` that calls an API function — that belongs in a hook.
### 3.3 Hooks (`src/hooks/`)
**Allowed content:** `useState`, `useEffect`, `useCallback`, `useRef`; calls to `src/api/`; local state derivation.
**Forbidden content:** JSX rendering, Fluent UI components.
Checklist:
- [ ] Each hook has a single, focused concern matching its name (e.g., `useBans` only manages ban data).
- [ ] Hooks return a stable interface: `{ data, loading, error, refetch }` or equivalent.
- [ ] Shared logic between hooks is extracted to `src/utils/` (pure) or a parent hook (stateful).
### 3.4 API layer (`src/api/`)
- `client.ts` is the only place that calls `fetch`. All other api files call `client.ts`.
- `endpoints.ts` is the single source of truth for URL strings.
- API functions must be typed: explicit request and response TypeScript interfaces from `src/types/`.
### 3.5 Types (`src/types/`)
- Interfaces must match the backend Pydantic response schemas exactly (field names, optionality).
- Do not use `any`. Use `unknown` and narrow with type guards when the shape is genuinely unknown.
---
## 4. General Code Quality Rules
### Naming
- Python: `snake_case` for variables/functions, `PascalCase` for classes.
- TypeScript: `camelCase` for variables/functions, `PascalCase` for components and types.
- File names must match the primary export they contain.
### Error handling
- Backend: raise typed exceptions; map them to HTTP status codes in `main.py` exception handlers or in the router — nowhere else.
- Frontend: all API call error states are represented in hook return values; never swallow errors silently.
### Logging (backend)
- Use `structlog` with bound context loggers — never bare `print()`.
- Log at `debug` in repositories, `info` in services for meaningful events, `warning`/`error` in tasks and exception handlers.
- Never log sensitive data (passwords, session tokens, raw IP lists larger than a handful of entries).
### Async correctness (backend)
- Every function that touches I/O (database, fail2ban socket, HTTP) must be `async def`.
- Never call `asyncio.run()` inside a running event loop.
- Do not use `time.sleep()` — use `await asyncio.sleep()`.
---
## 5. Refactoring Workflow
Follow this sequence for every refactoring task:
1. **Read** the relevant section of [Architekture.md](Architekture.md) for the files you will touch.
2. **Run** the full test suite to confirm the baseline.
3. **Identify** the violation or smell: which rule from this document does it break?
4. **Plan** the minimal change: what is the smallest edit that fixes the violation?
5. **Edit** the code. One logical change per commit.
6. **Verify** imports: nothing new violates the dependency direction.
7. **Run** the full test suite. All previously passing tests must still pass.
8. **Update** any affected docstrings or inline comments to reflect the new structure.
9. **Do not** update `Architekture.md` unless the refactor changes the documented structure — that requires a separate review.
---
## 6. Common Violations to Look For
| Violation | Where it typically appears | Fix |
|---|---|---|
| Business logic in a router handler | `app/routers/*.py` | Extract logic to the corresponding service |
| Direct `aiosqlite` calls in a service | `app/services/*.py` | Move the query into the matching repository |
| `HTTPException` raised inside a service | `app/services/*.py` | Raise a domain exception; catch and convert it in the router or exception handler |
| API call inside a React component | `src/components/*.tsx` | Move to a hook; pass data via props |
| Hardcoded URL string in a hook or component | `src/hooks/*.ts`, `src/components/*.tsx` | Use the constant from `src/api/endpoints.ts` |
| `any` type in TypeScript | anywhere in `src/` | Replace with a concrete interface from `src/types/` |
| `print()` statements in production code | `backend/app/**/*.py` | Replace with `structlog` logger |
| Synchronous I/O in an async function | `backend/app/**/*.py` | Use the async equivalent |
| A repository method that contains an `if` with a business rule | `app/repositories/*.py` | Move the rule to the service layer |
---
## 7. Out of Scope
Do not make the following changes unless explicitly instructed in a separate task:
- Adding new API endpoints or pages.
- Changing database schema or migration files.
- Upgrading dependencies.
- Altering Docker or CI configuration.
- Modifying `Architekture.md` or `Tasks.md`.

View File

@@ -4,142 +4,504 @@ 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.
**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.
**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:
- `config.py` → returns `ActionConfig` (parsed detail)
- `file_config.py` → returns `ConfFileContent` (raw file text)
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.
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.
> **Architectural Review — 2026-03-16**
> The findings below were identified by auditing every backend and frontend module against the rules in [Refactoring.md](Refactoring.md) and [Architekture.md](Architekture.md).
> Tasks are grouped by layer and ordered so that lower-level fixes (repositories, services) are done before the layers that depend on them.
---
## Rename dev jail `bangui-sim` → `manual-Jail` — 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`.
**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.
**Files to update**
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`).
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.
### BACKEND
---
## Bug Fix: Config screen content pane does not update when switching jails — DONE
#### TASK B-1 — Create a `fail2ban_db` repository for direct fail2ban database queries
**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.
**Violated rule:** Refactoring.md §2.2 — Services must not perform direct `aiosqlite` calls; go through a repository.
**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).
**Files affected:**
- `backend/app/services/ban_service.py` — lines 247, 398, 568, 646: four separate `aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True)` blocks that execute raw SQL against the fail2ban SQLite database.
- `backend/app/services/history_service.py` — lines 118, 208: two more direct `aiosqlite.connect()` blocks against the fail2ban database.
**Root cause**
In `frontend/src/components/config/JailsTab.tsx`, the child components rendered by `ConfigListDetail` are not given a `key` prop:
**What to do:**
```tsx
{selectedActiveJail !== undefined ? (
<JailConfigDetail
jail={selectedActiveJail} // no key prop
...
/>
) : selectedInactiveJail !== undefined ? (
<InactiveJailDetail
jail={selectedInactiveJail} // no key prop
...
/>
) : null}
```
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.
1. Create `backend/app/repositories/fail2ban_db_repo.py`.
2. Move all SQL that touches the fail2ban database into clearly named async functions in that module. Each function must accept the fail2ban database path (`db_path: str`) as a parameter (connection management stays inside the repository function, since the fail2ban database is an external, read-only resource not managed by BanGUI's own connection pool).
- `get_currently_banned(db_path, jail_filter, since) -> list[BanRecord]`
- `get_ban_counts_by_bucket(db_path, ...) -> list[int]`
- `check_db_nonempty(db_path) -> bool`
- `get_history_for_ip(db_path, ip) -> list[HistoryRecord]`
- `get_history_page(db_path, ...) -> tuple[list[HistoryRecord], int]`
— Adjust signatures as needed to cover all query sites.
3. Replace the inline `aiosqlite.connect` blocks in `ban_service.py` and `history_service.py` with calls to the new repository functions.
4. Add the new repository to `backend/tests/test_repositories/` with unit tests that mock the SQLite file.
---
#### TASK B-2 — Remove direct SQL query from `routers/geo.py`
**Violated rule:** Refactoring.md §2.1 — Routers must contain zero business logic; no SQL or repository imports.
**Files affected:**
- `backend/app/routers/geo.py` — lines 157165: the `re_resolve_geo` handler runs `db.execute("SELECT ip FROM geo_cache WHERE country_code IS NULL")` directly.
**What to do:**
1. Add a function `get_unresolved_ips(db: aiosqlite.Connection) -> list[str]` to the appropriate repository (`geo_cache_repo.py` — create it if it does not yet exist, or add it to `settings_repo.py` if the table belongs there).
2. In the router handler, replace the inline SQL block with a single call to the new repository function via `geo_service` (preferred) or directly if the service layer already handles this path.
3. The final handler body must contain no `db.execute` calls.
---
#### TASK B-3 — Remove repository import from `routers/blocklist.py`
**Violated rule:** Refactoring.md §2.1 — Routers must not import from repositories; all data access must go through services.
**Files affected:**
- `backend/app/routers/blocklist.py` — line 45: `from app.repositories import import_log_repo`; the `get_import_log` handler (around line 220) calls `import_log_repo.list_logs()` directly.
**What to do:**
1. Add a `list_import_logs(db, source_id, page, page_size) -> tuple[list[ImportRunResult], int]` method to `blocklist_service.py` (it can be a thin wrapper that calls `import_log_repo.list_logs` internally).
2. In the router, replace the direct `import_log_repo.list_logs(...)` call with `await blocklist_service.list_import_logs(...)`.
3. Remove the `import_log_repo` import from the router.
---
#### TASK B-4 — Move `conffile_parser.py` from `services/` to `utils/`
**Violated rule:** Refactoring.md §2.2 and Architecture §2.1 — `services/` is for business logic. `conffile_parser.py` is a pure, stateless parsing library with no framework dependencies (no FastAPI, no aiosqlite). It belongs in `utils/`.
**Files affected:**
- `backend/app/services/conffile_parser.py` — all callers that import from `app.services.conffile_parser`.
**What to do:**
1. Move the file: `backend/app/services/conffile_parser.py``backend/app/utils/conffile_parser.py`.
2. Update every import in the codebase from `from app.services.conffile_parser import ...` to `from app.utils.conffile_parser import ...`.
3. Run the full test suite to confirm nothing is broken.
---
#### TASK B-5 — Create a `geo_cache_repo` and remove direct SQL from `geo_service.py`
**Violated rule:** Refactoring.md §2.2 — Services must not execute raw SQL; go through a repository.
**Files affected:**
- `backend/app/services/geo_service.py` — multiple direct `db.execute` / `db.executemany` calls in `cache_stats()` (line 187), `load_cache_from_db()` (line 271), `_persist_entry()` (lines 304316), `_persist_neg_entry()` (lines 329338), `flush_dirty()` (lines 795+), and geo-data batch persist blocks (lines 588612).
**What to do:**
1. Create `backend/app/repositories/geo_cache_repo.py` with typed async functions for every SQL operation currently inline in `geo_service.py`:
- `load_all(db) -> list[GeoCacheRow]`
- `upsert_entry(db, geo_row) -> None`
- `upsert_neg_entry(db, ip) -> None`
- `flush_dirty(db, entries) -> int`
- `get_stats(db) -> dict[str, int]`
- `get_unresolved_ips(db) -> list[str]` (also needed by B-2)
2. Replace every `db.execute` / `db.executemany` call in `geo_service.py` with calls to the new repository.
3. Add tests in `backend/tests/test_repositories/test_geo_cache_repo.py`.
---
#### TASK B-6 — Remove direct SQL from `tasks/geo_re_resolve.py`
**Violated rule:** Refactoring.md §2.5 — Tasks must not use repositories directly; they must call a service method.
**Files affected:**
- `backend/app/tasks/geo_re_resolve.py` — line 53: `async with db.execute("SELECT ip FROM geo_cache WHERE country_code IS NULL")`.
**What to do:**
After completing TASK B-5, a `geo_service` method (or via `geo_cache_repo` through `geo_service`) that returns unresolved IPs will exist.
1. Replace the inline SQL block in `_run_re_resolve` with a call to that service method (e.g., `unresolved = await geo_service.get_unresolved_ips(db)`).
2. The task function must contain no `db.execute` calls of its own.
---
#### TASK B-7 — Replace `Any` type annotations in `ban_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/ban_service.py` — lines 192, 271, 346, 434, 455: uses of `Any` for `geo_enricher` parameter and `geo_map` dict value type.
**What to do:**
1. Define a precise callable type alias for the geo enricher, e.g.:
```python
from collections.abc import Awaitable, Callable
GeoEnricher: TypeAlias = Callable[[str], Awaitable[GeoInfo | None]]
```
2. Replace `geo_enricher: Any | None` with `geo_enricher: GeoEnricher | None` (both occurrences).
3. Replace `geo_map: dict[str, Any]` with `geo_map: dict[str, GeoInfo]` (both occurrences).
4. Replace the inner `_safe_lookup` return type `tuple[str, Any]` with `tuple[str, GeoInfo | None]`.
5. Run `mypy --strict` or `pyright` to confirm zero remaining type errors in this file.
---
#### TASK B-8 — Remove `print()` from `geo_service.py` docstring example
**Violated rule:** Refactoring.md §4 / Backend-Development.md §2 — Never use `print()` in production code; use `structlog`.
**Files affected:**
- `backend/app/services/geo_service.py` — line 33: `print(info.country_code) # "DE"` appears inside a module-level docstring usage example.
**What to do:**
Remove or rewrite the docstring snippet so it does not contain a bare `print()` call. If the example is kept, annotate it clearly as a documentation-only code block that should not be copied into production code, or replace with a comment like `# info.country_code == "DE"`.
---
#### TASK B-9 — Remove direct SQL from `main.py` lifespan into `geo_service`
**Violated rule:** Refactoring.md §2 — Application startup code must not execute raw SQL; data-access logic belongs in a repository (or, when count semantics belong to a domain concern, a service method).
**Files affected:**
- `backend/app/main.py` — lines 164168: the lifespan handler runs `db.execute("SELECT COUNT(*) FROM geo_cache WHERE country_code IS NULL")` directly to log a startup warning about unresolved geo entries.
**What to do:**
1. After TASK B-5 is complete, `geo_cache_repo` will expose a `get_stats(db) -> dict[str, int]` function (or a dedicated `count_unresolved(db) -> int`). Use that.
2. If B-5 is not yet merged, add an interim function `count_unresolved(db: aiosqlite.Connection) -> int` to `geo_cache_repo.py` now and call it from `geo_service` as `geo_service.count_unresolved_cached(db) -> Awaitable[int]`.
3. Replace the inline `async with db.execute(...)` block in `main.py` with a single `await geo_service.count_unresolved_cached(db)` call.
4. The `main.py` lifespan function must contain no `db.execute` calls of its own.
---
#### TASK B-10 — Replace `Any` type usage in `history_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/history_service.py` — uses `Any` for `geo_enricher` and query parameter lists.
**What to do:**
1. Define a shared `GeoEnricher` type alias (e.g., in `app/services/geo_service.py` or a new `app/models/geo.py`) similar to TASK B-7.
2. Update `history_service.py` to use `GeoEnricher | None` for the `geo_enricher` parameter.
3. Replace `list[Any]` for SQL parameters with a more precise type (e.g., `list[object]` or a custom `SqlParam` alias).
4. Run `mypy --strict` or `pyright` to confirm there are no remaining `Any` usages in `history_service.py`.
---
#### TASK B-11 — Reduce `Any` usage in `server_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/server_service.py` — uses `Any` for raw socket response values and command parameters.
**What to do:**
1. Define typed aliases for the expected response and command shapes used by `Fail2BanClient` (e.g., `Fail2BanResponse = tuple[int, object]`, `Fail2BanCommand = list[str | int | None]`).
2. Replace `Any` with those aliases in `_ok`, `_safe_get`, and other helper functions.
3. Ensure the public API functions (`get_settings`, etc.) have explicit return types and avoid propagating `Any` to callers.
4. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in `server_service.py`.
---
### FRONTEND
---
#### TASK F-1 — Wrap `SetupPage` API calls in a dedicated hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions from `src/api/` directly; all data fetching goes through hooks.
**Files affected:**
- `frontend/src/pages/SetupPage.tsx` — lines 24, 114, 179: imports `getSetupStatus` and `submitSetup` from `../api/setup` and calls them directly inside the component.
**What to do:**
1. Create `frontend/src/hooks/useSetup.ts` that encapsulates:
- Fetching setup status on mount (`{ isSetupComplete, loading, error }`).
- A `submitSetup(payload)` mutation that returns `{ submitting, submitError, submit }`.
2. Update `SetupPage.tsx` to use `useSetup` exclusively; remove all direct `api/setup` imports from the page.
---
#### TASK F-2 — Wrap `JailDetailPage` jail-control API calls in a hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
**Files affected:**
- `frontend/src/pages/JailDetailPage.tsx` — lines 3744, 262, 272, 285, 295: imports and directly calls `startJail`, `stopJail`, `setJailIdle`, `reloadJail` from `../api/jails`.
**What to do:**
1. Check whether `useJailDetail` or `useJails` already expose these control actions. If so, use those hook-provided callbacks instead of calling the API directly.
2. If they do not, add `start()`, `stop()`, `reload()`, `setIdle(idle: boolean)` actions to the appropriate hook (e.g., `useJailDetail`).
3. Remove all direct `startJail` / `stopJail` / `setJailIdle` / `reloadJail` API imports from the page.
4. The `ApiError` import may remain if it is used only for `instanceof` type-narrowing in error handlers, but prefer exposing an `error: ApiError | null` from the hook instead.
---
#### TASK F-3 — Wrap `MapPage` config API call in a hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
**Files affected:**
- `frontend/src/pages/MapPage.tsx` — line 34: imports `fetchMapColorThresholds` from `../api/config` and calls it in a `useEffect`.
**What to do:**
1. Create `frontend/src/hooks/useMapColorThresholds.ts` (or add the fetch to the existing `useMapData` hook if it is cohesive).
2. Replace the inline `useEffect` + `fetchMapColorThresholds` pattern in `MapPage` with the new hook call.
3. Remove the direct `api/config` import from the page.
---
#### TASK F-4 — Wrap `BlocklistsPage` preview API call in a hook
**Violated rule:** Refactoring.md §3.1 — Pages must not call API functions directly.
**Files affected:**
- `frontend/src/pages/BlocklistsPage.tsx` — line 54: imports `previewBlocklist` from `../api/blocklist`.
**What to do:**
1. Add a `previewBlocklist(url)` action to the existing `useBlocklists` hook (or create a `useBlocklistPreview` hook), returning `{ preview, previewing, previewError, runPreview }`.
2. Update `BlocklistsPage` to call the hook action instead of the raw API function.
3. Remove the direct `api/blocklist` import for `previewBlocklist` from the page.
---
#### TASK F-5 — Move all API calls out of `BannedIpsSection` into a hook
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions; all data must come via props or hooks invoked in the parent.
**Files affected:**
- `frontend/src/components/jail/BannedIpsSection.tsx` — imports and directly calls `fetchJailBannedIps` and `unbanIp` from `../../api/jails`.
**What to do:**
1. Create `frontend/src/hooks/useJailBannedIps.ts` with state `{ bannedIps, loading, error, page, totalPages, refetch }` and an `unban(ip)` action.
2. Invoke this hook in the parent page (`JailDetailPage`) and pass `bannedIps`, `loading`, `error`, `onUnban`, and pagination props down to `BannedIpsSection`.
3. Remove all `api/` imports from `BannedIpsSection.tsx`; the component receives everything through props.
4. Update `BannedIpsSection` tests to use props instead of mocking API calls directly.
---
#### TASK F-6 — Move all API calls out of config tab and dialog components into hooks
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
**Files affected (all in `frontend/src/components/config/`):**
- `FiltersTab.tsx` — calls `fetchFilters`, `fetchFilterFile`, `updateFilterFile` from `../../api/config` directly.
- `JailsTab.tsx` — calls multiple config API functions directly.
- `ActionsTab.tsx` — calls config API functions directly.
- `ExportTab.tsx` — calls multiple file-management API functions directly.
- `JailFilesTab.tsx` — calls API functions for jail file management.
- `ServerHealthSection.tsx` — calls `fetchFail2BanLog`, `fetchServiceStatus` from `../../api/config`.
- `CreateFilterDialog.tsx` — calls `createFilter` from `../../api/config`.
- `CreateJailDialog.tsx` — calls `createJailConfigFile` from `../../api/config`.
- `CreateActionDialog.tsx` — calls `createAction` from `../../api/config`.
- `ActivateJailDialog.tsx` — calls `activateJail`, `validateJailConfig` from `../../api/config`.
- `AssignFilterDialog.tsx` — calls `assignFilterToJail` from `../../api/config` and `fetchJails` from `../../api/jails`.
- `AssignActionDialog.tsx` — calls `assignActionToJail` from `../../api/config` and `fetchJails` from `../../api/jails`.
**What to do:**
For each component listed:
1. Identify or create the appropriate hook in `frontend/src/hooks/`. Group related concerns — for example, a single `useFiltersConfig` hook can cover fetch, update, and create actions for filters.
2. Move all `useEffect` + API call patterns from the component into the hook. The hook must return `{ data, loading, error, refetch, ...actions }`.
3. The component must receive data and action callbacks exclusively through props or a hook called in its closest page ancestor.
4. Remove all `../../api/` imports from the component files listed above.
5. Update or add unit tests for any new hooks created.
---
#### TASK F-7 — Move `SetupGuard` API call into a hook
**Violated rule:** Refactoring.md §3.2 — Components must not contain a `useEffect` that calls an API function.
**Files affected:**
- `frontend/src/components/SetupGuard.tsx` — line 12: imports `getSetupStatus` from `../api/setup`; lines 2836: calls it directly inside a `useEffect`.
**What to do:**
1. The `useSetup` hook created for TASK F-1 exposes setup-status fetching. Reuse it here, or extract the status-only slice into a `useSetupStatus()` hook that `SetupGuard` and `SetupPage` can both consume.
2. Replace the inline `useEffect` + `getSetupStatus` pattern in `SetupGuard` with a call to the hook.
3. Remove the direct `../api/setup` import from `SetupGuard.tsx`.
4. Update `SetupGuard` tests — they currently mock `../../api/setup` directly; update them to mock the hook instead.
**Dependency:** Can share hook infrastructure with TASK F-1.
---
#### TASK F-8 — Move `ServerTab` direct API calls into hooks
**Violated rule:** Refactoring.md §3.2 — Components must not call API functions.
**Files affected:**
- `frontend/src/components/config/ServerTab.tsx`:
- lines 36-41: imports `fetchMapColorThresholds`, `updateMapColorThresholds`, `reloadConfig`, `restartFail2Ban` from `../../api/config` and calls each directly inside `useCallback`/`useEffect` handlers.
*Note: This component was inadvertently omitted from the TASK F-6 file list despite belonging to the same `components/config/` family.*
**What to do:**
1. The `fetchMapColorThresholds` / `updateMapColorThresholds` concern overlaps with TASK F-3 (`useMapColorThresholds` hook). Extend that hook or create a dedicated `useMapColorThresholdsConfig` hook that also exposes an `update(payload)` action.
2. Add `reload()` and `restart()` actions to a suitable config hook (e.g., a `useServerActions` hook or extend `useServerSettings` in `src/hooks/useConfig.ts`).
3. Replace all direct `reloadConfig()`, `restartFail2Ban()`, `fetchMapColorThresholds()`, and `updateMapColorThresholds()` calls in `ServerTab` with the hook-provided actions.
4. Remove all `../../api/config` imports for these four functions from `ServerTab.tsx`.
**Dependency:** Coordinate with TASK F-3 to avoid creating duplicate `useMapColorThresholds` hook logic.
---
#### TASK F-9 — Move `TimezoneProvider` API call into a hook
**Violated rule:** Refactoring.md §3.2 — A component (including a provider component) must not contain a `useEffect` that calls an API function directly; API calls belong in `src/hooks/`.
**Files affected:**
- `frontend/src/providers/TimezoneProvider.tsx` — line 20: imports `fetchTimezone` from `../api/setup`; lines 5762: calls it directly inside a `useCallback` that is invoked from `useEffect`.
**What to do:**
1. Create `frontend/src/hooks/useTimezoneData.ts` (or add to an existing setup-related hook) that fetches the timezone and returns `{ timezone, loading, error }`.
2. Call this hook inside `TimezoneProvider` and drive the context value from the hook's `timezone` output — removing the inline `fetchTimezone()` call.
3. Remove the direct `../api/setup` import from `TimezoneProvider.tsx`.
4. The hook may be reused in any future component that needs the configured timezone without going through the context.
---
#### TASK B-12 — Remove `Any` type annotations in `config_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/config_service.py` — several helper functions (`_ok`, `_to_dict`, `_ensure_list`, `_safe_get`, `_set`, `_set_global`) use `Any` for inputs/outputs.
**What to do:**
1. Define typed aliases for the fail2ban client response and command shapes (e.g., `Fail2BanResponse = tuple[int, object | None]`, `Fail2BanCommand = list[str | int | None]`).
2. Replace `Any` in helper signatures with the new aliases (and use `object`/`str`/`int` where appropriate).
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-13 — Remove `Any` type annotations in `jail_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/jail_service.py` — helper utilities (`_ok`, `_to_dict`, `_ensure_list`, `_safe_get`, etc.) use `Any` for raw fail2ban responses and command parameters.
**What to do:**
1. Define typed aliases for fail2ban response and command shapes (e.g., `Fail2BanResponse`, `Fail2BanCommand`).
2. Update helper function signatures to use the new types instead of `Any`.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-14 — Remove `Any` type annotations in `health_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/health_service.py` — helper functions `_ok` and `_to_dict` and their callers currently use `Any`.
**What to do:**
1. Define typed aliases for fail2ban responses (e.g. `Fail2BanResponse = tuple[int, object | None]`).
2. Update `_ok`, `_to_dict`, and any helper usage sites to use concrete types instead of `Any`.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-15 — Remove `Any` type annotations in `blocklist_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/blocklist_service.py` — helper `_row_to_source()` and other internal functions currently use `Any`.
**What to do:**
1. Replace `Any` with precise types for repository row dictionaries (e.g. `dict[str, object]` or a dedicated `BlocklistSourceRow` TypedDict).
2. Update helper signatures and any call sites accordingly.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-16 — Remove `Any` type annotations in `import_log_repo.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/repositories/import_log_repo.py` — returns `dict[str, Any]` and accepts `list[Any]` parameters.
**What to do:**
1. Define a typed row model (e.g. `ImportLogRow = TypedDict[...]`) or a Pydantic model for import log entries.
2. Update public function signatures to return typed structures instead of `dict[str, Any]` and to accept properly typed query parameters.
3. Update callers (e.g. `routers/blocklist.py` and `services/blocklist_service.py`) to work with the new types.
4. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-17 — Remove `Any` type annotations in `config_file_service.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/services/config_file_service.py` — internal helpers (`_to_dict_inner`, `_ok`, etc.) use `Any` for fail2ban response objects.
**What to do:**
1. Introduce typed aliases for fail2ban command/response shapes (e.g. `Fail2BanResponse`, `Fail2BanCommand`).
2. Replace `Any` in helper function signatures and return types with these aliases.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-18 — Remove `Any` type annotations in `fail2ban_client.py`
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/utils/fail2ban_client.py` — the public client interface uses `Any` for command and response types.
**What to do:**
1. Define clear type aliases such as `Fail2BanCommand = list[str | int | bool | None]` and `Fail2BanResponse = object` (or a more specific union of expected response shapes).
2. Update `_send_command_sync`, `_coerce_command_token`, and `Fail2BanClient.send` signatures to use these aliases.
3. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in this file.
---
#### TASK B-19 — Remove `Any` annotations from background tasks
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
**Files affected:**
- `backend/app/tasks/health_check.py` — uses `app: Any` and `last_activation: dict[str, Any] | None`.
- `backend/app/tasks/geo_re_resolve.py` — uses `app: Any`.
**What to do:**
1. Define a typed model for the shared application state (e.g., a `TypedDict` or `Protocol`) that includes the expected properties on `app.state` (e.g., `settings`, `db`, `server_status`, `last_activation`, `pending_recovery`).
2. Change task callbacks to accept `FastAPI` (or the typed app) instead of `Any`.
3. Replace `dict[str, Any]` with a lean typed record (e.g., a `TypedDict` or a small `@dataclass`) for `last_activation`.
4. Run `mypy --strict` or `pyright` to confirm no remaining `Any` usages in these files.
---
#### TASK B-20 — Remove `type: ignore` in `dependencies.get_settings`
**Violated rule:** Backend-Development.md §1 — Avoid `Any` and ignored type errors.
**Files affected:**
- `backend/app/dependencies.py` — `get_settings` currently uses `# type: ignore[no-any-return]`.
**What to do:**
1. Introduce a typed model (e.g., `TypedDict` or `Protocol`) for `app.state` to declare `settings: Settings` and other shared state properties.
2. Update `get_settings` (and any other helpers that read from `app.state`) so the return type is inferred as `Settings` without needing a `type: ignore` comment.
3. Run `mypy --strict` or `pyright` to confirm the type ignore is no longer needed.

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.4",
"description": "BanGUI frontend — fail2ban web management interface",
"type": "module",
"scripts": {

View File

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

View File

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