Compare commits
26 Commits
61daa8bbc0
...
v0.9.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 93d26e3c60 | |||
| 954dcf7ea6 | |||
| bf8144916a | |||
| 481daa4e1a | |||
| 889976c7ee | |||
| d3d2cb0915 | |||
| bf82e38b6e | |||
| e98fd1de93 | |||
| 8f515893ea | |||
| 81f99d0b50 | |||
| 030bca09b7 | |||
| 5b7d1a4360 | |||
| e7834a888e | |||
| abb224e01b | |||
| 57cf93b1e5 | |||
| c41165c294 | |||
| cdf73e2d65 | |||
| 21753c4f06 | |||
| eb859af371 | |||
| 5a5c619a34 | |||
| 00119ed68d | |||
| b81e0cdbb4 | |||
| 41dcd60225 | |||
| 12f04bd8d6 | |||
| d4d04491d2 | |||
| 93dc699825 |
@@ -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"
|
||||
|
||||
@@ -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
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
||||
v0.9.7
|
||||
@@ -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
73
Docker/docker-compose.yml
Normal 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
|
||||
@@ -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 1–3 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
|
||||
|
||||
@@ -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]
|
||||
@@ -18,8 +18,8 @@ logpath = /dev/null
|
||||
backend = auto
|
||||
maxretry = 1
|
||||
findtime = 1d
|
||||
# Block imported IPs for one week.
|
||||
bantime = 1w
|
||||
# Block imported IPs for 24 hours.
|
||||
bantime = 86400
|
||||
|
||||
# Never ban the Docker bridge network or localhost.
|
||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||
|
||||
@@ -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
|
||||
@@ -56,11 +56,8 @@ echo " Registry : ${REGISTRY}"
|
||||
echo " Tag : ${TAG}"
|
||||
echo "============================================"
|
||||
|
||||
if [[ "${ENGINE}" == "podman" ]]; then
|
||||
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
|
||||
err "Not logged in. Run:\n podman login ${REGISTRY}"
|
||||
fi
|
||||
fi
|
||||
log "Logging in to ${REGISTRY}"
|
||||
"${ENGINE}" login "${REGISTRY}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build
|
||||
|
||||
86
Docker/release.sh
Normal file
86
Docker/release.sh
Normal 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"
|
||||
@@ -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
238
Docs/Refactoring.md
Normal 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`.
|
||||
189
Docs/Tasks.md
189
Docs/Tasks.md
@@ -4,136 +4,89 @@ This document breaks the entire BanGUI project into development stages, ordered
|
||||
|
||||
---
|
||||
|
||||
## Agent Operating Instructions
|
||||
## Open Issues
|
||||
|
||||
These instructions apply to every AI agent working in this repository. Read them fully before touching any file.
|
||||
|
||||
### Before You Begin
|
||||
|
||||
1. Read [Instructions.md](Instructions.md) in full — it defines the project context, coding standards, and workflow rules. Every rule there is authoritative and takes precedence over any assumption you make.
|
||||
2. Read [Architekture.md](Architekture.md) to understand the system structure before touching any component.
|
||||
3. Read the development guide relevant to your task: [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) (or both).
|
||||
4. Read [Features.md](Features.md) so you understand what the product is supposed to do and do not accidentally break intended behaviour.
|
||||
|
||||
### How to Work Through This Document
|
||||
|
||||
- Tasks are grouped by feature area. Each group is self-contained.
|
||||
- Work through tasks in the order they appear within a group; earlier tasks establish foundations for later ones.
|
||||
- Mark a task **in-progress** before you start it and **completed** the moment it is done. Never batch completions.
|
||||
- If a task depends on another task that is not yet complete, stop and complete the dependency first.
|
||||
- If you are uncertain whether a change is correct, read the relevant documentation section again before proceeding. Do not guess.
|
||||
|
||||
### Code Quality Rules (Summary)
|
||||
|
||||
- No TODOs, no placeholders, no half-finished functions.
|
||||
- Full type annotations on every function (Python) and full TypeScript types on every symbol (no `any`).
|
||||
- Layered architecture: routers → services → repositories. No layer may skip another.
|
||||
- All backend errors are raised as typed HTTP exceptions; all unexpected errors are logged via structlog before re-raising.
|
||||
- All frontend state lives in typed hooks; no raw `fetch` calls outside of the `api/` layer.
|
||||
- After every code change, run the full test suite (`make test`) and ensure it is green.
|
||||
|
||||
### Definition of Done
|
||||
|
||||
A task is done when:
|
||||
- The code compiles and the test suite passes (`make test`).
|
||||
- The feature works end-to-end in the dev stack (`make up`).
|
||||
- No new lint errors are introduced.
|
||||
- The change is consistent with all documentation rules.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
### Task 1 — Blocklist-import jail ban time must be 24 hours
|
||||
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Context**
|
||||
|
||||
When the blocklist importer bans an IP it calls `jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, ip)` (see `backend/app/services/blocklist_service.py`, constant `BLOCKLIST_JAIL = "blocklist-import"`). That call sends `set blocklist-import banip <ip>` to fail2ban, which applies the jail's configured `bantime`. There is currently no guarantee that the `blocklist-import` jail's `bantime` is 86 400 s (24 h), so imported IPs may be released too early or held indefinitely depending on the jail template.
|
||||
|
||||
**What to do**
|
||||
|
||||
1. Locate every place the `blocklist-import` jail is defined or provisioned — check `Docker/fail2ban-dev-config/`, `Docker/Dockerfile.backend`, any jail template files, and the `setup_service.py` / `SetupPage.tsx` flow.
|
||||
2. Ensure the `blocklist-import` jail is created with `bantime = 86400` (24 h). If the jail is created at runtime by the setup service, add or update the `bantime` parameter there. If it is defined in a static config file, set `bantime = 86400` in that file.
|
||||
3. Verify that the existing `jail_service.ban_ip` call in `blocklist_service.import_source` does not need a per-call duration override; the jail-level default of 86 400 s is sufficient.
|
||||
4. Add or update the relevant unit/integration test in `backend/tests/` to assert that the blocklist-import jail is set up with a 24-hour bantime.
|
||||
|
||||
---
|
||||
|
||||
### BUG-001 — fail2ban: `bangui-sim` jail fails to start due to missing `banaction`
|
||||
### Task 2 — Clicking a jail in Jail Overview navigates to Configuration → Jails
|
||||
|
||||
**Status:** Done
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Summary:** `jail.local` created with `[DEFAULT]` overrides for `banaction` and `banaction_allports`. The container init script (`init-fail2ban-config`) overwrites `jail.conf` from the image's `/defaults/` on every start, so modifying `jail.conf` directly is ineffective. `jail.local` is not in the container's defaults and thus persists correctly. Additionally fixed a `TypeError` in `config_file_service.py` where `except jail_service.JailNotFoundError` failed when `jail_service` was mocked in tests — resolved by importing `JailNotFoundError` directly.
|
||||
**Context**
|
||||
|
||||
#### Error
|
||||
`JailsPage.tsx` renders a "Jail Overview" data grid with one row per jail (see `frontend/src/pages/JailsPage.tsx`). Clicking a row currently does nothing. `ConfigPage.tsx` hosts a tab bar with a "Jails" tab that renders `JailsTab`, which already uses a list/detail layout where a jail can be selected from the left pane.
|
||||
|
||||
```
|
||||
Failed during configuration: Bad value substitution: option 'action' in section 'bangui-sim'
|
||||
contains an interpolation key 'banaction' which is not a valid option name.
|
||||
Raw value: '%(action_)s'
|
||||
```
|
||||
**What to do**
|
||||
|
||||
#### Root Cause
|
||||
|
||||
fail2ban's interpolation system resolves option values at configuration load time by
|
||||
substituting `%(key)s` placeholders with values from the same section or from `[DEFAULT]`.
|
||||
|
||||
The chain that fails is:
|
||||
|
||||
1. Every jail inherits `action = %(action_)s` from `[DEFAULT]` (no override in `bangui-sim.conf`).
|
||||
2. `action_` is defined in `[DEFAULT]` as `%(banaction)s[port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]`.
|
||||
3. `banaction` is **commented out** in `[DEFAULT]`:
|
||||
```ini
|
||||
# Docker/fail2ban-dev-config/fail2ban/jail.conf [DEFAULT]
|
||||
#banaction = iptables-multiport ← this line is disabled
|
||||
```
|
||||
4. Because `banaction` is absent from the interpolation namespace, fail2ban cannot resolve
|
||||
`action_`, which makes it unable to resolve `action`, and the jail fails to load.
|
||||
|
||||
The same root cause affects every jail in `jail.d/` that does not define its own `banaction`,
|
||||
including `blocklist-import.conf`.
|
||||
|
||||
#### Fix
|
||||
|
||||
**File:** `Docker/fail2ban-dev-config/fail2ban/jail.conf`
|
||||
|
||||
Uncomment the `banaction` line inside the `[DEFAULT]` section so the value is globally
|
||||
available to all jails:
|
||||
|
||||
```ini
|
||||
banaction = iptables-multiport
|
||||
banaction_allports = iptables-allports
|
||||
```
|
||||
|
||||
This is safe: the dev compose (`Docker/compose.debug.yml`) already grants the fail2ban
|
||||
container `NET_ADMIN` and `NET_RAW` capabilities, which are the prerequisites for
|
||||
iptables-based banning.
|
||||
|
||||
#### Tasks
|
||||
|
||||
- [x] **BUG-001-T1 — Add `banaction` override via `jail.local` [DEFAULT]**
|
||||
|
||||
Open `Docker/fail2ban-dev-config/fail2ban/jail.conf`.
|
||||
Find the two commented-out lines near the `action_` definition:
|
||||
```ini
|
||||
#banaction = iptables-multiport
|
||||
#banaction_allports = iptables-allports
|
||||
```
|
||||
Remove the leading `#` from both lines so they become active options.
|
||||
Do not change any other part of the file.
|
||||
|
||||
- [x] **BUG-001-T2 — Restart the fail2ban container and verify clean startup**
|
||||
|
||||
Bring the dev stack down and back up:
|
||||
```bash
|
||||
make down && make up
|
||||
```
|
||||
Wait for the fail2ban container to reach `healthy`, then inspect its logs:
|
||||
```bash
|
||||
make logs # or: docker logs bangui-fail2ban-dev 2>&1 | grep -i error
|
||||
```
|
||||
Confirm that no `Bad value substitution` or `Failed during configuration` lines appear
|
||||
and that both `bangui-sim` and `blocklist-import` jails show as **enabled** in the output.
|
||||
|
||||
- [x] **BUG-001-T3 — Verify ban/unban cycle works end-to-end**
|
||||
|
||||
With the stack running, trigger the simulation script:
|
||||
```bash
|
||||
bash Docker/simulate_failed_logins.sh
|
||||
```
|
||||
Then confirm fail2ban has recorded a ban:
|
||||
```bash
|
||||
bash Docker/check_ban_status.sh
|
||||
```
|
||||
The script should report at least one banned IP in the `bangui-sim` jail.
|
||||
Also verify that the BanGUI dashboard reflects the new ban entry.
|
||||
1. In `JailsPage.tsx`, make each jail name cell (or the entire row) a clickable element that navigates to `/config` with state `{ tab: "jails", jail: "<jailName>" }`. Use `useNavigate` from `react-router-dom`; the existing `Link` import can be used or replaced with a programmatic navigate.
|
||||
2. In `ConfigPage.tsx`, read the location state on mount. If `state.tab` is `"jails"`, set the active tab to `"jails"`. Pass `state.jail` down to `<JailsTab initialJail={state.jail} />`.
|
||||
3. In `JailsTab.tsx`, accept an optional `initialJail?: string` prop. When it is provided, pre-select that jail in the left-pane list on first render (i.e. set the selected jail state to the jail whose name matches `initialJail`). This should scroll the item into view if the list is long.
|
||||
4. Add a frontend unit test in `frontend/src/pages/__tests__/` that mounts `JailsPage` with a mocked jail list, clicks a jail row, and asserts that `useNavigate` was called with the correct path and state.
|
||||
|
||||
---
|
||||
|
||||
### Task 3 — Setting bantime / findtime throws 400 error due to unsupported `backend` set command
|
||||
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Context**
|
||||
|
||||
Editing ban time or find time in Configuration → Jails triggers an auto-save that sends the full `JailConfigUpdate` payload including the `backend` field. `config_service.update_jail_config` then calls `set <jail> backend <value>` on the fail2ban socket, which returns error code 1 with the message `Invalid command 'backend' (no set action or not yet implemented)`. Fail2ban does not support changing a jail's backend at runtime; it must be set before the jail starts.
|
||||
|
||||
**What to do**
|
||||
|
||||
**Backend** (`backend/app/services/config_service.py`):
|
||||
|
||||
1. Remove the `if update.backend is not None: await _set("backend", update.backend)` block from `update_jail_config`. Setting `backend` via the socket is not supported by fail2ban and will always fail.
|
||||
2. `log_encoding` has the same constraint — verify whether `set <jail> logencoding` is supported at runtime. If it is not, remove it too. If it is supported, leave it.
|
||||
3. Ensure the function still accepts and stores the `backend` value in the Pydantic model for read purposes; do not remove it from `JailConfigUpdate` or the response model.
|
||||
|
||||
**Frontend** (`frontend/src/components/config/JailsTab.tsx`):
|
||||
|
||||
4. Remove `backend` (and `log_encoding` if step 2 confirms it is unsupported) from the `autoSavePayload` memo so the field is never sent in the PATCH/PUT body. The displayed value should remain read-only — show them as plain text or a disabled select so the user can see the current value without being able to trigger the broken set command.
|
||||
|
||||
**Tests**:
|
||||
|
||||
5. Add or update the backend test for `update_jail_config` to assert that no `set … backend` command is issued, and that a payload containing a `backend` field does not cause an error.
|
||||
|
||||
---
|
||||
|
||||
### Task 4 — Unify filter bar: use `DashboardFilterBar` in World Map and History pages
|
||||
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Context**
|
||||
|
||||
`DashboardPage.tsx` uses the shared `<DashboardFilterBar>` component for its time-range and origin-filter controls. `MapPage.tsx` and `HistoryPage.tsx` each implement their own ad-hoc filter UI: `MapPage` uses a Fluent UI `<Select>` for time range plus an inline Toolbar for origin filter; `HistoryPage` uses a `<Select>` for time range with no origin filter toggle. The `DashboardFilterBar` already supports both `TimeRange` and `BanOriginFilter` with the exact toggle-button style shown in the design reference. All three pages should share the same filter appearance and interaction patterns.
|
||||
|
||||
**What to do**
|
||||
|
||||
1. **`MapPage.tsx`**: Replace the custom time-range `<Select>` and the inline origin-filter Toolbar with `<DashboardFilterBar timeRange={range} onTimeRangeChange={setRange} originFilter={originFilter} onOriginFilterChange={setOriginFilter} />`. Remove the now-unused `TIME_RANGE_OPTIONS` constant and the `BAN_ORIGIN_FILTER_LABELS` inline usage. Pass `originFilter` to `useMapData` if it does not already receive it (check the hook signature).
|
||||
2. **`HistoryPage.tsx`**: Replace the custom time-range `<Select>` with `<DashboardFilterBar>`. Add an `originFilter` state (`BanOriginFilter`, default `"all"`) and wire it through `<DashboardFilterBar onOriginFilterChange={setOriginFilter} />`. Pass the origin filter into the `useHistory` query so the backend receives it. If `useHistory` / `HistoryQuery` does not yet accept `origin_filter`, add the parameter to the type and the hook's fetch call.
|
||||
3. Remove any local `filterBar` style definitions from `MapPage.tsx` and `HistoryPage.tsx` that duplicate what `DashboardFilterBar` already provides.
|
||||
4. Ensure the `DashboardFilterBar` component's props interface (`DashboardFilterBarProps` in `frontend/src/components/DashboardFilterBar.tsx`) is not changed in a breaking way; only the call sites change.
|
||||
5. Update or add component tests for `MapPage` and `HistoryPage` to assert that `DashboardFilterBar` is rendered and that changing the time range or origin filter updates the displayed data.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1 +1,50 @@
|
||||
"""BanGUI backend application package."""
|
||||
"""BanGUI backend application package.
|
||||
|
||||
This package exposes the application version based on the project metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import importlib.metadata
|
||||
import tomllib
|
||||
|
||||
PACKAGE_NAME: Final[str] = "bangui-backend"
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str:
|
||||
"""Read the project version from ``pyproject.toml``.
|
||||
|
||||
This is used as a fallback when the package metadata is not available (e.g.
|
||||
when running directly from a source checkout without installing the package).
|
||||
"""
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
pyproject_path = project_root / "pyproject.toml"
|
||||
if not pyproject_path.exists():
|
||||
raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
|
||||
|
||||
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
||||
return str(data["project"]["version"])
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
"""Return the current package version.
|
||||
|
||||
Prefer the project metadata in ``pyproject.toml`` when available, since this
|
||||
is the single source of truth for local development and is kept in sync with
|
||||
the frontend and Docker release version.
|
||||
|
||||
When running from an installed distribution where the ``pyproject.toml``
|
||||
is not available, fall back to installed package metadata.
|
||||
"""
|
||||
|
||||
try:
|
||||
return _read_pyproject_version()
|
||||
except FileNotFoundError:
|
||||
return importlib.metadata.version(PACKAGE_NAME)
|
||||
|
||||
|
||||
__version__ = _read_version()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app import __version__
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.routers import (
|
||||
@@ -49,6 +50,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 +139,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 +328,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)
|
||||
|
||||
@@ -360,7 +366,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app: FastAPI = FastAPI(
|
||||
title="BanGUI",
|
||||
description="Web interface for monitoring, managing, and configuring fail2ban.",
|
||||
version="0.1.0",
|
||||
version=__version__,
|
||||
lifespan=_lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -807,6 +807,14 @@ class InactiveJail(BaseModel):
|
||||
"inactive jails that appear in this list."
|
||||
),
|
||||
)
|
||||
has_local_override: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"``True`` when a ``jail.d/{name}.local`` file exists for this jail. "
|
||||
"Only meaningful for inactive jails; indicates that a cleanup action "
|
||||
"is available."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InactiveJailListResponse(BaseModel):
|
||||
|
||||
@@ -40,9 +40,12 @@ from __future__ import annotations
|
||||
import datetime
|
||||
from typing import Annotated
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
from app.models.config import (
|
||||
ActionConfig,
|
||||
ActionCreateRequest,
|
||||
@@ -97,6 +100,7 @@ from app.services.config_service import (
|
||||
ConfigValidationError,
|
||||
JailNotFoundError,
|
||||
)
|
||||
from app.services.jail_service import JailOperationError
|
||||
from app.tasks.health_check import _run_probe
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
@@ -357,11 +361,17 @@ async def reload_fail2ban(
|
||||
_auth: Validated session.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 when fail2ban reports the reload failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except JailOperationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"fail2ban reload failed: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
@@ -381,24 +391,57 @@ async def restart_fail2ban(
|
||||
) -> None:
|
||||
"""Trigger a full fail2ban service restart.
|
||||
|
||||
The fail2ban daemon is completely stopped and then started again,
|
||||
re-reading all configuration files in the process.
|
||||
Stops the fail2ban daemon via the Unix domain socket, then starts it
|
||||
again using the configured ``fail2ban_start_command``. After starting,
|
||||
probes the socket for up to 10 seconds to confirm the daemon came back
|
||||
online.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
HTTPException: 409 when fail2ban reports the stop command failed.
|
||||
HTTPException: 502 when fail2ban is unreachable for the stop command.
|
||||
HTTPException: 503 when fail2ban does not come back online within
|
||||
10 seconds after being started. Check the fail2ban log for
|
||||
initialisation errors. Use
|
||||
``POST /api/config/jails/{name}/rollback`` if a specific jail
|
||||
is suspect.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
start_cmd: str = request.app.state.settings.fail2ban_start_command
|
||||
start_cmd_parts: list[str] = start_cmd.split()
|
||||
|
||||
# Step 1: stop the daemon via socket.
|
||||
try:
|
||||
# Perform restart by sending the restart command via the fail2ban socket.
|
||||
# If fail2ban is not running, this will raise an exception, and we return 502.
|
||||
await jail_service.restart(socket_path)
|
||||
except JailOperationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"fail2ban stop command failed: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
# Step 2: start the daemon via subprocess.
|
||||
await config_file_service.start_daemon(start_cmd_parts)
|
||||
|
||||
# Step 3: probe the socket until fail2ban is responsive or the budget expires.
|
||||
fail2ban_running: bool = await config_file_service.wait_for_fail2ban(
|
||||
socket_path, max_wait_seconds=10.0
|
||||
)
|
||||
if not fail2ban_running:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"fail2ban was stopped but did not come back online within 10 seconds. "
|
||||
"Check the fail2ban log for initialisation errors. "
|
||||
"Use POST /api/config/jails/{name}/rollback if a specific jail is suspect."
|
||||
),
|
||||
)
|
||||
log.info("fail2ban_restarted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex tester (stateless)
|
||||
@@ -755,6 +798,60 @@ async def deactivate_jail(
|
||||
return result
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/jails/{name}/local",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete the jail.d override file for an inactive jail",
|
||||
)
|
||||
async def delete_jail_local_override(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> None:
|
||||
"""Remove the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||
|
||||
This endpoint is the clean-up action for inactive jails that still carry
|
||||
a ``.local`` override file (e.g. one written with ``enabled = false`` by a
|
||||
previous deactivation). The file is deleted without modifying fail2ban's
|
||||
running state, since the jail is already inactive.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
_auth: Validated session.
|
||||
name: Name of the jail whose ``.local`` file should be removed.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* contains invalid characters.
|
||||
HTTPException: 404 if *name* is not found in any config file.
|
||||
HTTPException: 409 if the jail is currently active.
|
||||
HTTPException: 500 if the file cannot be deleted.
|
||||
HTTPException: 502 if fail2ban is unreachable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
|
||||
try:
|
||||
await config_file_service.delete_jail_local_override(
|
||||
config_dir, socket_path, name
|
||||
)
|
||||
except JailNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except JailNotFoundInConfigError:
|
||||
raise _not_found(name) from None
|
||||
except JailAlreadyActiveError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jail {name!r} is currently active; deactivate it first.",
|
||||
) from None
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete config override: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail validation & rollback endpoints (Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,8 +14,8 @@ Endpoints:
|
||||
* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model
|
||||
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
|
||||
* ``GET /api/config/actions`` — list all action files
|
||||
* ``GET /api/config/actions/{name}`` — get one action file (with content)
|
||||
* ``PUT /api/config/actions/{name}`` — update an action file
|
||||
* ``GET /api/config/actions/{name}/raw`` — get one action file (raw content)
|
||||
* ``PUT /api/config/actions/{name}/raw`` — update an action file (raw content)
|
||||
* ``POST /api/config/actions`` — create a new action file
|
||||
* ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model
|
||||
* ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model
|
||||
@@ -460,7 +460,7 @@ async def list_action_files(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/actions/{name}",
|
||||
"/actions/{name}/raw",
|
||||
response_model=ConfFileContent,
|
||||
summary="Return an action definition file with its content",
|
||||
)
|
||||
@@ -496,7 +496,7 @@ async def get_action_file(
|
||||
|
||||
|
||||
@router.put(
|
||||
"/actions/{name}",
|
||||
"/actions/{name}/raw",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Update an action definition file",
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.ban import TimeRange
|
||||
from app.models.ban import BanOrigin, TimeRange
|
||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||
from app.services import geo_service, history_service
|
||||
|
||||
@@ -52,6 +52,10 @@ async def get_history(
|
||||
default=None,
|
||||
description="Restrict results to IPs matching this prefix.",
|
||||
),
|
||||
origin: BanOrigin | None = Query(
|
||||
default=None,
|
||||
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||
page_size: int = Query(
|
||||
default=_DEFAULT_PAGE_SIZE,
|
||||
@@ -89,6 +93,7 @@ async def get_history(
|
||||
range_=range,
|
||||
jail=jail,
|
||||
ip_filter=ip,
|
||||
origin=origin,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
geo_enricher=_enricher,
|
||||
|
||||
@@ -429,6 +429,7 @@ def _build_inactive_jail(
|
||||
name: str,
|
||||
settings: dict[str, str],
|
||||
source_file: str,
|
||||
config_dir: Path | None = None,
|
||||
) -> InactiveJail:
|
||||
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
|
||||
|
||||
@@ -436,6 +437,8 @@ def _build_inactive_jail(
|
||||
name: Jail section name.
|
||||
settings: Merged key→value dict (DEFAULT values already applied).
|
||||
source_file: Path of the file that last defined this section.
|
||||
config_dir: Absolute path to the fail2ban configuration directory, used
|
||||
to check whether a ``jail.d/{name}.local`` override file exists.
|
||||
|
||||
Returns:
|
||||
Populated :class:`~app.models.config.InactiveJail`.
|
||||
@@ -513,6 +516,11 @@ def _build_inactive_jail(
|
||||
bantime_escalation=bantime_escalation,
|
||||
source_file=source_file,
|
||||
enabled=enabled,
|
||||
has_local_override=(
|
||||
(config_dir / "jail.d" / f"{name}.local").is_file()
|
||||
if config_dir is not None
|
||||
else False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -740,7 +748,7 @@ async def _probe_fail2ban_running(socket_path: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _wait_for_fail2ban(
|
||||
async def wait_for_fail2ban(
|
||||
socket_path: str,
|
||||
max_wait_seconds: float = 10.0,
|
||||
poll_interval: float = 2.0,
|
||||
@@ -764,7 +772,7 @@ async def _wait_for_fail2ban(
|
||||
return False
|
||||
|
||||
|
||||
async def _start_daemon(start_cmd_parts: list[str]) -> bool:
|
||||
async def start_daemon(start_cmd_parts: list[str]) -> bool:
|
||||
"""Start the fail2ban daemon using *start_cmd_parts*.
|
||||
|
||||
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
|
||||
@@ -1111,7 +1119,7 @@ async def list_inactive_jails(
|
||||
continue
|
||||
|
||||
source = source_files.get(jail_name, config_dir)
|
||||
inactive.append(_build_inactive_jail(jail_name, settings, source))
|
||||
inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir)))
|
||||
|
||||
log.info(
|
||||
"inactive_jails_listed",
|
||||
@@ -1469,6 +1477,57 @@ async def deactivate_jail(
|
||||
)
|
||||
|
||||
|
||||
async def delete_jail_local_override(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||
|
||||
This is the clean-up action shown in the config UI when an inactive jail
|
||||
still has a ``.local`` override file (e.g. ``enabled = false``). The
|
||||
file is deleted outright; no fail2ban reload is required because the jail
|
||||
is already inactive.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Name of the jail whose ``.local`` file should be removed.
|
||||
|
||||
Raises:
|
||||
JailNameError: If *name* contains invalid characters.
|
||||
JailNotFoundInConfigError: If *name* is not defined in any config file.
|
||||
JailAlreadyActiveError: If the jail is currently active (refusing to
|
||||
delete the live config file).
|
||||
ConfigWriteError: If the file cannot be deleted.
|
||||
"""
|
||||
_safe_jail_name(name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
all_jails, _source_files = await loop.run_in_executor(
|
||||
None, _parse_jails_sync, Path(config_dir)
|
||||
)
|
||||
|
||||
if name not in all_jails:
|
||||
raise JailNotFoundInConfigError(name)
|
||||
|
||||
active_names = await _get_active_jail_names(socket_path)
|
||||
if name in active_names:
|
||||
raise JailAlreadyActiveError(name)
|
||||
|
||||
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: local_path.unlink(missing_ok=True)
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Failed to delete {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info("jail_local_override_deleted", jail=name, path=str(local_path))
|
||||
|
||||
|
||||
async def validate_jail_config(
|
||||
config_dir: str,
|
||||
name: str,
|
||||
@@ -1541,11 +1600,11 @@ async def rollback_jail(
|
||||
log.info("jail_rolled_back_disabled", jail=name)
|
||||
|
||||
# Attempt to start the daemon.
|
||||
started = await _start_daemon(start_cmd_parts)
|
||||
started = await start_daemon(start_cmd_parts)
|
||||
log.info("jail_rollback_start_attempted", jail=name, start_ok=started)
|
||||
|
||||
# Wait for the socket to come back.
|
||||
fail2ban_running = await _wait_for_fail2ban(
|
||||
fail2ban_running = await wait_for_fail2ban(
|
||||
socket_path, max_wait_seconds=10.0, poll_interval=2.0
|
||||
)
|
||||
|
||||
|
||||
@@ -368,8 +368,9 @@ async def update_jail_config(
|
||||
await _set("datepattern", update.date_pattern)
|
||||
if update.dns_mode is not None:
|
||||
await _set("usedns", update.dns_mode)
|
||||
if update.backend is not None:
|
||||
await _set("backend", update.backend)
|
||||
# Fail2ban does not support changing the log monitoring backend at runtime.
|
||||
# The configuration value is retained for read/display purposes but must not
|
||||
# be applied via the socket API.
|
||||
if update.log_encoding is not None:
|
||||
await _set("logencoding", update.log_encoding)
|
||||
if update.prefregex is not None:
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any
|
||||
import aiosqlite
|
||||
import structlog
|
||||
|
||||
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
||||
from app.models.ban import BLOCKLIST_JAIL, BanOrigin, TIME_RANGE_SECONDS, TimeRange
|
||||
from app.models.history import (
|
||||
HistoryBanItem,
|
||||
HistoryListResponse,
|
||||
@@ -58,6 +58,7 @@ async def list_history(
|
||||
*,
|
||||
range_: TimeRange | None = None,
|
||||
jail: str | None = None,
|
||||
origin: BanOrigin | None = None,
|
||||
ip_filter: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||
@@ -73,6 +74,8 @@ async def list_history(
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
range_: Time-range preset. ``None`` means all-time (no time filter).
|
||||
jail: If given, restrict results to bans from this jail.
|
||||
origin: Optional origin filter — ``"blocklist"`` restricts results to
|
||||
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
||||
ip_filter: If given, restrict results to bans for this exact IP
|
||||
(or a prefix — the query uses ``LIKE ip_filter%``).
|
||||
page: 1-based page number (default: ``1``).
|
||||
@@ -99,6 +102,14 @@ async def list_history(
|
||||
wheres.append("jail = ?")
|
||||
params.append(jail)
|
||||
|
||||
if origin is not None:
|
||||
if origin == "blocklist":
|
||||
wheres.append("jail = ?")
|
||||
params.append(BLOCKLIST_JAIL)
|
||||
elif origin == "selfblock":
|
||||
wheres.append("jail != ?")
|
||||
params.append(BLOCKLIST_JAIL)
|
||||
|
||||
if ip_filter is not None:
|
||||
wheres.append("ip LIKE ?")
|
||||
params.append(f"{ip_filter}%")
|
||||
|
||||
@@ -685,24 +685,29 @@ async def reload_all(
|
||||
|
||||
|
||||
async def restart(socket_path: str) -> None:
|
||||
"""Restart the fail2ban service (daemon).
|
||||
"""Stop the fail2ban daemon via the Unix socket.
|
||||
|
||||
Sends the 'restart' command to the fail2ban daemon via the Unix socket.
|
||||
All jails are stopped and the daemon is restarted, re-reading all
|
||||
configuration from scratch.
|
||||
Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()``
|
||||
on the daemon side and tears down all jails. The caller is responsible
|
||||
for starting the daemon again (e.g. via ``fail2ban-client start``).
|
||||
|
||||
Note:
|
||||
``["restart"]`` is a *client-side* orchestration command that is not
|
||||
handled by the fail2ban server transmitter — sending it to the socket
|
||||
raises ``"Invalid command"`` in the daemon.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Raises:
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
JailOperationError: If fail2ban reports the stop command failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["restart"]))
|
||||
log.info("fail2ban_restarted")
|
||||
_ok(await client.send(["stop"]))
|
||||
log.info("fail2ban_stopped_for_restart")
|
||||
except ValueError as exc:
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
93
backend/app/utils/jail_config.py
Normal file
93
backend/app/utils/jail_config.py
Normal 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 = 86400
|
||||
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))
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bangui-backend"
|
||||
version = "0.1.0"
|
||||
version = "0.9.4"
|
||||
description = "BanGUI backend — fail2ban web management interface"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -370,6 +370,124 @@ class TestReloadFail2ban:
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/reload returns 502 when fail2ban socket is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_service.reload_all",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/reload")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
async def test_409_when_reload_operation_fails(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/reload returns 409 when fail2ban reports a reload error."""
|
||||
from app.services.jail_service import JailOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_service.reload_all",
|
||||
AsyncMock(side_effect=JailOperationError("reload rejected")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/reload")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRestartFail2ban:
|
||||
"""Tests for ``POST /api/config/restart``."""
|
||||
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/restart returns 204 when fail2ban restarts cleanly."""
|
||||
with (
|
||||
patch(
|
||||
"app.routers.config.jail_service.restart",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.routers.config.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.routers.config.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
resp = await config_client.post("/api/config/restart")
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_503_when_fail2ban_does_not_come_back(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/restart returns 503 when fail2ban does not come back online."""
|
||||
with (
|
||||
patch(
|
||||
"app.routers.config.jail_service.restart",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.routers.config.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.routers.config.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
resp = await config_client.post("/api/config/restart")
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_409_when_stop_command_fails(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/restart returns 409 when fail2ban rejects the stop command."""
|
||||
from app.services.jail_service import JailOperationError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_service.restart",
|
||||
AsyncMock(side_effect=JailOperationError("stop failed")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/restart")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/restart returns 502 when fail2ban socket is unreachable."""
|
||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.jail_service.restart",
|
||||
AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")),
|
||||
):
|
||||
resp = await config_client.post("/api/config/restart")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
async def test_start_daemon_called_after_stop(self, config_client: AsyncClient) -> None:
|
||||
"""start_daemon is called after a successful stop."""
|
||||
mock_start = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch(
|
||||
"app.routers.config.jail_service.restart",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.routers.config.config_file_service.start_daemon",
|
||||
mock_start,
|
||||
),
|
||||
patch(
|
||||
"app.routers.config.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
await config_client.post("/api/config/restart")
|
||||
|
||||
mock_start.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/regex-test
|
||||
|
||||
@@ -377,6 +377,102 @@ class TestCreateActionFile:
|
||||
assert resp.json()["name"] == "myaction"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/actions/{name}/raw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetActionFileRaw:
|
||||
"""Tests for ``GET /api/config/actions/{name}/raw``."""
|
||||
|
||||
async def test_200_returns_content(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_action_file",
|
||||
AsyncMock(return_value=_conf_file_content("iptables")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/actions/iptables/raw")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "iptables"
|
||||
|
||||
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_action_file",
|
||||
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/actions/missing/raw")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_503_on_config_dir_error(
|
||||
self, file_config_client: AsyncClient
|
||||
) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.get_action_file",
|
||||
AsyncMock(side_effect=ConfigDirError("no dir")),
|
||||
):
|
||||
resp = await file_config_client.get("/api/config/actions/iptables/raw")
|
||||
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /api/config/actions/{name}/raw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateActionFileRaw:
|
||||
"""Tests for ``PUT /api/config/actions/{name}/raw``."""
|
||||
|
||||
async def test_204_on_success(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.write_action_file",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/actions/iptables/raw",
|
||||
json={"content": "[Definition]\nactionban = iptables -I INPUT -s <ip> -j DROP\n"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_400_write_error(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.write_action_file",
|
||||
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/actions/iptables/raw",
|
||||
json={"content": "x"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_404_not_found(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.write_action_file",
|
||||
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/actions/missing/raw",
|
||||
json={"content": "x"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.file_config.file_config_service.write_action_file",
|
||||
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
|
||||
):
|
||||
resp = await file_config_client.put(
|
||||
"/api/config/actions/escape/raw",
|
||||
json={"content": "x"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/jail-files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -213,6 +213,18 @@ class TestHistoryList:
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("range_") == "7d"
|
||||
|
||||
async def test_forwards_origin_filter(self, history_client: AsyncClient) -> None:
|
||||
"""The ``origin`` query parameter is forwarded to the service."""
|
||||
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
|
||||
with patch(
|
||||
"app.routers.history.history_service.list_history",
|
||||
new=mock_fn,
|
||||
):
|
||||
await history_client.get("/api/history?origin=blocklist")
|
||||
|
||||
_args, kwargs = mock_fn.call_args
|
||||
assert kwargs.get("origin") == "blocklist"
|
||||
|
||||
async def test_empty_result(self, history_client: AsyncClient) -> None:
|
||||
"""An empty history returns items=[] and total=0."""
|
||||
with patch(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.config_file_service import (
|
||||
activate_jail,
|
||||
deactivate_jail,
|
||||
list_inactive_jails,
|
||||
rollback_jail,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -289,6 +290,28 @@ class TestBuildInactiveJail:
|
||||
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
|
||||
assert jail.enabled is True
|
||||
|
||||
def test_has_local_override_absent(self, tmp_path: Path) -> None:
|
||||
"""has_local_override is False when no .local file exists."""
|
||||
jail = _build_inactive_jail(
|
||||
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
|
||||
)
|
||||
assert jail.has_local_override is False
|
||||
|
||||
def test_has_local_override_present(self, tmp_path: Path) -> None:
|
||||
"""has_local_override is True when jail.d/{name}.local exists."""
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
local.write_text("[sshd]\nenabled = false\n")
|
||||
jail = _build_inactive_jail(
|
||||
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
|
||||
)
|
||||
assert jail.has_local_override is True
|
||||
|
||||
def test_has_local_override_no_config_dir(self) -> None:
|
||||
"""has_local_override is False when config_dir is not provided."""
|
||||
jail = _build_inactive_jail("sshd", {}, "/etc/fail2ban/jail.conf")
|
||||
assert jail.has_local_override is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_local_override_sync
|
||||
@@ -424,6 +447,121 @@ class TestListInactiveJails:
|
||||
assert "sshd" in names
|
||||
assert "apache-auth" in names
|
||||
|
||||
async def test_has_local_override_true_when_local_file_exists(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""has_local_override is True for a jail whose jail.d .local file exists."""
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
local = tmp_path / "jail.d" / "apache-auth.local"
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
local.write_text("[apache-auth]\nenabled = false\n")
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
jail = next(j for j in result.jails if j.name == "apache-auth")
|
||||
assert jail.has_local_override is True
|
||||
|
||||
async def test_has_local_override_false_when_no_local_file(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""has_local_override is False when no jail.d .local file exists."""
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
jail = next(j for j in result.jails if j.name == "apache-auth")
|
||||
assert jail.has_local_override is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# delete_jail_local_override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteJailLocalOverride:
|
||||
"""Tests for :func:`~app.services.config_file_service.delete_jail_local_override`."""
|
||||
|
||||
async def test_deletes_local_file(self, tmp_path: Path) -> None:
|
||||
"""delete_jail_local_override removes the jail.d/.local file."""
|
||||
from app.services.config_file_service import delete_jail_local_override
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
local = tmp_path / "jail.d" / "apache-auth.local"
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
local.write_text("[apache-auth]\nenabled = false\n")
|
||||
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
|
||||
|
||||
assert not local.exists()
|
||||
|
||||
async def test_no_error_when_local_file_missing(self, tmp_path: Path) -> None:
|
||||
"""delete_jail_local_override succeeds silently when no .local file exists."""
|
||||
from app.services.config_file_service import delete_jail_local_override
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
# Must not raise even though there is no .local file.
|
||||
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
|
||||
|
||||
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
|
||||
"""delete_jail_local_override raises JailNotFoundInConfigError for unknown jail."""
|
||||
from app.services.config_file_service import (
|
||||
JailNotFoundInConfigError,
|
||||
delete_jail_local_override,
|
||||
)
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
pytest.raises(JailNotFoundInConfigError),
|
||||
):
|
||||
await delete_jail_local_override(str(tmp_path), "/fake.sock", "nonexistent")
|
||||
|
||||
async def test_raises_jail_already_active(self, tmp_path: Path) -> None:
|
||||
"""delete_jail_local_override raises JailAlreadyActiveError when jail is running."""
|
||||
from app.services.config_file_service import (
|
||||
JailAlreadyActiveError,
|
||||
delete_jail_local_override,
|
||||
)
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
local.write_text("[sshd]\nenabled = false\n")
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
),
|
||||
pytest.raises(JailAlreadyActiveError),
|
||||
):
|
||||
await delete_jail_local_override(str(tmp_path), "/fake.sock", "sshd")
|
||||
|
||||
async def test_raises_jail_name_error(self, tmp_path: Path) -> None:
|
||||
"""delete_jail_local_override raises JailNameError for invalid jail names."""
|
||||
from app.services.config_file_service import (
|
||||
JailNameError,
|
||||
delete_jail_local_override,
|
||||
)
|
||||
|
||||
with pytest.raises(JailNameError):
|
||||
await delete_jail_local_override(str(tmp_path), "/fake.sock", "../evil")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# activate_jail
|
||||
@@ -3173,5 +3311,209 @@ class TestActivateJailRollback:
|
||||
# Verify the error message mentions logpath issues.
|
||||
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
|
||||
|
||||
async def test_activate_jail_rollback_deletes_file_when_no_prior_local(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Rollback deletes the .local file when none existed before activation.
|
||||
|
||||
When a jail had no .local override before activation, activate_jail
|
||||
creates one with enabled = true. If reload then crashes, rollback must
|
||||
delete that file (leaving the jail in the same state as before the
|
||||
activation attempt).
|
||||
|
||||
Expects:
|
||||
- The .local file is absent after rollback.
|
||||
- The response indicates recovered=True.
|
||||
"""
|
||||
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
|
||||
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||
# No .local file exists before activation.
|
||||
assert not local_path.exists()
|
||||
|
||||
req = ActivateJailRequest()
|
||||
reload_call_count = 0
|
||||
|
||||
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
|
||||
nonlocal reload_call_count
|
||||
reload_call_count += 1
|
||||
if reload_call_count == 1:
|
||||
raise RuntimeError("fail2ban crashed")
|
||||
# Recovery reload succeeds.
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._validate_jail_config_sync",
|
||||
return_value=JailValidationResult(
|
||||
jail_name="apache-auth", valid=True
|
||||
),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
|
||||
result = await activate_jail(
|
||||
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||
)
|
||||
|
||||
assert result.active is False
|
||||
assert result.recovered is True
|
||||
assert not local_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rollback_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRollbackJail:
|
||||
"""Integration tests for :func:`~app.services.config_file_service.rollback_jail`."""
|
||||
|
||||
async def test_local_file_written_enabled_false(self, tmp_path: Path) -> None:
|
||||
"""rollback_jail writes enabled=false to jail.d/{name}.local before any socket call."""
|
||||
(tmp_path / "jail.d").mkdir()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
AsyncMock(return_value={"sshd"}),
|
||||
),
|
||||
):
|
||||
await rollback_jail(str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"])
|
||||
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
assert local.is_file(), "jail.d/sshd.local must be written"
|
||||
content = local.read_text()
|
||||
assert "enabled = false" in content
|
||||
|
||||
async def test_start_command_invoked_via_subprocess(self, tmp_path: Path) -> None:
|
||||
"""rollback_jail invokes the daemon start command via start_daemon, not via socket."""
|
||||
mock_start = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch("app.services.config_file_service.start_daemon", mock_start),
|
||||
patch(
|
||||
"app.services.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
AsyncMock(return_value={"other"}),
|
||||
),
|
||||
):
|
||||
await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
mock_start.assert_awaited_once_with(["fail2ban-client", "start"])
|
||||
|
||||
async def test_fail2ban_running_reflects_socket_probe_not_subprocess_exit(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""fail2ban_running in the response reflects the socket probe result.
|
||||
|
||||
Even when start_daemon returns True (subprocess exit 0), if the socket
|
||||
probe returns False the response must report fail2ban_running=False.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=False), # socket still unresponsive
|
||||
),
|
||||
):
|
||||
result = await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
assert result.fail2ban_running is False
|
||||
|
||||
async def test_active_jails_zero_when_fail2ban_not_running(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""active_jails is 0 in the response when fail2ban_running is False."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=False),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
result = await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
assert result.active_jails == 0
|
||||
|
||||
async def test_active_jails_count_from_socket_when_running(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""active_jails reflects the actual jail count from the socket when fail2ban is up."""
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
AsyncMock(return_value={"sshd", "nginx", "apache-auth"}),
|
||||
),
|
||||
):
|
||||
result = await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
assert result.active_jails == 3
|
||||
|
||||
async def test_fail2ban_down_at_start_still_succeeds_file_write(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""rollback_jail writes the local file even when fail2ban is down at call time."""
|
||||
# fail2ban is down: start_daemon fails and wait_for_fail2ban returns False.
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service.start_daemon",
|
||||
AsyncMock(return_value=False),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service.wait_for_fail2ban",
|
||||
AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
result = await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
assert local.is_file(), "local file must be written even when fail2ban is down"
|
||||
assert result.disabled is True
|
||||
assert result.fail2ban_running is False
|
||||
|
||||
|
||||
@@ -256,6 +256,27 @@ class TestUpdateJailConfig:
|
||||
assert "bantime" in keys
|
||||
assert "maxretry" in keys
|
||||
|
||||
async def test_ignores_backend_field(self) -> None:
|
||||
"""update_jail_config does not send a set command for backend."""
|
||||
sent_commands: list[list[Any]] = []
|
||||
|
||||
async def _send(command: list[Any]) -> Any:
|
||||
sent_commands.append(command)
|
||||
return (0, "OK")
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(side_effect=_send)
|
||||
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
update = JailConfigUpdate(backend="polling")
|
||||
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||
|
||||
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
|
||||
assert "backend" not in keys
|
||||
|
||||
async def test_raises_validation_error_on_bad_regex(self) -> None:
|
||||
"""update_jail_config raises ConfigValidationError for invalid regex."""
|
||||
from app.models.config import JailConfigUpdate
|
||||
|
||||
@@ -441,6 +441,33 @@ class TestJailControls:
|
||||
)
|
||||
assert exc_info.value.name == "airsonic-auth"
|
||||
|
||||
async def test_restart_sends_stop_command(self) -> None:
|
||||
"""restart() sends the ['stop'] command to the fail2ban socket."""
|
||||
with _patch_client({"stop": (0, None)}):
|
||||
await jail_service.restart(_SOCKET) # should not raise
|
||||
|
||||
async def test_restart_operation_error_raises(self) -> None:
|
||||
"""restart() raises JailOperationError when fail2ban rejects the stop."""
|
||||
with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises(
|
||||
JailOperationError
|
||||
):
|
||||
await jail_service.restart(_SOCKET)
|
||||
|
||||
async def test_restart_connection_error_propagates(self) -> None:
|
||||
"""restart() propagates Fail2BanConnectionError when socket is unreachable."""
|
||||
|
||||
class _FailClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(
|
||||
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.jail_service.Fail2BanClient", _FailClient),
|
||||
pytest.raises(Fail2BanConnectionError),
|
||||
):
|
||||
await jail_service.restart(_SOCKET)
|
||||
|
||||
async def test_start_not_found_raises(self) -> None:
|
||||
"""start_jail raises JailNotFoundError for unknown jail."""
|
||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
||||
|
||||
138
backend/tests/test_utils/test_jail_config.py
Normal file
138
backend/tests/test_utils/test_jail_config.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
|
||||
# Blocklist-import jail must have a 24-hour ban time
|
||||
blocklist_conf = _read(jail_d, _BLOCKLIST_CONF)
|
||||
assert "bantime = 86400" in blocklist_conf
|
||||
|
||||
# .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"
|
||||
)
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.4",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.9.7",
|
||||
"description": "BanGUI frontend — fail2ban web management interface",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,10 +39,8 @@ import type {
|
||||
LogPreviewResponse,
|
||||
MapColorThresholdsResponse,
|
||||
MapColorThresholdsUpdate,
|
||||
PendingRecovery,
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
RollbackResponse,
|
||||
ServerSettingsResponse,
|
||||
ServerSettingsUpdate,
|
||||
JailFileConfig,
|
||||
@@ -265,14 +263,14 @@ export async function fetchActionFiles(): Promise<ConfFilesResponse> {
|
||||
}
|
||||
|
||||
export async function fetchActionFile(name: string): Promise<ConfFileContent> {
|
||||
return get<ConfFileContent>(ENDPOINTS.configAction(name));
|
||||
return get<ConfFileContent>(ENDPOINTS.configActionRaw(name));
|
||||
}
|
||||
|
||||
export async function updateActionFile(
|
||||
name: string,
|
||||
req: ConfFileUpdateRequest
|
||||
): Promise<void> {
|
||||
await put<undefined>(ENDPOINTS.configAction(name), req);
|
||||
await put<undefined>(ENDPOINTS.configActionRaw(name), req);
|
||||
}
|
||||
|
||||
export async function createActionFile(
|
||||
@@ -552,6 +550,18 @@ export async function deactivateJail(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||
*
|
||||
* Only valid when the jail is **not** currently active. Use this to clean up
|
||||
* leftover ``.local`` files after a jail has been fully deactivated.
|
||||
*
|
||||
* @param name - The jail name.
|
||||
*/
|
||||
export async function deleteJailLocalOverride(name: string): Promise<void> {
|
||||
await del<undefined>(ENDPOINTS.configJailLocalOverride(name));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fail2ban log viewer (Task 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -593,21 +603,3 @@ export async function validateJailConfig(
|
||||
): Promise<JailValidationResult> {
|
||||
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the pending crash-recovery record, if any.
|
||||
*
|
||||
* Returns null when fail2ban is healthy and no recovery is pending.
|
||||
*/
|
||||
export async function fetchPendingRecovery(): Promise<PendingRecovery | null> {
|
||||
return get<PendingRecovery | null>(ENDPOINTS.configPendingRecovery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a bad jail — disables it and attempts to restart fail2ban.
|
||||
*
|
||||
* @param name - Name of the jail to disable.
|
||||
*/
|
||||
export async function rollbackJail(name: string): Promise<RollbackResponse> {
|
||||
return post<RollbackResponse>(ENDPOINTS.configJailRollback(name), undefined);
|
||||
}
|
||||
|
||||
@@ -71,11 +71,10 @@ export const ENDPOINTS = {
|
||||
`/config/jails/${encodeURIComponent(name)}/activate`,
|
||||
configJailDeactivate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
||||
configJailLocalOverride: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/local`,
|
||||
configJailValidate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/validate`,
|
||||
configJailRollback: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/rollback`,
|
||||
configPendingRecovery: "/config/pending-recovery" as string,
|
||||
configGlobal: "/config/global",
|
||||
configReload: "/config/reload",
|
||||
configRestart: "/config/restart",
|
||||
@@ -105,6 +104,7 @@ export const ENDPOINTS = {
|
||||
`/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`,
|
||||
configActions: "/config/actions",
|
||||
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
|
||||
configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`,
|
||||
configActionParsed: (name: string): string =>
|
||||
`/config/actions/${encodeURIComponent(name)}/parsed`,
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function fetchHistory(
|
||||
): Promise<HistoryListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.range) params.set("range", query.range);
|
||||
if (query.origin) params.set("origin", query.origin);
|
||||
if (query.jail) params.set("jail", query.jail);
|
||||
if (query.ip) params.set("ip", query.ip);
|
||||
if (query.page !== undefined) params.set("page", String(query.page));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
153
frontend/src/components/__tests__/ServerStatusBar.test.tsx
Normal file
153
frontend/src/components/__tests__/ServerStatusBar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
77
frontend/src/components/__tests__/SetupGuard.test.tsx
Normal file
77
frontend/src/components/__tests__/SetupGuard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* RecoveryBanner — full-width warning shown when fail2ban stopped responding
|
||||
* shortly after a jail was activated (indicating the new jail config may be
|
||||
* invalid).
|
||||
*
|
||||
* Polls ``GET /api/config/pending-recovery`` every 10 seconds and renders a
|
||||
* dismissible ``MessageBar`` when an unresolved crash record is present.
|
||||
* The "Disable & Restart" button calls the rollback endpoint to disable the
|
||||
* offending jail and attempt to restart fail2ban.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarActions,
|
||||
MessageBarBody,
|
||||
MessageBarTitle,
|
||||
Spinner,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchPendingRecovery, rollbackJail } from "../../api/config";
|
||||
import type { PendingRecovery } from "../../types/config";
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Recovery banner that polls for pending crash-recovery records.
|
||||
*
|
||||
* Mount this once at the layout level so it is visible across all pages
|
||||
* while a recovery is pending.
|
||||
*
|
||||
* @returns A MessageBar element, or null when nothing is pending.
|
||||
*/
|
||||
export function RecoveryBanner(): React.JSX.Element | null {
|
||||
const navigate = useNavigate();
|
||||
const [pending, setPending] = useState<PendingRecovery | null>(null);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [rollbackError, setRollbackError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const poll = useCallback((): void => {
|
||||
fetchPendingRecovery()
|
||||
.then((record) => {
|
||||
// Hide the banner once fail2ban has recovered on its own.
|
||||
if (record?.recovered) {
|
||||
setPending(null);
|
||||
} else {
|
||||
setPending(record);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* ignore network errors — will retry */ });
|
||||
}, []);
|
||||
|
||||
// Start polling on mount.
|
||||
useEffect(() => {
|
||||
poll();
|
||||
timerRef.current = setInterval(poll, POLL_INTERVAL_MS);
|
||||
return (): void => {
|
||||
if (timerRef.current !== null) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [poll]);
|
||||
|
||||
const handleRollback = useCallback((): void => {
|
||||
if (!pending || rolling) return;
|
||||
setRolling(true);
|
||||
setRollbackError(null);
|
||||
rollbackJail(pending.jail_name)
|
||||
.then(() => {
|
||||
setPending(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setRollbackError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
setRolling(false);
|
||||
});
|
||||
}, [pending, rolling]);
|
||||
|
||||
const handleViewDetails = useCallback((): void => {
|
||||
navigate("/config");
|
||||
}, [navigate]);
|
||||
|
||||
if (pending === null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
paddingLeft: tokens.spacingHorizontalM,
|
||||
paddingRight: tokens.spacingHorizontalM,
|
||||
paddingTop: tokens.spacingVerticalXS,
|
||||
paddingBottom: tokens.spacingVerticalXS,
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>fail2ban Stopped After Jail Activation</MessageBarTitle>
|
||||
fail2ban stopped responding after activating jail{" "}
|
||||
<strong>{pending.jail_name}</strong>. The jail's configuration
|
||||
may be invalid.
|
||||
{rollbackError && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalXS, color: tokens.colorStatusDangerForeground1 }}>
|
||||
Rollback failed: {rollbackError}
|
||||
</div>
|
||||
)}
|
||||
</MessageBarBody>
|
||||
<MessageBarActions>
|
||||
<Button
|
||||
appearance="primary"
|
||||
size="small"
|
||||
icon={rolling ? <Spinner size="tiny" /> : undefined}
|
||||
disabled={rolling}
|
||||
onClick={handleRollback}
|
||||
>
|
||||
{rolling ? "Disabling…" : "Disable & Restart"}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleViewDetails}
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
</MessageBarActions>
|
||||
</MessageBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Tests for RecoveryBanner (Task 3).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { RecoveryBanner } from "../RecoveryBanner";
|
||||
import type { PendingRecovery } from "../../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../../api/config", () => ({
|
||||
fetchPendingRecovery: vi.fn(),
|
||||
rollbackJail: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchPendingRecovery, rollbackJail } from "../../../api/config";
|
||||
|
||||
const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery);
|
||||
const mockRollbackJail = vi.mocked(rollbackJail);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pendingRecord: PendingRecovery = {
|
||||
jail_name: "sshd",
|
||||
activated_at: "2024-01-01T12:00:00Z",
|
||||
detected_at: "2024-01-01T12:00:30Z",
|
||||
recovered: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderBanner() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<MemoryRouter>
|
||||
<RecoveryBanner />
|
||||
</MemoryRouter>
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RecoveryBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when pending recovery is null", async () => {
|
||||
mockFetchPendingRecovery.mockResolvedValue(null);
|
||||
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchPendingRecovery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders warning when there is an unresolved pending recovery", async () => {
|
||||
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
|
||||
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/fail2ban stopped responding after activating jail/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/sshd/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /view logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the banner when recovery is marked as recovered", async () => {
|
||||
const recoveredRecord: PendingRecovery = { ...pendingRecord, recovered: true };
|
||||
mockFetchPendingRecovery.mockResolvedValue(recoveredRecord);
|
||||
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchPendingRecovery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls rollbackJail and hides banner on successful rollback", async () => {
|
||||
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
|
||||
mockRollbackJail.mockResolvedValue({
|
||||
jail_name: "sshd",
|
||||
disabled: true,
|
||||
fail2ban_running: true,
|
||||
active_jails: 0,
|
||||
message: "Rolled back.",
|
||||
});
|
||||
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /disable & restart/i }),
|
||||
);
|
||||
|
||||
expect(mockRollbackJail).toHaveBeenCalledWith("sshd");
|
||||
});
|
||||
|
||||
it("shows rollback error when rollbackJail fails", async () => {
|
||||
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
|
||||
mockRollbackJail.mockRejectedValue(new Error("Connection refused"));
|
||||
|
||||
renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /disable & restart/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/rollback failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,8 @@
|
||||
* findtime, maxretry, port and logpath. Calls the activate endpoint on
|
||||
* confirmation and propagates the result via callbacks.
|
||||
*
|
||||
* Task 3 additions:
|
||||
* - Runs pre-activation validation when the dialog opens and displays any
|
||||
* warnings or blocking errors before the user confirms.
|
||||
* - Extended spinner text during the post-reload probe phase.
|
||||
* - Calls `onCrashDetected` when the activation response signals that
|
||||
* fail2ban stopped responding after the reload.
|
||||
* Runs pre-activation validation when the dialog opens and displays any
|
||||
* warnings or blocking errors before the user confirms.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -52,11 +48,6 @@ export interface ActivateJailDialogProps {
|
||||
onClose: () => void;
|
||||
/** Called after the jail has been successfully activated. */
|
||||
onActivated: () => void;
|
||||
/**
|
||||
* Called when fail2ban stopped responding after the jail was activated.
|
||||
* The recovery banner will surface this to the user.
|
||||
*/
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -77,7 +68,6 @@ export function ActivateJailDialog({
|
||||
open,
|
||||
onClose,
|
||||
onActivated,
|
||||
onCrashDetected,
|
||||
}: ActivateJailDialogProps): React.JSX.Element {
|
||||
const [bantime, setBantime] = useState("");
|
||||
const [findtime, setFindtime] = useState("");
|
||||
@@ -173,9 +163,6 @@ export function ActivateJailDialog({
|
||||
setValidationWarnings(result.validation_warnings);
|
||||
}
|
||||
resetForm();
|
||||
if (!result.fail2ban_running) {
|
||||
onCrashDetected?.();
|
||||
}
|
||||
onActivated();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -339,9 +326,10 @@ export function ActivateJailDialog({
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>Activation Failed — System Recovered</MessageBarTitle>
|
||||
Activation of jail “{jail.name}” failed. The server
|
||||
has been automatically recovered.
|
||||
<MessageBarTitle>Activation Failed — Configuration Rolled Back</MessageBarTitle>
|
||||
The configuration for jail “{jail.name}” has been
|
||||
rolled back to its previous state and fail2ban is running
|
||||
normally. Review the configuration and try activating again.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
@@ -351,10 +339,12 @@ export function ActivateJailDialog({
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>
|
||||
<MessageBarTitle>Activation Failed — Manual Intervention Required</MessageBarTitle>
|
||||
Activation of jail “{jail.name}” failed and
|
||||
automatic recovery was unsuccessful. Manual intervention is
|
||||
required.
|
||||
<MessageBarTitle>Activation Failed — Rollback Unsuccessful</MessageBarTitle>
|
||||
Activation of jail “{jail.name}” failed and the
|
||||
automatic rollback did not complete. The file{" "}
|
||||
<code>jail.d/{jail.name}.local</code> may still contain{" "}
|
||||
<code>enabled = true</code>. Check the fail2ban logs, correct
|
||||
the file manually, and restart fail2ban.
|
||||
</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ApiError } from "../../api/client";
|
||||
import {
|
||||
addLogPath,
|
||||
deactivateJail,
|
||||
deleteJailLocalOverride,
|
||||
deleteLogPath,
|
||||
fetchInactiveJails,
|
||||
fetchJailConfigFileContent,
|
||||
@@ -215,7 +216,6 @@ function JailConfigDetail({
|
||||
ignore_regex: ignoreRegex,
|
||||
date_pattern: datePattern !== "" ? datePattern : null,
|
||||
dns_mode: dnsMode,
|
||||
backend,
|
||||
log_encoding: logEncoding,
|
||||
prefregex: prefRegex !== "" ? prefRegex : null,
|
||||
bantime_escalation: {
|
||||
@@ -230,7 +230,7 @@ function JailConfigDetail({
|
||||
}),
|
||||
[
|
||||
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
||||
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
|
||||
dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
|
||||
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
|
||||
jail.ban_time, jail.find_time, jail.max_retry,
|
||||
],
|
||||
@@ -573,7 +573,7 @@ function JailConfigDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readOnly && (onActivate !== undefined || onValidate !== undefined) && (
|
||||
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
|
||||
{onValidate !== undefined && (
|
||||
<Button
|
||||
@@ -585,6 +585,15 @@ function JailConfigDetail({
|
||||
{validating ? "Validating…" : "Validate Config"}
|
||||
</Button>
|
||||
)}
|
||||
{onDeactivate !== undefined && (
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<LockOpen24Regular />}
|
||||
onClick={onDeactivate}
|
||||
>
|
||||
Deactivate Jail
|
||||
</Button>
|
||||
)}
|
||||
{onActivate !== undefined && (
|
||||
<Button
|
||||
appearance="primary"
|
||||
@@ -618,8 +627,8 @@ function JailConfigDetail({
|
||||
interface InactiveJailDetailProps {
|
||||
jail: InactiveJail;
|
||||
onActivate: () => void;
|
||||
/** Whether to show and call onCrashDetected on activation crash. */
|
||||
onCrashDetected?: () => void;
|
||||
/** Called when the user requests removal of the .local override file. */
|
||||
onDeactivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,6 +645,7 @@ interface InactiveJailDetailProps {
|
||||
function InactiveJailDetail({
|
||||
jail,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
}: InactiveJailDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [validating, setValidating] = useState(false);
|
||||
@@ -729,6 +739,7 @@ function InactiveJailDetail({
|
||||
onSave={async () => { /* read-only — never called */ }}
|
||||
readOnly
|
||||
onActivate={onActivate}
|
||||
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
|
||||
onValidate={handleValidate}
|
||||
validating={validating}
|
||||
/>
|
||||
@@ -746,12 +757,12 @@ function InactiveJailDetail({
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export interface JailsTabProps {
|
||||
/** Called when fail2ban stopped responding after a jail was activated. */
|
||||
onCrashDetected?: () => void;
|
||||
interface JailsTabProps {
|
||||
/** Jail name to pre-select when the component mounts. */
|
||||
initialJail?: string;
|
||||
}
|
||||
|
||||
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
|
||||
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { jails, loading, error, refresh, updateJail } =
|
||||
useJailConfigs();
|
||||
@@ -786,6 +797,15 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||
}, [refresh, loadInactive]);
|
||||
|
||||
const handleDeactivateInactive = useCallback((name: string): void => {
|
||||
deleteJailLocalOverride(name)
|
||||
.then(() => {
|
||||
setSelectedName(null);
|
||||
loadInactive();
|
||||
})
|
||||
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||
}, [loadInactive]);
|
||||
|
||||
const handleActivated = useCallback((): void => {
|
||||
setActivateTarget(null);
|
||||
setSelectedName(null);
|
||||
@@ -803,6 +823,13 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
return [...activeItems, ...inactiveItems];
|
||||
}, [jails, inactiveJails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialJail || selectedName) return;
|
||||
if (listItems.some((item) => item.name === initialJail)) {
|
||||
setSelectedName(initialJail);
|
||||
}
|
||||
}, [initialJail, listItems, selectedName]);
|
||||
|
||||
const activeJailMap = useMemo(
|
||||
() => new Map(jails.map((j) => [j.name, j])),
|
||||
[jails],
|
||||
@@ -882,15 +909,21 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
>
|
||||
{selectedActiveJail !== undefined ? (
|
||||
<JailConfigDetail
|
||||
key={selectedActiveJail.name}
|
||||
jail={selectedActiveJail}
|
||||
onSave={updateJail}
|
||||
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
||||
/>
|
||||
) : selectedInactiveJail !== undefined ? (
|
||||
<InactiveJailDetail
|
||||
key={selectedInactiveJail.name}
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
onCrashDetected={onCrashDetected}
|
||||
onDeactivate={
|
||||
selectedInactiveJail.has_local_override
|
||||
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</ConfigListDetail>
|
||||
@@ -901,7 +934,6 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
||||
open={activateTarget !== null}
|
||||
onClose={() => { setActivateTarget(null); }}
|
||||
onActivated={handleActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
|
||||
<CreateJailDialog
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* - "Activate" button is enabled when validation passes.
|
||||
* - Dialog stays open and shows an error when the backend returns active=false.
|
||||
* - `onActivated` is called and dialog closes when backend returns active=true.
|
||||
* - `onCrashDetected` is called when fail2ban_running is false after activation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -55,6 +54,7 @@ const baseJail: InactiveJail = {
|
||||
bantime_escalation: null,
|
||||
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
||||
enabled: false,
|
||||
has_local_override: false,
|
||||
};
|
||||
|
||||
/** Successful activation response. */
|
||||
@@ -98,7 +98,6 @@ interface DialogProps {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
onActivated?: () => void;
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
function renderDialog({
|
||||
@@ -106,7 +105,6 @@ function renderDialog({
|
||||
open = true,
|
||||
onClose = vi.fn(),
|
||||
onActivated = vi.fn(),
|
||||
onCrashDetected = vi.fn(),
|
||||
}: DialogProps = {}) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
@@ -115,7 +113,6 @@ function renderDialog({
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onActivated={onActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
</FluentProvider>,
|
||||
);
|
||||
@@ -202,28 +199,4 @@ describe("ActivateJailDialog", () => {
|
||||
expect(onActivated).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onCrashDetected when fail2ban_running is false after activation", async () => {
|
||||
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
||||
mockActivateJail.mockResolvedValue({
|
||||
...successResponse,
|
||||
fail2ban_running: false,
|
||||
});
|
||||
|
||||
const onActivated = vi.fn();
|
||||
const onCrashDetected = vi.fn();
|
||||
renderDialog({ onActivated, onCrashDetected });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
||||
await userEvent.click(activateBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCrashDetected).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onActivated).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
77
frontend/src/components/config/__tests__/JailsTab.test.tsx
Normal file
77
frontend/src/components/config/__tests__/JailsTab.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
import { JailsTab } from "../JailsTab";
|
||||
import type { JailConfig } from "../../../types/config";
|
||||
import { useAutoSave } from "../../../hooks/useAutoSave";
|
||||
import { useJailConfigs } from "../../../hooks/useConfig";
|
||||
import { useConfigActiveStatus } from "../../../hooks/useConfigActiveStatus";
|
||||
|
||||
vi.mock("../../../hooks/useAutoSave");
|
||||
vi.mock("../../../hooks/useConfig");
|
||||
vi.mock("../../../hooks/useConfigActiveStatus");
|
||||
vi.mock("../../../api/config", () => ({
|
||||
fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [] }),
|
||||
deactivateJail: vi.fn(),
|
||||
deleteJailLocalOverride: vi.fn(),
|
||||
addLogPath: vi.fn(),
|
||||
deleteLogPath: vi.fn(),
|
||||
fetchJailConfigFileContent: vi.fn(),
|
||||
updateJailConfigFile: vi.fn(),
|
||||
validateJailConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseAutoSave = vi.mocked(useAutoSave);
|
||||
const mockUseJailConfigs = vi.mocked(useJailConfigs);
|
||||
const mockUseConfigActiveStatus = vi.mocked(useConfigActiveStatus);
|
||||
|
||||
const basicJail: JailConfig = {
|
||||
name: "sshd",
|
||||
ban_time: 600,
|
||||
max_retry: 5,
|
||||
find_time: 600,
|
||||
fail_regex: [],
|
||||
ignore_regex: [],
|
||||
log_paths: [],
|
||||
date_pattern: null,
|
||||
log_encoding: "auto",
|
||||
backend: "polling",
|
||||
use_dns: "warn",
|
||||
prefregex: "",
|
||||
actions: [],
|
||||
bantime_escalation: null,
|
||||
};
|
||||
|
||||
describe("JailsTab", () => {
|
||||
it("does not include backend in auto-save payload", () => {
|
||||
const autoSavePayloads: Array<Record<string, unknown>> = [];
|
||||
mockUseAutoSave.mockImplementation((value) => {
|
||||
autoSavePayloads.push(value as Record<string, unknown>);
|
||||
return { status: "idle", errorText: null, retry: vi.fn() };
|
||||
});
|
||||
|
||||
mockUseJailConfigs.mockReturnValue({
|
||||
jails: [basicJail],
|
||||
total: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
updateJail: vi.fn(),
|
||||
reloadAll: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseConfigActiveStatus.mockReturnValue({ activeJails: new Set<string>() });
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<JailsTab initialJail="sshd" />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
expect(autoSavePayloads.length).toBeGreaterThan(0);
|
||||
const lastPayload = autoSavePayloads[autoSavePayloads.length - 1];
|
||||
|
||||
expect(lastPayload).not.toHaveProperty("backend");
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,6 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../providers/AuthProvider";
|
||||
import { useServerStatus } from "../hooks/useServerStatus";
|
||||
import { useBlocklistStatus } from "../hooks/useBlocklist";
|
||||
import { RecoveryBanner } from "../components/common/RecoveryBanner";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -146,6 +145,16 @@ const useStyles = makeStyles({
|
||||
padding: tokens.spacingVerticalS,
|
||||
flexShrink: 0,
|
||||
},
|
||||
versionText: {
|
||||
display: "block",
|
||||
color: tokens.colorNeutralForeground4,
|
||||
fontSize: "11px",
|
||||
paddingLeft: tokens.spacingHorizontalS,
|
||||
paddingRight: tokens.spacingHorizontalS,
|
||||
paddingBottom: tokens.spacingVerticalXS,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
// Main content
|
||||
main: {
|
||||
@@ -302,6 +311,11 @@ export function MainLayout(): React.JSX.Element {
|
||||
|
||||
{/* Footer — Logout */}
|
||||
<div className={styles.sidebarFooter}>
|
||||
{!collapsed && (
|
||||
<Text className={styles.versionText}>
|
||||
BanGUI v{__APP_VERSION__}
|
||||
</Text>
|
||||
)}
|
||||
<Tooltip
|
||||
content={collapsed ? "Sign out" : ""}
|
||||
relationship="label"
|
||||
@@ -336,8 +350,6 @@ export function MainLayout(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
</div>
|
||||
)}
|
||||
{/* Recovery banner — shown when fail2ban crashed after a jail activation */}
|
||||
<RecoveryBanner />
|
||||
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
|
||||
{blocklistHasErrors && (
|
||||
<div className={styles.warningBar} role="alert">
|
||||
|
||||
78
frontend/src/layouts/__tests__/MainLayout.test.tsx
Normal file
78
frontend/src/layouts/__tests__/MainLayout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@
|
||||
* Export — raw file editors for jail, filter, and action files
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import {
|
||||
ActionsTab,
|
||||
@@ -58,8 +59,16 @@ type TabValue =
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const location = useLocation();
|
||||
const [tab, setTab] = useState<TabValue>("jails");
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { tab?: string; jail?: string } | null;
|
||||
if (state?.tab === "jails") {
|
||||
setTab("jails");
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.header}>
|
||||
@@ -86,7 +95,11 @@ export function ConfigPage(): React.JSX.Element {
|
||||
</TabList>
|
||||
|
||||
<div className={styles.tabContent} key={tab}>
|
||||
{tab === "jails" && <JailsTab />}
|
||||
{tab === "jails" && (
|
||||
<JailsTab
|
||||
initialJail={(location.state as { jail?: string } | null)?.jail}
|
||||
/>
|
||||
)}
|
||||
{tab === "filters" && <FiltersTab />}
|
||||
{tab === "actions" && <ActionsTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -42,8 +41,10 @@ import {
|
||||
ChevronLeftRegular,
|
||||
ChevronRightRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||
import { useHistory, useIpHistory } from "../hooks/useHistory";
|
||||
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -54,13 +55,6 @@ const HIGH_BAN_THRESHOLD = 5;
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
{ label: "Last 365 days", value: "365d" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -381,7 +375,8 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
// Filter state
|
||||
const [range, setRange] = useState<TimeRange | undefined>(undefined);
|
||||
const [range, setRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
const [jailFilter, setJailFilter] = useState("");
|
||||
const [ipFilter, setIpFilter] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
||||
@@ -397,11 +392,12 @@ export function HistoryPage(): React.JSX.Element {
|
||||
const applyFilters = useCallback((): void => {
|
||||
setAppliedQuery({
|
||||
range: range,
|
||||
origin: originFilter !== "all" ? originFilter : undefined,
|
||||
jail: jailFilter.trim() || undefined,
|
||||
ip: ipFilter.trim() || undefined,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
}, [range, jailFilter, ipFilter]);
|
||||
}, [range, originFilter, jailFilter, ipFilter]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
@@ -452,24 +448,16 @@ export function HistoryPage(): React.JSX.Element {
|
||||
{/* Filter bar */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<div className={styles.filterRow}>
|
||||
<div className={styles.filterLabel}>
|
||||
<Text size={200}>Time range</Text>
|
||||
<Select
|
||||
aria-label="Time range"
|
||||
value={range ?? ""}
|
||||
onChange={(_ev, data): void => {
|
||||
setRange(data.value === "" ? undefined : (data.value as TimeRange));
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<option value="">All time</option>
|
||||
{TIME_RANGE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<DashboardFilterBar
|
||||
timeRange={range}
|
||||
onTimeRangeChange={(value) => {
|
||||
setRange(value);
|
||||
}}
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={(value) => {
|
||||
setOriginFilter(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.filterLabel}>
|
||||
<Text size={200}>Jail</Text>
|
||||
@@ -506,7 +494,8 @@ export function HistoryPage(): React.JSX.Element {
|
||||
appearance="subtle"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
setRange(undefined);
|
||||
setRange("24h");
|
||||
setOriginFilter("all");
|
||||
setJailFilter("");
|
||||
setIpFilter("");
|
||||
setAppliedQuery({ page_size: PAGE_SIZE });
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* geo-location details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
SearchRegular,
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||
import type { JailSummary } from "../types/jail";
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -151,77 +151,88 @@ function fmtSeconds(s: number): string {
|
||||
return `${String(Math.round(s / 3600))}h`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jail overview columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (j) => (
|
||||
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
||||
{j.name}
|
||||
</Text>
|
||||
</Link>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "status",
|
||||
renderHeaderCell: () => "Status",
|
||||
renderCell: (j) => {
|
||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||
return <Badge appearance="filled" color="success">running</Badge>;
|
||||
},
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "backend",
|
||||
renderHeaderCell: () => "Backend",
|
||||
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "failed",
|
||||
renderHeaderCell: () => "Failed",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "findTime",
|
||||
renderHeaderCell: () => "Find Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banTime",
|
||||
renderHeaderCell: () => "Ban Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "maxRetry",
|
||||
renderHeaderCell: () => "Max Retry",
|
||||
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||
}),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-component: Jail overview section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function JailOverviewSection(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||
useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const jailColumns = useMemo<TableColumnDefinition<JailSummary>[]>(
|
||||
() => [
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Jail",
|
||||
renderCell: (j) => (
|
||||
<Button
|
||||
appearance="transparent"
|
||||
size="small"
|
||||
style={{ padding: 0, minWidth: 0, justifyContent: "flex-start" }}
|
||||
onClick={() =>
|
||||
navigate("/config", {
|
||||
state: { tab: "jails", jail: j.name },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}
|
||||
>
|
||||
{j.name}
|
||||
</Text>
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "status",
|
||||
renderHeaderCell: () => "Status",
|
||||
renderCell: (j) => {
|
||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||
return <Badge appearance="filled" color="success">running</Badge>;
|
||||
},
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "backend",
|
||||
renderHeaderCell: () => "Backend",
|
||||
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banned",
|
||||
renderHeaderCell: () => "Banned",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "failed",
|
||||
renderHeaderCell: () => "Failed",
|
||||
renderCell: (j) => (
|
||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||
),
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "findTime",
|
||||
renderHeaderCell: () => "Find Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "banTime",
|
||||
renderHeaderCell: () => "Ban Time",
|
||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
||||
}),
|
||||
createTableColumn<JailSummary>({
|
||||
columnId: "maxRetry",
|
||||
renderHeaderCell: () => "Max Retry",
|
||||
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||
}),
|
||||
],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
fn().catch((err: unknown) => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Button,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,19 +21,17 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Toolbar,
|
||||
ToolbarButton,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||
import { WorldMap } from "../components/WorldMap";
|
||||
import { useMapData } from "../hooks/useMapData";
|
||||
import { fetchMapColorThresholds } from "../api/config";
|
||||
import type { TimeRange } from "../types/map";
|
||||
import type { BanOriginFilter } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
@@ -56,34 +53,23 @@ const useStyles = makeStyles({
|
||||
flexWrap: "wrap",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
||||
background: tokens.colorNeutralBackground3,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
},
|
||||
tableWrapper: {
|
||||
overflow: "auto",
|
||||
maxHeight: "420px",
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
},
|
||||
filterBar: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: tokens.spacingHorizontalM,
|
||||
padding: tokens.spacingVerticalS,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time-range options
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
||||
{ label: "Last 24 hours", value: "24h" },
|
||||
{ label: "Last 7 days", value: "7d" },
|
||||
{ label: "Last 30 days", value: "30d" },
|
||||
{ label: "Last 365 days", value: "365d" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MapPage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -136,41 +122,20 @@ export function MapPage(): React.JSX.Element {
|
||||
World Map
|
||||
</Text>
|
||||
|
||||
<Toolbar size="small">
|
||||
<Select
|
||||
aria-label="Time range"
|
||||
value={range}
|
||||
onChange={(_ev, data): void => {
|
||||
setRange(data.value as TimeRange);
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
|
||||
<DashboardFilterBar
|
||||
timeRange={range}
|
||||
onTimeRangeChange={(value) => {
|
||||
setRange(value);
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{TIME_RANGE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Origin filter */}
|
||||
<Select
|
||||
aria-label="Origin filter"
|
||||
value={originFilter}
|
||||
onChange={(_ev, data): void => {
|
||||
setOriginFilter(data.value as BanOriginFilter);
|
||||
originFilter={originFilter}
|
||||
onOriginFilterChange={(value) => {
|
||||
setOriginFilter(value);
|
||||
setSelectedCountry(null);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{BAN_ORIGIN_FILTER_LABELS[f]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<ToolbarButton
|
||||
/>
|
||||
<Button
|
||||
icon={<ArrowCounterclockwiseRegular />}
|
||||
onClick={(): void => {
|
||||
refresh();
|
||||
@@ -178,7 +143,7 @@ export function MapPage(): React.JSX.Element {
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
/>
|
||||
</Toolbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -6,7 +6,11 @@ import { ConfigPage } from "../ConfigPage";
|
||||
|
||||
// Mock all tab components to avoid deep render trees and API calls.
|
||||
vi.mock("../../components/config", () => ({
|
||||
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
||||
JailsTab: ({ initialJail }: { initialJail?: string }) => (
|
||||
<div data-testid="jails-tab" data-initial-jail={initialJail}>
|
||||
JailsTab
|
||||
</div>
|
||||
),
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
@@ -53,4 +57,22 @@ describe("ConfigPage", () => {
|
||||
renderPage();
|
||||
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the Jails tab based on location state", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: "/config", state: { tab: "jails", jail: "sshd" } },
|
||||
]}
|
||||
>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPage />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const jailsTab = screen.getByTestId("jails-tab");
|
||||
expect(jailsTab).toBeInTheDocument();
|
||||
expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
|
||||
});
|
||||
});
|
||||
|
||||
58
frontend/src/pages/__tests__/HistoryPage.test.tsx
Normal file
58
frontend/src/pages/__tests__/HistoryPage.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { HistoryPage } from "../HistoryPage";
|
||||
|
||||
let lastQuery: Record<string, unknown> | null = null;
|
||||
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
||||
lastQuery = query;
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
setPage: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useHistory", () => ({
|
||||
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
|
||||
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../components/WorldMap", () => ({
|
||||
WorldMap: () => <div data-testid="world-map" />,
|
||||
}));
|
||||
|
||||
vi.mock("../api/config", () => ({
|
||||
fetchMapColorThresholds: async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
threshold_high: 100,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("HistoryPage", () => {
|
||||
it("renders DashboardFilterBar and applies origin+range filters", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<HistoryPage />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Initial load should include the default query.
|
||||
expect(lastQuery).toEqual({ page_size: 50 });
|
||||
|
||||
// Change the time-range and origin filter, then apply.
|
||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||
await user.click(screen.getByRole("button", { name: /Apply/i }));
|
||||
|
||||
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
|
||||
});
|
||||
});
|
||||
74
frontend/src/pages/__tests__/JailsPage.test.tsx
Normal file
74
frontend/src/pages/__tests__/JailsPage.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { JailsPage } from "../JailsPage";
|
||||
import type { JailSummary } from "../../types/jail";
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = (await vi.importActual<typeof import("react-router-dom")>(
|
||||
"react-router-dom",
|
||||
)) as unknown as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useJails", () => ({
|
||||
useJails: () => ({
|
||||
jails: [
|
||||
{
|
||||
name: "sshd",
|
||||
enabled: true,
|
||||
running: true,
|
||||
idle: false,
|
||||
backend: "systemd",
|
||||
find_time: 600,
|
||||
ban_time: 3600,
|
||||
max_retry: 5,
|
||||
status: {
|
||||
currently_banned: 1,
|
||||
total_banned: 10,
|
||||
currently_failed: 0,
|
||||
total_failed: 0,
|
||||
},
|
||||
},
|
||||
] as JailSummary[],
|
||||
total: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
startJail: vi.fn().mockResolvedValue(undefined),
|
||||
stopJail: vi.fn().mockResolvedValue(undefined),
|
||||
setIdle: vi.fn().mockResolvedValue(undefined),
|
||||
reloadJail: vi.fn().mockResolvedValue(undefined),
|
||||
reloadAll: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<JailsPage />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("JailsPage", () => {
|
||||
it("navigates to Configuration → Jails when a jail is clicked", async () => {
|
||||
renderPage();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByText("sshd"));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/config", {
|
||||
state: { tab: "jails", jail: "sshd" },
|
||||
});
|
||||
});
|
||||
});
|
||||
58
frontend/src/pages/__tests__/MapPage.test.tsx
Normal file
58
frontend/src/pages/__tests__/MapPage.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MapPage } from "../MapPage";
|
||||
|
||||
const mockFetchMapColorThresholds = vi.fn(async () => ({
|
||||
threshold_low: 10,
|
||||
threshold_medium: 50,
|
||||
threshold_high: 100,
|
||||
}));
|
||||
|
||||
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
||||
const mockUseMapData = vi.fn((range: string, origin: string) => {
|
||||
lastArgs = { range, origin };
|
||||
return {
|
||||
countries: {},
|
||||
countryNames: {},
|
||||
bans: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useMapData", () => ({
|
||||
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
|
||||
}));
|
||||
|
||||
vi.mock("../api/config", async () => ({
|
||||
fetchMapColorThresholds: mockFetchMapColorThresholds,
|
||||
}));
|
||||
|
||||
vi.mock("../components/WorldMap", () => ({
|
||||
WorldMap: () => <div data-testid="world-map" />,
|
||||
}));
|
||||
|
||||
describe("MapPage", () => {
|
||||
it("renders DashboardFilterBar and updates data when filters change", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<MapPage />
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Initial load should call useMapData with default filters.
|
||||
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||
expect(lastArgs.range).toBe("7d");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||
expect(lastArgs.origin).toBe("blocklist");
|
||||
});
|
||||
});
|
||||
83
frontend/src/pages/__tests__/SetupPage.test.tsx
Normal file
83
frontend/src/pages/__tests__/SetupPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -524,6 +524,11 @@ export interface InactiveJail {
|
||||
source_file: string;
|
||||
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* True when a ``jail.d/{name}.local`` override file exists for this jail.
|
||||
* Indicates that a "Deactivate Jail" cleanup action is available.
|
||||
*/
|
||||
has_local_override: boolean;
|
||||
}
|
||||
|
||||
export interface InactiveJailListResponse {
|
||||
@@ -581,20 +586,6 @@ export interface JailValidationResult {
|
||||
issues: JailValidationIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded when fail2ban stops responding shortly after a jail activation.
|
||||
* Surfaced by `GET /api/config/pending-recovery`.
|
||||
*/
|
||||
export interface PendingRecovery {
|
||||
jail_name: string;
|
||||
/** ISO-8601 datetime string. */
|
||||
activated_at: string;
|
||||
/** ISO-8601 datetime string. */
|
||||
detected_at: string;
|
||||
/** True once fail2ban comes back online after the crash. */
|
||||
recovered: boolean;
|
||||
}
|
||||
|
||||
/** Response from `POST /api/config/jails/{name}/rollback`. */
|
||||
export interface RollbackResponse {
|
||||
jail_name: string;
|
||||
|
||||
@@ -50,8 +50,11 @@ export interface IpDetailResponse {
|
||||
}
|
||||
|
||||
/** Query parameters supported by GET /api/history */
|
||||
import type { BanOriginFilter } from "./ban";
|
||||
|
||||
export interface HistoryQuery {
|
||||
range?: TimeRange;
|
||||
origin?: BanOriginFilter;
|
||||
jail?: string;
|
||||
ip?: string;
|
||||
page?: number;
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
3
frontend/src/vite-env.d.ts
vendored
3
frontend/src/vite-env.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
10
pytest.ini
Normal file
10
pytest.ini
Normal file
@@ -0,0 +1,10 @@
|
||||
[pytest]
|
||||
# Ensure pytest-asyncio is in auto mode for async tests without explicit markers.
|
||||
asyncio_mode = auto
|
||||
|
||||
# Run the backend test suite from the repository root.
|
||||
testpaths = backend/tests
|
||||
pythonpath = backend
|
||||
|
||||
# Keep coverage output consistent with backend/pyproject.toml settings.
|
||||
addopts = --cov=backend/app --cov-report=term-missing
|
||||
Reference in New Issue
Block a user