Compare commits
36 Commits
4be2469f92
...
v0.9.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 59da34dc3b | |||
| 90f54cf39c | |||
| 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 | |||
| 61daa8bbc0 | |||
| 57a0bbe36e | |||
| f62785aaf2 | |||
| 1e33220f59 | |||
| 1da38361a9 | |||
| 9630aea877 | |||
| 037c18eb00 | |||
| 2e1a4b3b2b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,6 +105,7 @@ Docker/fail2ban-dev-config/**
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
|
||||
!Docker/fail2ban-dev-config/fail2ban/jail.local
|
||||
|
||||
# ── Misc ──────────────────────────────────────
|
||||
*.log
|
||||
|
||||
@@ -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.8
|
||||
@@ -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() {
|
||||
|
||||
@@ -37,6 +37,11 @@ services:
|
||||
timeout: 5s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
# NOTE: The fail2ban-config volume must be pre-populated with the following files:
|
||||
# • fail2ban/jail.conf (or jail.d/*.conf) with the DEFAULT section containing:
|
||||
# banaction = iptables-allports[lockingopt="-w 5"]
|
||||
# This prevents xtables lock contention errors when multiple jails start in parallel.
|
||||
# See https://fail2ban.readthedocs.io/en/latest/development/environment.html
|
||||
|
||||
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
||||
backend:
|
||||
|
||||
73
Docker/docker-compose.yml
Normal file
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,9 +18,8 @@ logpath = /dev/null
|
||||
backend = auto
|
||||
maxretry = 1
|
||||
findtime = 1d
|
||||
# Block imported IPs for one week.
|
||||
bantime = 1w
|
||||
banaction = iptables-allports
|
||||
# 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,16 +5,15 @@
|
||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
[bangui-sim]
|
||||
[manual-Jail]
|
||||
|
||||
enabled = true
|
||||
filter = bangui-sim
|
||||
filter = manual-Jail
|
||||
logpath = /remotelogs/bangui/auth.log
|
||||
backend = polling
|
||||
maxretry = 3
|
||||
findtime = 120
|
||||
bantime = 60
|
||||
banaction = iptables-allports
|
||||
|
||||
# Never ban localhost, the Docker bridge network, or the host machine.
|
||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||
6
Docker/fail2ban-dev-config/fail2ban/jail.local
Normal file
6
Docker/fail2ban-dev-config/fail2ban/jail.local
Normal file
@@ -0,0 +1,6 @@
|
||||
# Local overrides — not overwritten by the container init script.
|
||||
# Provides banaction so all jails can resolve %(action_)s interpolation.
|
||||
|
||||
[DEFAULT]
|
||||
banaction = iptables-multiport
|
||||
banaction_allports = iptables-allports
|
||||
@@ -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`.
|
||||
142
Docs/Tasks.md
142
Docs/Tasks.md
@@ -4,97 +4,89 @@ This document breaks the entire BanGUI project into development stages, ordered
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Move "Configuration" to the Last Position in the Sidebar ✅ DONE
|
||||
## Open Issues
|
||||
|
||||
**Summary:** Moved the `Configuration` entry in `NAV_ITEMS` to the last position in `frontend/src/layouts/MainLayout.tsx`.
|
||||
|
||||
**File:** `frontend/src/layouts/MainLayout.tsx`
|
||||
|
||||
The `NAV_ITEMS` array (around line 183) defines the sidebar menu order. Currently the order is: Dashboard, World Map, Jails, **Configuration**, History, Blocklists. Move the Configuration entry so it is the **last** element in the array. The resulting order must be:
|
||||
|
||||
1. Dashboard
|
||||
2. World Map
|
||||
3. Jails
|
||||
4. History
|
||||
5. Blocklists
|
||||
6. Configuration
|
||||
|
||||
Only the position in the array changes. Do not modify the label, path, or icon of any item.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Auto-Recovery When Jail Activation Fails ✅ DONE
|
||||
### Task 1 — Blocklist-import jail ban time must be 24 hours
|
||||
|
||||
**Summary:** Added `recovered: bool | None` field to `JailActivationResponse` model. Implemented `_restore_local_file_sync` and `_rollback_activation_async` helpers. Updated `activate_jail` to back up the original `.local` file, roll back on any post-write failure (reload error, health-check failure, or jail not starting), and return `recovered=True/False`. Updated `ActivateJailDialog.tsx` to show warning/critical banners based on the `recovered` field. Added 3 new backend tests covering all rollback scenarios.
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Context:** When a user activates a jail via `POST /api/config/jails/{name}/activate`, the backend writes `enabled = true` to `jail.d/{name}.local` and then reloads fail2ban. If the new configuration is invalid or the server crashes after reload, fail2ban stays broken and all jails go offline. The system must automatically recover by rolling back the change and restarting fail2ban.
|
||||
**Context**
|
||||
|
||||
### Backend Changes
|
||||
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.
|
||||
|
||||
**File:** `backend/app/services/config_file_service.py` — `activate_jail()` method (around line 1086)
|
||||
**What to do**
|
||||
|
||||
Wrap the reload-and-verify sequence in error handling that performs a rollback on failure:
|
||||
|
||||
1. **Before writing** the `.local` override file, check whether a `.local` file for that jail already exists. If it does, read and keep its content in memory as a backup. If it does not exist, remember that no file existed.
|
||||
2. **Write** the override file with `enabled = true` (existing logic).
|
||||
3. **Reload** fail2ban via `jail_service.reload_all()` (existing logic).
|
||||
4. **Health-check / verify** that fail2ban is responsive and the jail appears in the active list (existing logic).
|
||||
5. **If any step after the write fails** (reload error, health-check timeout, jail not appearing):
|
||||
- **Rollback the config**: restore the original `.local` file content (or delete the file if it did not exist before).
|
||||
- **Restart fail2ban**: call `jail_service.reload_all()` again so fail2ban recovers with the old configuration.
|
||||
- **Health-check again** to confirm fail2ban is back.
|
||||
- Return an appropriate error response (HTTP 502 or 500) with a message that explains the activation failed **and** the system was recovered. Include a field `recovered: true` in the JSON body so the frontend can display a recovery notice.
|
||||
6. If rollback itself fails, return an error with `recovered: false` so the frontend can display a critical alert.
|
||||
|
||||
**File:** `backend/app/routers/config.py` — `activate_jail` endpoint (around line 584)
|
||||
|
||||
Propagate the `recovered` field in the error response. No extra logic is needed in the router if the service already raises an appropriate exception or returns a result object with the recovery status.
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
**File:** `frontend/src/components/config/JailsTab.tsx` (or wherever the activate mutation result is handled)
|
||||
|
||||
When the activation API call returns an error:
|
||||
- If `recovered` is `true`, show a warning banner/toast: *"Activation of jail '{name}' failed. The server has been automatically recovered."*
|
||||
- If `recovered` is `false`, show a critical error banner/toast: *"Activation of jail '{name}' failed and automatic recovery was unsuccessful. Manual intervention is required."*
|
||||
|
||||
### Tests
|
||||
|
||||
Add or extend tests in `backend/tests/test_services/test_config_file_service.py`:
|
||||
|
||||
- **test_activate_jail_rollback_on_reload_failure**: Mock `jail_service.reload_all()` to raise on the first call (activation reload) and succeed on the second call (recovery reload). Assert the `.local` file is restored to its original content and the response indicates `recovered: true`.
|
||||
- **test_activate_jail_rollback_on_health_check_failure**: Mock the health check to fail after reload. Assert rollback and recovery.
|
||||
- **test_activate_jail_rollback_failure**: Mock both the activation reload and the recovery reload to fail. Assert the response indicates `recovered: false`.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Match Pie Chart Slice Colors to Country Label Font Colors ✅ DONE
|
||||
### Task 2 — Clicking a jail in Jail Overview navigates to Configuration → Jails
|
||||
|
||||
**Summary:** Updated `legendFormatter` in `TopCountriesPieChart.tsx` to return `React.ReactNode` instead of `string`, using `<span style={{ color: entry.color }}>` to colour each legend label to match its pie slice. Imported `LegendPayload` from `recharts/types/component/DefaultLegendContent`.
|
||||
**Status:** ✅ Done
|
||||
|
||||
**Context:** The dashboard's Top Countries pie chart (`frontend/src/components/TopCountriesPieChart.tsx`) uses a color palette from `frontend/src/utils/chartTheme.ts` for the pie slices. The country names displayed in the legend next to the chart currently use the default text color. They should instead use the **same color as their corresponding pie slice**.
|
||||
**Context**
|
||||
|
||||
### Changes
|
||||
`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.
|
||||
|
||||
**File:** `frontend/src/components/TopCountriesPieChart.tsx`
|
||||
**What to do**
|
||||
|
||||
In the `<Legend>` component (rendered by Recharts), the `formatter` prop already receives the legend entry value. Apply a custom renderer so each country name is rendered with its matching slice color as the **font color**. The Recharts `<Legend>` accepts a `formatter` function whose second argument is the entry object containing the `color` property. Use that color to wrap the text in a `<span>` with `style={{ color: entry.color }}`. Example:
|
||||
|
||||
```tsx
|
||||
formatter={(value: string, entry: LegendPayload) => {
|
||||
const slice = slices.find((s) => s.name === value);
|
||||
if (slice == null || total === 0) return value;
|
||||
const pct = ((slice.value / total) * 100).toFixed(1);
|
||||
return (
|
||||
<span style={{ color: entry.color }}>
|
||||
{value} ({pct}%)
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
```
|
||||
|
||||
Make sure the `formatter` return type is `ReactNode` (not `string`). Import the Recharts `Payload` type if needed: `import type { Payload } from "recharts/types/component/DefaultLegendContent"` . Adjust the import path to match the Recharts version in the project.
|
||||
|
||||
Do **not** change the pie slice colors themselves — only the country label font color must match the slice it corresponds to.
|
||||
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,15 +361,88 @@ async def reload_fail2ban(
|
||||
_auth: Validated session.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 when fail2ban reports the reload failed.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except JailOperationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"fail2ban reload failed: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
# Restart endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restart",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Restart the fail2ban service",
|
||||
)
|
||||
async def restart_fail2ban(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> None:
|
||||
"""Trigger a full fail2ban service restart.
|
||||
|
||||
Stops the fail2ban daemon via the Unix domain socket, then starts it
|
||||
again using the configured ``fail2ban_start_command``. After starting,
|
||||
probes the socket for up to 10 seconds to confirm the daemon came back
|
||||
online.
|
||||
|
||||
Args:
|
||||
request: Incoming request.
|
||||
_auth: Validated session.
|
||||
|
||||
Raises:
|
||||
HTTPException: 409 when fail2ban reports the stop command failed.
|
||||
HTTPException: 502 when fail2ban is unreachable for the stop command.
|
||||
HTTPException: 503 when fail2ban does not come back online within
|
||||
10 seconds after being started. Check the fail2ban log for
|
||||
initialisation errors. Use
|
||||
``POST /api/config/jails/{name}/rollback`` if a specific jail
|
||||
is suspect.
|
||||
"""
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
start_cmd: str = request.app.state.settings.fail2ban_start_command
|
||||
start_cmd_parts: list[str] = start_cmd.split()
|
||||
|
||||
# Step 1: stop the daemon via socket.
|
||||
try:
|
||||
await jail_service.restart(socket_path)
|
||||
except JailOperationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"fail2ban stop command failed: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
# Step 2: start the daemon via subprocess.
|
||||
await config_file_service.start_daemon(start_cmd_parts)
|
||||
|
||||
# Step 3: probe the socket until fail2ban is responsive or the budget expires.
|
||||
fail2ban_running: bool = await config_file_service.wait_for_fail2ban(
|
||||
socket_path, max_wait_seconds=10.0
|
||||
)
|
||||
if not fail2ban_running:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"fail2ban was stopped but did not come back online within 10 seconds. "
|
||||
"Check the fail2ban log for initialisation errors. "
|
||||
"Use POST /api/config/jails/{name}/rollback if a specific jail is suspect."
|
||||
),
|
||||
)
|
||||
log.info("fail2ban_restarted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex tester (stateless)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -721,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,
|
||||
|
||||
@@ -55,6 +55,7 @@ from app.models.config import (
|
||||
RollbackResponse,
|
||||
)
|
||||
from app.services import conffile_parser, jail_service
|
||||
from app.services.jail_service import JailNotFoundError as JailNotFoundError
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
@@ -428,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.
|
||||
|
||||
@@ -435,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`.
|
||||
@@ -512,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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -739,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,
|
||||
@@ -763,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)
|
||||
@@ -1110,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",
|
||||
@@ -1231,6 +1240,30 @@ async def activate_jail(
|
||||
# ---------------------------------------------------------------------- #
|
||||
try:
|
||||
await jail_service.reload_all(socket_path, include_jails=[name])
|
||||
except JailNotFoundError as exc:
|
||||
# Jail configuration is invalid (e.g. missing logpath that prevents
|
||||
# fail2ban from loading the jail). Roll back and provide a specific error.
|
||||
log.warning(
|
||||
"reload_after_activate_failed_jail_not_found",
|
||||
jail=name,
|
||||
error=str(exc),
|
||||
)
|
||||
recovered = await _rollback_activation_async(
|
||||
config_dir, name, socket_path, original_content
|
||||
)
|
||||
return JailActivationResponse(
|
||||
name=name,
|
||||
active=False,
|
||||
fail2ban_running=False,
|
||||
recovered=recovered,
|
||||
validation_warnings=warnings,
|
||||
message=(
|
||||
f"Jail {name!r} activation failed: {str(exc)}. "
|
||||
"Check that all logpath files exist and are readable. "
|
||||
"The configuration was "
|
||||
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
||||
recovered = await _rollback_activation_async(
|
||||
@@ -1444,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,
|
||||
@@ -1516,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}%")
|
||||
|
||||
@@ -43,6 +43,13 @@ _SOCKET_TIMEOUT: float = 10.0
|
||||
# ensures only one reload stream is in-flight at a time.
|
||||
_reload_all_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
# Capability detection for optional fail2ban transmitter commands (backend, idle).
|
||||
# These commands are not supported in all fail2ban versions. Caching the result
|
||||
# avoids sending unsupported commands every polling cycle and spamming the
|
||||
# fail2ban log with "Invalid command" errors.
|
||||
_backend_cmd_supported: bool | None = None
|
||||
_backend_cmd_lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -185,6 +192,51 @@ async def _safe_get(
|
||||
return default
|
||||
|
||||
|
||||
async def _check_backend_cmd_supported(
|
||||
client: Fail2BanClient,
|
||||
jail_name: str,
|
||||
) -> bool:
|
||||
"""Detect whether the fail2ban daemon supports optional ``get ... backend`` command.
|
||||
|
||||
Some fail2ban versions (e.g. LinuxServer.io container) do not implement the
|
||||
optional ``get <jail> backend`` and ``get <jail> idle`` transmitter sub-commands.
|
||||
This helper probes the daemon once and caches the result to avoid repeated
|
||||
"Invalid command" errors in the fail2ban log.
|
||||
|
||||
Uses double-check locking to minimize lock contention in concurrent polls.
|
||||
|
||||
Args:
|
||||
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
|
||||
jail_name: Name of any jail to use for the probe command.
|
||||
|
||||
Returns:
|
||||
``True`` if the command is supported, ``False`` otherwise.
|
||||
Once determined, the result is cached and reused for all jails.
|
||||
"""
|
||||
global _backend_cmd_supported
|
||||
|
||||
# Fast path: return cached result if already determined.
|
||||
if _backend_cmd_supported is not None:
|
||||
return _backend_cmd_supported
|
||||
|
||||
# Slow path: acquire lock and probe the command once.
|
||||
async with _backend_cmd_lock:
|
||||
# Double-check idiom: another coroutine may have probed while we waited.
|
||||
if _backend_cmd_supported is not None:
|
||||
return _backend_cmd_supported
|
||||
|
||||
# Probe: send the command and catch any exception.
|
||||
try:
|
||||
_ok(await client.send(["get", jail_name, "backend"]))
|
||||
_backend_cmd_supported = True
|
||||
log.debug("backend_cmd_supported_detected")
|
||||
except Exception:
|
||||
_backend_cmd_supported = False
|
||||
log.debug("backend_cmd_unsupported_detected")
|
||||
|
||||
return _backend_cmd_supported
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Jail listing & detail
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -238,7 +290,11 @@ async def _fetch_jail_summary(
|
||||
"""Fetch and build a :class:`~app.models.jail.JailSummary` for one jail.
|
||||
|
||||
Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``,
|
||||
``backend``, and ``idle`` commands in parallel.
|
||||
``backend``, and ``idle`` commands in parallel (if supported).
|
||||
|
||||
The ``backend`` and ``idle`` commands are optional and not supported in
|
||||
all fail2ban versions. If not supported, this function will not send them
|
||||
to avoid spamming the fail2ban log with "Invalid command" errors.
|
||||
|
||||
Args:
|
||||
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
||||
@@ -247,15 +303,38 @@ async def _fetch_jail_summary(
|
||||
Returns:
|
||||
A :class:`~app.models.jail.JailSummary` populated from the responses.
|
||||
"""
|
||||
_r = await asyncio.gather(
|
||||
# Check whether optional backend/idle commands are supported.
|
||||
# This probe happens once per session and is cached to avoid repeated
|
||||
# "Invalid command" errors in the fail2ban log.
|
||||
backend_cmd_is_supported = await _check_backend_cmd_supported(client, name)
|
||||
|
||||
# Build the gather list based on command support.
|
||||
gather_list: list[Any] = [
|
||||
client.send(["status", name, "short"]),
|
||||
client.send(["get", name, "bantime"]),
|
||||
client.send(["get", name, "findtime"]),
|
||||
client.send(["get", name, "maxretry"]),
|
||||
client.send(["get", name, "backend"]),
|
||||
client.send(["get", name, "idle"]),
|
||||
return_exceptions=True,
|
||||
)
|
||||
]
|
||||
|
||||
if backend_cmd_is_supported:
|
||||
# Commands are supported; send them for real values.
|
||||
gather_list.extend([
|
||||
client.send(["get", name, "backend"]),
|
||||
client.send(["get", name, "idle"]),
|
||||
])
|
||||
uses_backend_backend_commands = True
|
||||
else:
|
||||
# Commands not supported; return default values without sending.
|
||||
async def _return_default(value: Any) -> tuple[int, Any]:
|
||||
return (0, value)
|
||||
|
||||
gather_list.extend([
|
||||
_return_default("polling"), # backend default
|
||||
_return_default(False), # idle default
|
||||
])
|
||||
uses_backend_backend_commands = False
|
||||
|
||||
_r = await asyncio.gather(*gather_list, return_exceptions=True)
|
||||
status_raw: Any = _r[0]
|
||||
bantime_raw: Any = _r[1]
|
||||
findtime_raw: Any = _r[2]
|
||||
@@ -569,7 +648,10 @@ async def reload_all(
|
||||
exclude_jails: Jail names to remove from the start stream.
|
||||
|
||||
Raises:
|
||||
JailOperationError: If fail2ban reports the operation failed.
|
||||
JailNotFoundError: If a jail in *include_jails* does not exist or
|
||||
its configuration is invalid (e.g. missing logpath).
|
||||
JailOperationError: If fail2ban reports the operation failed for
|
||||
a different reason.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
@@ -593,9 +675,43 @@ async def reload_all(
|
||||
_ok(await client.send(["reload", "--all", [], stream]))
|
||||
log.info("all_jails_reloaded")
|
||||
except ValueError as exc:
|
||||
# Detect UnknownJailException (missing or invalid jail configuration)
|
||||
# and re-raise as JailNotFoundError for better error specificity.
|
||||
if _is_not_found_error(exc):
|
||||
# Extract the jail name from include_jails if available.
|
||||
jail_name = include_jails[0] if include_jails else "unknown"
|
||||
raise JailNotFoundError(jail_name) from exc
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
async def restart(socket_path: str) -> None:
|
||||
"""Stop the fail2ban daemon via the Unix socket.
|
||||
|
||||
Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()``
|
||||
on the daemon side and tears down all jails. The caller is responsible
|
||||
for starting the daemon again (e.g. via ``fail2ban-client start``).
|
||||
|
||||
Note:
|
||||
``["restart"]`` is a *client-side* orchestration command that is not
|
||||
handled by the fail2ban server transmitter — sending it to the socket
|
||||
raises ``"Invalid command"`` in the daemon.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Raises:
|
||||
JailOperationError: If fail2ban reports the stop command failed.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||
cannot be reached.
|
||||
"""
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
try:
|
||||
_ok(await client.send(["stop"]))
|
||||
log.info("fail2ban_stopped_for_restart")
|
||||
except ValueError as exc:
|
||||
raise JailOperationError(str(exc)) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — Ban / Unban
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
@@ -3110,4 +3248,272 @@ class TestActivateJailRollback:
|
||||
assert result.active is False
|
||||
assert result.recovered is False
|
||||
|
||||
async def test_activate_jail_rollback_on_jail_not_found_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Rollback when reload_all raises JailNotFoundError (invalid config).
|
||||
|
||||
When fail2ban cannot create a jail due to invalid configuration
|
||||
(e.g., missing logpath), it raises UnknownJailException which becomes
|
||||
JailNotFoundError. This test verifies proper handling and rollback.
|
||||
|
||||
Expects:
|
||||
- The .local file is restored to its original content.
|
||||
- The response indicates recovered=True.
|
||||
- The error message mentions the logpath issue.
|
||||
"""
|
||||
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||
from app.services.jail_service import JailNotFoundError
|
||||
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
original_local = "[apache-auth]\nenabled = false\n"
|
||||
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(original_local)
|
||||
|
||||
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:
|
||||
# Simulate UnknownJailException from fail2ban due to missing logpath.
|
||||
raise JailNotFoundError("apache-auth")
|
||||
# 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)
|
||||
mock_js.JailNotFoundError = JailNotFoundError
|
||||
result = await activate_jail(
|
||||
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||
)
|
||||
|
||||
assert result.active is False
|
||||
assert result.recovered is True
|
||||
assert local_path.read_text() == original_local
|
||||
# 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
|
||||
|
||||
@@ -184,10 +184,90 @@ class TestListJails:
|
||||
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
|
||||
await jail_service.list_jails(_SOCKET)
|
||||
|
||||
async def test_backend_idle_commands_unsupported(self) -> None:
|
||||
"""list_jails handles unsupported backend and idle commands gracefully.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
When the fail2ban daemon does not support get ... backend/idle commands,
|
||||
list_jails should not send them, avoiding "Invalid command" errors in the
|
||||
fail2ban log.
|
||||
"""
|
||||
# Reset the capability cache to test detection.
|
||||
jail_service._backend_cmd_supported = None
|
||||
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
# Capability probe: get backend fails (command not supported).
|
||||
"get|sshd|backend": (1, Exception("Invalid command (no get action or not yet implemented)")),
|
||||
# Subsequent gets should still work.
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
# Verify the result uses the default values for backend and idle.
|
||||
jail = result.jails[0]
|
||||
assert jail.backend == "polling" # default
|
||||
assert jail.idle is False # default
|
||||
# Capability should now be cached as False.
|
||||
assert jail_service._backend_cmd_supported is False
|
||||
|
||||
async def test_backend_idle_commands_supported(self) -> None:
|
||||
"""list_jails detects and sends backend/idle commands when supported."""
|
||||
# Reset the capability cache to test detection.
|
||||
jail_service._backend_cmd_supported = None
|
||||
|
||||
responses = {
|
||||
"status": _make_global_status("sshd"),
|
||||
"status|sshd|short": _make_short_status(),
|
||||
# Capability probe: get backend succeeds.
|
||||
"get|sshd|backend": (0, "systemd"),
|
||||
# All other commands.
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|sshd|idle": (0, True),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
# Verify real values are returned.
|
||||
jail = result.jails[0]
|
||||
assert jail.backend == "systemd" # real value
|
||||
assert jail.idle is True # real value
|
||||
# Capability should now be cached as True.
|
||||
assert jail_service._backend_cmd_supported is True
|
||||
|
||||
async def test_backend_idle_commands_cached_after_first_probe(self) -> None:
|
||||
"""list_jails caches capability result and reuses it across polling cycles."""
|
||||
# Reset the capability cache.
|
||||
jail_service._backend_cmd_supported = None
|
||||
|
||||
responses = {
|
||||
"status": _make_global_status("sshd, nginx"),
|
||||
# Probes happen once per polling cycle (for the first jail listed).
|
||||
"status|sshd|short": _make_short_status(),
|
||||
"status|nginx|short": _make_short_status(),
|
||||
# Capability probe: backend is unsupported.
|
||||
"get|sshd|backend": (1, Exception("Invalid command")),
|
||||
# Subsequent jails do not trigger another probe; they use cached result.
|
||||
# (The mock doesn't have get|nginx|backend because it shouldn't be called.)
|
||||
"get|sshd|bantime": (0, 600),
|
||||
"get|sshd|findtime": (0, 600),
|
||||
"get|sshd|maxretry": (0, 5),
|
||||
"get|nginx|bantime": (0, 600),
|
||||
"get|nginx|findtime": (0, 600),
|
||||
"get|nginx|maxretry": (0, 5),
|
||||
}
|
||||
with _patch_client(responses):
|
||||
result = await jail_service.list_jails(_SOCKET)
|
||||
|
||||
# Both jails should return default values (cached result is False).
|
||||
for jail in result.jails:
|
||||
assert jail.backend == "polling"
|
||||
assert jail.idle is False
|
||||
|
||||
|
||||
class TestGetJail:
|
||||
@@ -339,6 +419,55 @@ class TestJailControls:
|
||||
_SOCKET, include_jails=["new"], exclude_jails=["old"]
|
||||
)
|
||||
|
||||
async def test_reload_all_unknown_jail_raises_jail_not_found(self) -> None:
|
||||
"""reload_all detects UnknownJailException and raises JailNotFoundError.
|
||||
|
||||
When fail2ban cannot load a jail due to invalid configuration (e.g.,
|
||||
missing logpath), it raises UnknownJailException during reload. This
|
||||
test verifies that reload_all detects this and re-raises as
|
||||
JailNotFoundError instead of the generic JailOperationError.
|
||||
"""
|
||||
with _patch_client(
|
||||
{
|
||||
"status": _make_global_status("sshd"),
|
||||
"reload|--all|[]|[['start', 'airsonic-auth'], ['start', 'sshd']]": (
|
||||
1,
|
||||
Exception("UnknownJailException('airsonic-auth')"),
|
||||
),
|
||||
}
|
||||
), pytest.raises(jail_service.JailNotFoundError) as exc_info:
|
||||
await jail_service.reload_all(
|
||||
_SOCKET, include_jails=["airsonic-auth"]
|
||||
)
|
||||
assert exc_info.value.name == "airsonic-auth"
|
||||
|
||||
async def test_restart_sends_stop_command(self) -> None:
|
||||
"""restart() sends the ['stop'] command to the fail2ban socket."""
|
||||
with _patch_client({"stop": (0, None)}):
|
||||
await jail_service.restart(_SOCKET) # should not raise
|
||||
|
||||
async def test_restart_operation_error_raises(self) -> None:
|
||||
"""restart() raises JailOperationError when fail2ban rejects the stop."""
|
||||
with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises(
|
||||
JailOperationError
|
||||
):
|
||||
await jail_service.restart(_SOCKET)
|
||||
|
||||
async def test_restart_connection_error_propagates(self) -> None:
|
||||
"""restart() propagates Fail2BanConnectionError when socket is unreachable."""
|
||||
|
||||
class _FailClient:
|
||||
def __init__(self, **_kw: Any) -> None:
|
||||
self.send = AsyncMock(
|
||||
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.jail_service.Fail2BanClient", _FailClient),
|
||||
pytest.raises(Fail2BanConnectionError),
|
||||
):
|
||||
await jail_service.restart(_SOCKET)
|
||||
|
||||
async def test_start_not_found_raises(self) -> None:
|
||||
"""start_jail raises JailNotFoundError for unknown jail."""
|
||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
||||
|
||||
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.8",
|
||||
"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,
|
||||
@@ -88,7 +86,7 @@ export async function updateGlobalConfig(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reload
|
||||
// Reload and Restart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function reloadConfig(
|
||||
@@ -96,6 +94,11 @@ export async function reloadConfig(
|
||||
await post<undefined>(ENDPOINTS.configReload, undefined);
|
||||
}
|
||||
|
||||
export async function restartFail2Ban(
|
||||
): Promise<void> {
|
||||
await post<undefined>(ENDPOINTS.configRestart, undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex tester
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -260,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(
|
||||
@@ -547,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -588,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,13 +71,13 @@ export const ENDPOINTS = {
|
||||
`/config/jails/${encodeURIComponent(name)}/activate`,
|
||||
configJailDeactivate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
||||
configJailLocalOverride: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/local`,
|
||||
configJailValidate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/validate`,
|
||||
configJailRollback: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/rollback`,
|
||||
configPendingRecovery: "/config/pending-recovery" as string,
|
||||
configGlobal: "/config/global",
|
||||
configReload: "/config/reload",
|
||||
configRestart: "/config/restart",
|
||||
configRegexTest: "/config/regex-test",
|
||||
configPreviewLog: "/config/preview-log",
|
||||
configMapColorThresholds: "/config/map-color-thresholds",
|
||||
@@ -104,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>
|
||||
)}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* GlobalTab — global fail2ban settings editor.
|
||||
*
|
||||
* Provides form fields for log level, log target, database purge age,
|
||||
* and database max matches.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
} from "@fluentui/react-components";
|
||||
import type { GlobalConfigUpdate } from "../../types/config";
|
||||
import { useGlobalConfig } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/** Available fail2ban log levels in descending severity order. */
|
||||
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
||||
|
||||
/**
|
||||
* Tab component for editing global fail2ban configuration.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function GlobalTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { config, loading, error, updateConfig } = useGlobalConfig();
|
||||
const [logLevel, setLogLevel] = useState("");
|
||||
const [logTarget, setLogTarget] = useState("");
|
||||
const [dbPurgeAge, setDbPurgeAge] = useState("");
|
||||
const [dbMaxMatches, setDbMaxMatches] = useState("");
|
||||
|
||||
// Sync local state when config loads for the first time.
|
||||
useEffect(() => {
|
||||
if (config && logLevel === "") {
|
||||
setLogLevel(config.log_level);
|
||||
setLogTarget(config.log_target);
|
||||
setDbPurgeAge(String(config.db_purge_age));
|
||||
setDbMaxMatches(String(config.db_max_matches));
|
||||
}
|
||||
// Only run on first config load.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
|
||||
const effectiveLogLevel = logLevel || config?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || config?.log_target || "";
|
||||
const effectiveDbPurgeAge =
|
||||
dbPurgeAge || (config ? String(config.db_purge_age) : "");
|
||||
const effectiveDbMaxMatches =
|
||||
dbMaxMatches || (config ? String(config.db_max_matches) : "");
|
||||
|
||||
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
|
||||
const update: GlobalConfigUpdate = {};
|
||||
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
|
||||
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
|
||||
if (effectiveDbPurgeAge)
|
||||
update.db_purge_age = Number(effectiveDbPurgeAge);
|
||||
if (effectiveDbMaxMatches)
|
||||
update.db_max_matches = Number(effectiveDbMaxMatches);
|
||||
return update;
|
||||
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(updatePayload, updateConfig);
|
||||
|
||||
if (loading) return <Spinner label="Loading global config…" />;
|
||||
if (error)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<AutoSaveIndicator
|
||||
status={saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Log Level">
|
||||
<Select
|
||||
value={effectiveLogLevel}
|
||||
onChange={(_e, d) => {
|
||||
setLogLevel(d.value);
|
||||
}}
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Log Target">
|
||||
<Input
|
||||
value={effectiveLogTarget}
|
||||
placeholder="STDOUT / /var/log/fail2ban.log"
|
||||
onChange={(_e, d) => {
|
||||
setLogTarget(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field
|
||||
label="DB Purge Age (s)"
|
||||
hint="Ban records older than this are removed from the fail2ban database."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbPurgeAge}
|
||||
onChange={(_e, d) => {
|
||||
setDbPurgeAge(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="DB Max Matches"
|
||||
hint="Maximum number of log-line matches stored per ban record."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbMaxMatches}
|
||||
onChange={(_e, d) => {
|
||||
setDbMaxMatches(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* MapTab — world map color threshold configuration editor.
|
||||
*
|
||||
* Allows the user to set the low / medium / high ban-count thresholds
|
||||
* that drive country fill colors on the World Map page.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
} from "../../api/config";
|
||||
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner form — only mounted after data is loaded.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MapFormProps {
|
||||
initial: MapColorThresholdsResponse;
|
||||
}
|
||||
|
||||
function MapForm({ initial }: MapFormProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
|
||||
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
|
||||
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
|
||||
|
||||
const high = Number(thresholdHigh);
|
||||
const medium = Number(thresholdMedium);
|
||||
const low = Number(thresholdLow);
|
||||
|
||||
const validationError = useMemo<string | null>(() => {
|
||||
if (isNaN(high) || isNaN(medium) || isNaN(low))
|
||||
return "All thresholds must be valid numbers.";
|
||||
if (high <= 0 || medium <= 0 || low <= 0)
|
||||
return "All thresholds must be positive integers.";
|
||||
if (!(high > medium && medium > low))
|
||||
return "Thresholds must satisfy: high > medium > low.";
|
||||
return null;
|
||||
}, [high, medium, low]);
|
||||
|
||||
// Only pass a new payload to useAutoSave when all values are valid.
|
||||
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||
threshold_high: initial.threshold_high,
|
||||
threshold_medium: initial.threshold_medium,
|
||||
threshold_low: initial.threshold_low,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (validationError !== null) return;
|
||||
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
|
||||
}, [high, medium, low, validationError]);
|
||||
|
||||
const saveThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
||||
useAutoSave(validPayload, saveThresholds);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.sectionCard}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Configure the ban count thresholds that determine country fill colors on
|
||||
the World Map. Countries with zero bans remain transparent. Colors
|
||||
smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={validationError ? "idle" : saveStatus}
|
||||
errorText={saveErrorText}
|
||||
onRetry={retrySave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{validationError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={thresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
size={200}
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
• 1 to {thresholdLow}: Light green → Full green
|
||||
<br />• {thresholdLow} to {thresholdMedium}: Green → Yellow
|
||||
<br />• {thresholdMedium} to {thresholdHigh}: Yellow → Red
|
||||
<br />• {thresholdHigh}+: Solid red
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outer loader component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tab component for editing world-map ban-count color thresholds.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function MapTab(): React.JSX.Element {
|
||||
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setThresholds(data);
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!thresholds && !loadError) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading map settings…">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
<SkeletonItem size={32} />
|
||||
</div>
|
||||
</Skeleton>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError)
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{loadError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
|
||||
if (!thresholds) return <></>;
|
||||
|
||||
return <MapForm initial={thresholds} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* LogTab — fail2ban log viewer and service health panel.
|
||||
* ServerHealthSection — service health panel and log viewer for ServerTab.
|
||||
*
|
||||
* Renders two sections:
|
||||
* 1. **Service Health panel** — shows online/offline state, version, active
|
||||
* jail count, total bans, total failures, log level, and log target.
|
||||
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
||||
* toolbar controls for line count, substring filter, manual refresh, and
|
||||
* optional auto-refresh. Log lines are color-coded by severity.
|
||||
* optional auto-refresh. Log lines are color-coded by severity.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -167,13 +167,11 @@ function detectSeverity(line: string): "error" | "warning" | "debug" | "default"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Log tab component for the Configuration page.
|
||||
*
|
||||
* Shows fail2ban service health and a live log viewer with refresh controls.
|
||||
* Server health panel and log viewer section for ServerTab.
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function LogTab(): React.JSX.Element {
|
||||
export function ServerHealthSection(): React.JSX.Element {
|
||||
const configStyles = useConfigStyles();
|
||||
const styles = useStyles();
|
||||
|
||||
@@ -317,10 +315,8 @@ export function LogTab(): React.JSX.Element {
|
||||
logData != null && logData.total_lines > logData.lines.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Service Health Panel */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<>
|
||||
{/* Service Health Panel */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||
<DocumentBulletList24Regular />
|
||||
@@ -384,9 +380,7 @@ export function LogTab(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Log Viewer */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Log Viewer */}
|
||||
<div className={configStyles.sectionCard}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
||||
<Text weight="semibold" size={400}>
|
||||
@@ -513,6 +507,6 @@ export function LogTab(): React.JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
* ServerTab — fail2ban server-level settings editor.
|
||||
*
|
||||
* Provides form fields for live server settings (log level, log target,
|
||||
* DB purge age, DB max matches) and a "Flush Logs" action button.
|
||||
* DB purge age, DB max matches), action buttons (flush logs, reload fail2ban,
|
||||
* restart fail2ban), world map color threshold configuration, and service
|
||||
* health + log viewer.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -15,16 +17,25 @@ import {
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
DocumentArrowDown24Regular,
|
||||
ArrowSync24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { ServerSettingsUpdate } from "../../types/config";
|
||||
import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useServerSettings } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
reloadConfig,
|
||||
restartFail2Ban,
|
||||
} from "../../api/config";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { ServerHealthSection } from "./ServerHealthSection";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
|
||||
/** Available fail2ban log levels in descending severity order. */
|
||||
@@ -46,6 +57,17 @@ export function ServerTab(): React.JSX.Element {
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
// Reload/Restart state
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
// Map color thresholds
|
||||
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
|
||||
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
|
||||
const [mapThresholdLow, setMapThresholdLow] = useState("");
|
||||
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
|
||||
|
||||
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
||||
const effectiveDbPurgeAge =
|
||||
@@ -83,6 +105,99 @@ export function ServerTab(): React.JSX.Element {
|
||||
}
|
||||
}, [flush]);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
setIsReloading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await reloadConfig();
|
||||
setMsg({ text: "fail2ban reloaded successfully", ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Reload failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setIsRestarting(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await restartFail2Ban();
|
||||
setMsg({ text: "fail2ban restart initiated", ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
text: err instanceof ApiError ? err.message : "Restart failed.",
|
||||
ok: false,
|
||||
});
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load map color thresholds on mount.
|
||||
const loadMapThresholds = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setMapThresholds(data);
|
||||
setMapThresholdHigh(String(data.threshold_high));
|
||||
setMapThresholdMedium(String(data.threshold_medium));
|
||||
setMapThresholdLow(String(data.threshold_low));
|
||||
setMapLoadError(null);
|
||||
} catch (err) {
|
||||
setMapLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMapThresholds();
|
||||
}, [loadMapThresholds]);
|
||||
|
||||
// Map threshold validation and auto-save.
|
||||
const mapHigh = Number(mapThresholdHigh);
|
||||
const mapMedium = Number(mapThresholdMedium);
|
||||
const mapLow = Number(mapThresholdLow);
|
||||
|
||||
const mapValidationError = useMemo<string | null>(() => {
|
||||
if (!mapThresholds) return null;
|
||||
if (isNaN(mapHigh) || isNaN(mapMedium) || isNaN(mapLow))
|
||||
return "All thresholds must be valid numbers.";
|
||||
if (mapHigh <= 0 || mapMedium <= 0 || mapLow <= 0)
|
||||
return "All thresholds must be positive integers.";
|
||||
if (!(mapHigh > mapMedium && mapMedium > mapLow))
|
||||
return "Thresholds must satisfy: high > medium > low.";
|
||||
return null;
|
||||
}, [mapHigh, mapMedium, mapLow, mapThresholds]);
|
||||
|
||||
const [mapValidPayload, setMapValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||
threshold_high: mapThresholds?.threshold_high ?? 0,
|
||||
threshold_medium: mapThresholds?.threshold_medium ?? 0,
|
||||
threshold_low: mapThresholds?.threshold_low ?? 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mapValidationError !== null || !mapThresholds) return;
|
||||
setMapValidPayload({
|
||||
threshold_high: mapHigh,
|
||||
threshold_medium: mapMedium,
|
||||
threshold_low: mapLow,
|
||||
});
|
||||
}, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]);
|
||||
|
||||
const saveMapThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
|
||||
useAutoSave(mapValidPayload, saveMapThresholds);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading server settings…">
|
||||
@@ -104,6 +219,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Service Health & Log Viewer section — shown first so users can
|
||||
immediately see whether fail2ban is reachable before editing settings. */}
|
||||
<ServerHealthSection />
|
||||
|
||||
<div className={styles.sectionCard}>
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
@@ -154,7 +273,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
</Field>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="DB Purge Age (s)">
|
||||
<Field
|
||||
label="DB Purge Age (s)"
|
||||
hint="Ban records older than this are removed from the fail2ban database."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbPurgeAge}
|
||||
@@ -163,7 +285,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DB Max Matches">
|
||||
<Field
|
||||
label="DB Max Matches"
|
||||
hint="Maximum number of log-line matches stored per ban record."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={effectiveDbMaxMatches}
|
||||
@@ -182,6 +307,22 @@ export function ServerTab(): React.JSX.Element {
|
||||
>
|
||||
{flushing ? "Flushing…" : "Flush Logs"}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowSync24Regular />}
|
||||
disabled={isReloading}
|
||||
onClick={() => void handleReload()}
|
||||
>
|
||||
{isReloading ? "Reloading…" : "Reload fail2ban"}
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowSync24Regular />}
|
||||
disabled={isRestarting}
|
||||
onClick={() => void handleRestart()}
|
||||
>
|
||||
{isRestarting ? "Restarting…" : "Restart fail2ban"}
|
||||
</Button>
|
||||
</div>
|
||||
{msg && (
|
||||
<MessageBar intent={msg.ok ? "success" : "error"}>
|
||||
@@ -189,6 +330,92 @@ export function ServerTab(): React.JSX.Element {
|
||||
</MessageBar>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map Color Thresholds section */}
|
||||
{mapLoadError ? (
|
||||
<div className={styles.sectionCard}>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{mapLoadError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
</div>
|
||||
) : mapThresholds ? (
|
||||
<div className={styles.sectionCard}>
|
||||
<Text as="h3" size={500} weight="semibold" block>
|
||||
Map Color Thresholds
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
size={300}
|
||||
className={styles.infoText}
|
||||
block
|
||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||
>
|
||||
Configure the ban count thresholds that determine country fill colors on
|
||||
the World Map. Countries with zero bans remain transparent. Colors
|
||||
smoothly interpolate between thresholds.
|
||||
</Text>
|
||||
|
||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<AutoSaveIndicator
|
||||
status={mapValidationError ? "idle" : mapSaveStatus}
|
||||
errorText={mapSaveErrorText}
|
||||
onRetry={retryMapSave}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapValidationError && (
|
||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
<MessageBarBody>{mapValidationError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Low Threshold (Green)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={mapThresholdLow}
|
||||
onChange={(_, d) => {
|
||||
setMapThresholdLow(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Medium Threshold (Yellow)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={mapThresholdMedium}
|
||||
onChange={(_, d) => {
|
||||
setMapThresholdMedium(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="High Threshold (Red)" required>
|
||||
<Input
|
||||
type="number"
|
||||
value={mapThresholdHigh}
|
||||
onChange={(_, d) => {
|
||||
setMapThresholdHigh(d.value);
|
||||
}}
|
||||
min={1}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
as="p"
|
||||
size={200}
|
||||
className={styles.infoText}
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
• 1 to {mapThresholdLow}: Light green → Full green
|
||||
<br />• {mapThresholdLow} to {mapThresholdMedium}: Green → Yellow
|
||||
<br />• {mapThresholdMedium} to {mapThresholdHigh}: Yellow → Red
|
||||
<br />• {mapThresholdHigh}+: Solid red
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Tests for the LogTab component (Task 2).
|
||||
*/
|
||||
|
||||
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 { LogTab } from "../LogTab";
|
||||
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("../../../api/config", () => ({
|
||||
fetchFail2BanLog: vi.fn(),
|
||||
fetchServiceStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
|
||||
|
||||
const mockFetchLog = vi.mocked(fetchFail2BanLog);
|
||||
const mockFetchStatus = vi.mocked(fetchServiceStatus);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const onlineStatus: ServiceStatusResponse = {
|
||||
online: true,
|
||||
version: "1.0.2",
|
||||
jail_count: 3,
|
||||
total_bans: 12,
|
||||
total_failures: 5,
|
||||
log_level: "INFO",
|
||||
log_target: "/var/log/fail2ban.log",
|
||||
};
|
||||
|
||||
const offlineStatus: ServiceStatusResponse = {
|
||||
online: false,
|
||||
version: null,
|
||||
jail_count: 0,
|
||||
total_bans: 0,
|
||||
total_failures: 0,
|
||||
log_level: "UNKNOWN",
|
||||
log_target: "UNKNOWN",
|
||||
};
|
||||
|
||||
const logResponse: Fail2BanLogResponse = {
|
||||
log_path: "/var/log/fail2ban.log",
|
||||
lines: [
|
||||
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
|
||||
"2025-01-01 12:00:01 WARNING sshd Too many failures",
|
||||
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
|
||||
],
|
||||
total_lines: 1000,
|
||||
log_level: "INFO",
|
||||
log_target: "/var/log/fail2ban.log",
|
||||
};
|
||||
|
||||
const nonFileLogResponse: Fail2BanLogResponse = {
|
||||
...logResponse,
|
||||
log_target: "STDOUT",
|
||||
lines: [],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderTab() {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<LogTab />
|
||||
</FluentProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("LogTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows a spinner while loading", () => {
|
||||
// Never resolves during this test.
|
||||
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
|
||||
mockFetchLog.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
renderTab();
|
||||
|
||||
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the health panel with Running badge when online", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
|
||||
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.0.2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
|
||||
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
|
||||
});
|
||||
|
||||
it("renders the Offline badge and warning when fail2ban is down", async () => {
|
||||
mockFetchStatus.mockResolvedValue(offlineStatus);
|
||||
mockFetchLog.mockRejectedValue(new Error("not running"));
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
|
||||
|
||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
||||
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders log lines in the log viewer", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a non-file target info banner when log_target is STDOUT", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(nonFileLogResponse);
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Refresh/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state when no lines match the filter", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows truncation notice when total_lines > lines.length", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
|
||||
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/showing last/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetchFail2BanLog again on Refresh button click", async () => {
|
||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
||||
mockFetchLog.mockResolvedValue(logResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderTab();
|
||||
|
||||
await waitFor(() => { expect(screen.getByText(/Refresh/)).toBeInTheDocument(); });
|
||||
|
||||
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
|
||||
await user.click(refreshBtn);
|
||||
|
||||
await waitFor(() => { expect(mockFetchLog).toHaveBeenCalledTimes(2); });
|
||||
});
|
||||
});
|
||||
@@ -30,16 +30,14 @@ export { ExportTab } from "./ExportTab";
|
||||
export { FilterForm } from "./FilterForm";
|
||||
export type { FilterFormProps } from "./FilterForm";
|
||||
export { FiltersTab } from "./FiltersTab";
|
||||
export { GlobalTab } from "./GlobalTab";
|
||||
export { JailFilesTab } from "./JailFilesTab";
|
||||
export { JailFileForm } from "./JailFileForm";
|
||||
export { JailsTab } from "./JailsTab";
|
||||
export { LogTab } from "./LogTab";
|
||||
export { MapTab } from "./MapTab";
|
||||
export { RawConfigSection } from "./RawConfigSection";
|
||||
export type { RawConfigSectionProps } from "./RawConfigSection";
|
||||
export { RegexList } from "./RegexList";
|
||||
export type { RegexListProps } from "./RegexList";
|
||||
export { RegexTesterTab } from "./RegexTesterTab";
|
||||
export { ServerTab } from "./ServerTab";
|
||||
export { ServerHealthSection } from "./ServerHealthSection";
|
||||
export { useConfigStyles } from "./configStyles";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -8,22 +8,18 @@
|
||||
* Jails — per-jail config accordion with inline editing
|
||||
* Filters — structured filter.d form editor
|
||||
* Actions — structured action.d form editor
|
||||
* Global — global fail2ban settings (log level, DB config)
|
||||
* Server — server-level settings + flush logs
|
||||
* Map — map color threshold configuration
|
||||
* Server — server-level settings, map thresholds, service health + log viewer
|
||||
* Regex Tester — live pattern tester
|
||||
* 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,
|
||||
FiltersTab,
|
||||
GlobalTab,
|
||||
JailsTab,
|
||||
LogTab,
|
||||
MapTab,
|
||||
RegexTesterTab,
|
||||
ServerTab,
|
||||
} from "../components/config";
|
||||
@@ -58,16 +54,21 @@ type TabValue =
|
||||
| "jails"
|
||||
| "filters"
|
||||
| "actions"
|
||||
| "global"
|
||||
| "server"
|
||||
| "map"
|
||||
| "regex"
|
||||
| "log";
|
||||
| "regex";
|
||||
|
||||
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}>
|
||||
@@ -89,22 +90,20 @@ export function ConfigPage(): React.JSX.Element {
|
||||
<Tab value="jails">Jails</Tab>
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
<Tab value="global">Global</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="map">Map</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
<Tab value="log">Log</Tab>
|
||||
</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 === "global" && <GlobalTab />}
|
||||
{tab === "server" && <ServerTab />}
|
||||
{tab === "map" && <MapTab />}
|
||||
{tab === "regex" && <RegexTesterTab />}
|
||||
{tab === "log" && <LogTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,12 +6,14 @@ 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>,
|
||||
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
MapTab: () => <div data-testid="map-tab">MapTab</div>,
|
||||
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
||||
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
||||
}));
|
||||
@@ -45,12 +47,6 @@ describe("ConfigPage", () => {
|
||||
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Global tab when Global tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
|
||||
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Server tab when Server tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
||||
@@ -61,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;
|
||||
|
||||
@@ -30,7 +30,12 @@ import { tokens } from "@fluentui/react-components";
|
||||
export function resolveFluentToken(tokenValue: string): string {
|
||||
const match = /var\((--[^,)]+)/.exec(tokenValue);
|
||||
if (match == null || match[1] == null) return tokenValue;
|
||||
const resolved = getComputedStyle(document.documentElement)
|
||||
|
||||
// FluentProvider injects CSS custom properties on its own wrapper <div>,
|
||||
// not on :root. Query that element so we resolve actual colour values.
|
||||
const el =
|
||||
document.querySelector(".fui-FluentProvider") ?? document.documentElement;
|
||||
const resolved = getComputedStyle(el)
|
||||
.getPropertyValue(match[1])
|
||||
.trim();
|
||||
return resolved !== "" ? resolved : tokenValue;
|
||||
|
||||
@@ -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