Compare commits
15 Commits
61daa8bbc0
...
5b7d1a4360
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b7d1a4360 | |||
| e7834a888e | |||
| abb224e01b | |||
| 57cf93b1e5 | |||
| c41165c294 | |||
| cdf73e2d65 | |||
| 21753c4f06 | |||
| eb859af371 | |||
| 5a5c619a34 | |||
| 00119ed68d | |||
| b81e0cdbb4 | |||
| 41dcd60225 | |||
| 12f04bd8d6 | |||
| d4d04491d2 | |||
| 93dc699825 |
@@ -10,7 +10,7 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── Stage 1: build dependencies ──────────────────────────────
|
# ── Stage 1: build dependencies ──────────────────────────────
|
||||||
FROM python:3.12-slim AS builder
|
FROM docker.io/library/python:3.12-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ RUN pip install --no-cache-dir --upgrade pip \
|
|||||||
&& pip install --no-cache-dir .
|
&& pip install --no-cache-dir .
|
||||||
|
|
||||||
# ── Stage 2: runtime image ───────────────────────────────────
|
# ── Stage 2: runtime image ───────────────────────────────────
|
||||||
FROM python:3.12-slim AS runtime
|
FROM docker.io/library/python:3.12-slim AS runtime
|
||||||
|
|
||||||
LABEL maintainer="BanGUI" \
|
LABEL maintainer="BanGUI" \
|
||||||
description="BanGUI backend — fail2ban web management API"
|
description="BanGUI backend — fail2ban web management API"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# ── Stage 1: install & build ─────────────────────────────────
|
# ── Stage 1: install & build ─────────────────────────────────
|
||||||
FROM node:22-alpine AS builder
|
FROM docker.io/library/node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ COPY frontend/ /build/
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 2: serve with nginx ────────────────────────────────
|
# ── 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" \
|
LABEL maintainer="BanGUI" \
|
||||||
description="BanGUI frontend — fail2ban web management UI"
|
description="BanGUI frontend — fail2ban web management UI"
|
||||||
|
|||||||
1
Docker/VERSION
Normal file
1
Docker/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v0.9.3
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# check_ban_status.sh
|
# 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.
|
# container and optionally unbans a specific IP.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly CONTAINER="bangui-fail2ban-dev"
|
readonly CONTAINER="bangui-fail2ban-dev"
|
||||||
readonly JAIL="bangui-sim"
|
readonly JAIL="manual-Jail"
|
||||||
|
|
||||||
# ── Helper: run a fail2ban-client command inside the container ─
|
# ── Helper: run a fail2ban-client command inside the container ─
|
||||||
f2b() {
|
f2b() {
|
||||||
|
|||||||
73
Docker/docker-compose.yml
Normal file
73
Docker/docker-compose.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
fail2ban:
|
||||||
|
image: lscr.io/linuxserver/fail2ban:latest
|
||||||
|
container_name: fail2ban
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
- TZ=Etc/UTC
|
||||||
|
- VERBOSITY=-vv #optional
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /server/server_fail2ban/config:/config
|
||||||
|
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
|
||||||
|
- /var/log:/var/log
|
||||||
|
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
|
||||||
|
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
|
||||||
|
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
|
||||||
|
|
||||||
|
|
||||||
|
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
|
||||||
|
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
|
||||||
|
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
|
||||||
|
container_name: bangui-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
fail2ban:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
- BANGUI_DATABASE_PATH=/data/bangui.db
|
||||||
|
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
||||||
|
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
|
||||||
|
- BANGUI_LOG_LEVEL=info
|
||||||
|
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
|
||||||
|
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
|
||||||
|
volumes:
|
||||||
|
- /server/server_fail2ban/bangui-data:/data
|
||||||
|
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
|
||||||
|
- /server/server_fail2ban/config:/config:rw
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
|
|
||||||
|
# ── Frontend (nginx serving built SPA + API proxy) ──────────
|
||||||
|
frontend:
|
||||||
|
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
|
||||||
|
container_name: bangui-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
ports:
|
||||||
|
- "${BANGUI_PORT:-8080}:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bangui-net:
|
||||||
|
name: bangui-net
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This directory contains the fail2ban configuration and supporting scripts for a
|
This directory contains the fail2ban configuration and supporting scripts for a
|
||||||
self-contained development test environment. A simulation script writes fake
|
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
|
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
|
||||||
without a real service.
|
without a real service.
|
||||||
|
|
||||||
@@ -71,14 +71,14 @@ Chains steps 1–3 automatically with appropriate sleep intervals.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines |
|
| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines |
|
||||||
| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
|
| `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) |
|
| `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`
|
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
||||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
(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
|
```ini
|
||||||
maxretry = 3 # failures before a ban
|
maxretry = 3 # failures before a ban
|
||||||
@@ -108,14 +108,14 @@ Test the regex manually:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec bangui-fail2ban-dev \
|
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
|
The output should show matched lines. If nothing matches, check that the log
|
||||||
lines match the corresponding `failregex` pattern:
|
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>
|
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
|
### IP not banned despite enough failures
|
||||||
|
|
||||||
Check whether the source IP falls inside the `ignoreip` range defined in
|
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
|
```ini
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
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
|
# Matches lines written by Docker/simulate_failed_logins.sh
|
||||||
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
||||||
|
# Jail: manual-Jail
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Definition]
|
[Definition]
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
# for lines produced by Docker/simulate_failed_logins.sh.
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[bangui-sim]
|
[manual-Jail]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
filter = bangui-sim
|
filter = manual-Jail
|
||||||
logpath = /remotelogs/bangui/auth.log
|
logpath = /remotelogs/bangui/auth.log
|
||||||
backend = polling
|
backend = polling
|
||||||
maxretry = 3
|
maxretry = 3
|
||||||
75
Docker/release.sh
Normal file
75
Docker/release.sh
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bump the project version and push images to the registry.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./release.sh
|
||||||
|
#
|
||||||
|
# The current version is stored in VERSION (next to this script).
|
||||||
|
# You will be asked whether to bump major, minor, or patch.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERSION_FILE="${SCRIPT_DIR}/VERSION"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Read current version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ ! -f "${VERSION_FILE}" ]]; then
|
||||||
|
echo "0.0.0" > "${VERSION_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT="$(cat "${VERSION_FILE}")"
|
||||||
|
# Strip leading 'v' for arithmetic
|
||||||
|
VERSION="${CURRENT#v}"
|
||||||
|
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " BanGUI — Release"
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "How would you like to bump the version?"
|
||||||
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||||
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo ""
|
||||||
|
read -rp "Enter choice [1/2/3]: " CHOICE
|
||||||
|
|
||||||
|
case "${CHOICE}" in
|
||||||
|
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||||
|
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||||
|
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Aborting." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "New version: ${NEW_TAG}"
|
||||||
|
read -rp "Confirm? [y/N]: " CONFIRM
|
||||||
|
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write new version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "${NEW_TAG}" > "${VERSION_FILE}"
|
||||||
|
echo "Version file updated → ${VERSION_FILE}"
|
||||||
|
|
||||||
|
# Keep frontend/package.json in sync so __APP_VERSION__ matches Docker/VERSION.
|
||||||
|
FRONT_VERSION="${NEW_TAG#v}"
|
||||||
|
FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||||
|
echo "frontend/package.json version updated → ${FRONT_VERSION}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||||
|
bash "${SCRIPT_DIR}/push.sh"
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# simulate_failed_logins.sh
|
# simulate_failed_logins.sh
|
||||||
#
|
#
|
||||||
# Writes synthetic authentication-failure log lines to a file
|
# Writes synthetic authentication-failure log lines to a file
|
||||||
# that matches the bangui-sim fail2ban filter.
|
# that matches the manual-Jail fail2ban filter.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# bash Docker/simulate_failed_logins.sh [COUNT] [SOURCE_IP] [LOG_FILE]
|
# bash Docker/simulate_failed_logins.sh [COUNT] [SOURCE_IP] [LOG_FILE]
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
# SOURCE_IP: 192.168.100.99
|
# SOURCE_IP: 192.168.100.99
|
||||||
# LOG_FILE : Docker/logs/auth.log (relative to repo root)
|
# 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>
|
# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
148
Docs/Tasks.md
148
Docs/Tasks.md
@@ -4,136 +4,56 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agent Operating Instructions
|
## Open Issues
|
||||||
|
|
||||||
These instructions apply to every AI agent working in this repository. Read them fully before touching any file.
|
### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done
|
||||||
|
|
||||||
### Before You Begin
|
**Implemented:**
|
||||||
|
- `frontend/vite.config.ts`: reads `package.json#version` at build time and injects it as the global `__APP_VERSION__` via Vite `define`.
|
||||||
|
- `frontend/src/vite-env.d.ts`: adds `declare const __APP_VERSION__: string` so TypeScript knows about the global.
|
||||||
|
- `frontend/src/layouts/MainLayout.tsx`: renders `BanGUI v{__APP_VERSION__}` in the sidebar footer when expanded (hidden when collapsed).
|
||||||
|
- `frontend/src/components/ServerStatusBar.tsx`: tooltip changed from `"fail2ban version"` to `"fail2ban daemon version"`.
|
||||||
|
- `Docker/release.sh`: after bumping `VERSION`, also updates `frontend/package.json#version` via `sed` to keep them in sync.
|
||||||
|
- `frontend/package.json`: version bumped from `0.9.0` to `0.9.3` to match `Docker/VERSION`.
|
||||||
|
- Tests added: `src/components/__tests__/ServerStatusBar.test.tsx`, `src/layouts/__tests__/MainLayout.test.tsx`.
|
||||||
|
|
||||||
1. Read [Instructions.md](Instructions.md) in full — it defines the project context, coding standards, and workflow rules. Every rule there is authoritative and takes precedence over any assumption you make.
|
**Problem:** The `ServerStatusBar` component on the Dashboard displays `v{status.version}`, which is the **fail2ban daemon version** (e.g. `v1.1.0`). The BanGUI application version lives in `Docker/VERSION` (e.g. `v0.9.3`) and is unrelated to the fail2ban version. Users see a version number they don't recognise and assume it reflects the BanGUI release.
|
||||||
2. Read [Architekture.md](Architekture.md) to understand the system structure before touching any component.
|
|
||||||
3. Read the development guide relevant to your task: [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) (or both).
|
|
||||||
4. Read [Features.md](Features.md) so you understand what the product is supposed to do and do not accidentally break intended behaviour.
|
|
||||||
|
|
||||||
### How to Work Through This Document
|
**Goal:** Make the distinction clear and expose the BanGUI application version.
|
||||||
|
|
||||||
- Tasks are grouped by feature area. Each group is self-contained.
|
**Suggested approach:**
|
||||||
- Work through tasks in the order they appear within a group; earlier tasks establish foundations for later ones.
|
1. Inject the BanGUI app version at build time — add a `define` entry in `frontend/vite.config.ts` that reads the `version` field from `frontend/package.json` (e.g. `__APP_VERSION__`). Keep `frontend/package.json` and `Docker/VERSION` in sync (update the release script `Docker/release.sh` or `Makefile` to write `package.json#version` from `VERSION`).
|
||||||
- Mark a task **in-progress** before you start it and **completed** the moment it is done. Never batch completions.
|
2. Show the BanGUI version in the sidebar footer inside `MainLayout.tsx` (collapsed view: show only when expanded, or via tooltip). This is the natural place for an "about" version tag.
|
||||||
- If a task depends on another task that is not yet complete, stop and complete the dependency first.
|
3. Update the fail2ban version tooltip in `ServerStatusBar.tsx` from the generic `"fail2ban version"` to something like `"fail2ban daemon version"` so the two are no longer visually indistinguishable.
|
||||||
- If you are uncertain whether a change is correct, read the relevant documentation section again before proceeding. Do not guess.
|
|
||||||
|
|
||||||
### Code Quality Rules (Summary)
|
**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`.
|
||||||
|
|
||||||
- No TODOs, no placeholders, no half-finished functions.
|
|
||||||
- Full type annotations on every function (Python) and full TypeScript types on every symbol (no `any`).
|
|
||||||
- Layered architecture: routers → services → repositories. No layer may skip another.
|
|
||||||
- All backend errors are raised as typed HTTP exceptions; all unexpected errors are logged via structlog before re-raising.
|
|
||||||
- All frontend state lives in typed hooks; no raw `fetch` calls outside of the `api/` layer.
|
|
||||||
- After every code change, run the full test suite (`make test`) and ensure it is green.
|
|
||||||
|
|
||||||
### Definition of Done
|
|
||||||
|
|
||||||
A task is done when:
|
|
||||||
- The code compiles and the test suite passes (`make test`).
|
|
||||||
- The feature works end-to-end in the dev stack (`make up`).
|
|
||||||
- No new lint errors are introduced.
|
|
||||||
- The change is consistent with all documentation rules.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug Fixes
|
### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done
|
||||||
|
|
||||||
|
**Implemented:** In `frontend/src/components/ServerStatusBar.tsx`, changed the `Failures:` label to `Failed Attempts:` and updated the tooltip from `"Currently failing IPs"` to `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Updated `ServerStatusBar.test.tsx` to assert the new label text.
|
||||||
|
|
||||||
|
**Problem:** The `ServerStatusBar` shows a "Failures: 42" counter with the tooltip `"Currently failing IPs"`. In fail2ban terminology *failures* are individual **failed authentication attempts** tracked in the fail2ban DB, not the number of unique IPs that failed. The current wording is ambiguous and misleading — users may think it means broken connections or error states.
|
||||||
|
|
||||||
|
**Goal:** Replace the tooltip with accurate, self-explanatory wording.
|
||||||
|
|
||||||
|
**Suggested fix:** Change the `Tooltip` content for the Failures stat in `ServerStatusBar.tsx` from `"Currently failing IPs"` to something like `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Additionally, consider renaming the label from `"Failures:"` to `"Failed Attempts:"` to match the tooltip language.
|
||||||
|
|
||||||
|
**Files:** `frontend/src/components/ServerStatusBar.tsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### BUG-001 — fail2ban: `bangui-sim` jail fails to start due to missing `banaction`
|
### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done
|
||||||
|
|
||||||
**Status:** Done
|
**Implemented:** In `frontend/src/components/config/ServerTab.tsx`, moved `<ServerHealthSection />` from the end of the JSX return to be the first element rendered inside the tab container, before all settings fields.
|
||||||
|
|
||||||
**Summary:** `jail.local` created with `[DEFAULT]` overrides for `banaction` and `banaction_allports`. The container init script (`init-fail2ban-config`) overwrites `jail.conf` from the image's `/defaults/` on every start, so modifying `jail.conf` directly is ineffective. `jail.local` is not in the container's defaults and thus persists correctly. Additionally fixed a `TypeError` in `config_file_service.py` where `except jail_service.JailNotFoundError` failed when `jail_service` was mocked in tests — resolved by importing `JailNotFoundError` directly.
|
**Problem:** In the Config page → Server tab, the `Service Health` panel (`ServerHealthSection`) is rendered at the bottom of the tab, after all settings sections (log level, log target, DB purge settings, map thresholds, reload/restart buttons). This means users must scroll past all editable fields to check service connectivity status, even though the health status is the most critical piece of context — it indicates whether the server is reachable at all.
|
||||||
|
|
||||||
#### Error
|
**Goal:** Move the `<ServerHealthSection />` block to the **top** of the `ServerTab` render output, before any settings fields.
|
||||||
|
|
||||||
```
|
**Suggested fix:** In `frontend/src/components/config/ServerTab.tsx`, move the `{/* Service Health & Log Viewer section */}` block (currently at the end of the JSX return around line 415) to be the first section rendered inside the tab container.
|
||||||
Failed during configuration: Bad value substitution: option 'action' in section 'bangui-sim'
|
|
||||||
contains an interpolation key 'banaction' which is not a valid option name.
|
|
||||||
Raw value: '%(action_)s'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Root Cause
|
**Files:** `frontend/src/components/config/ServerTab.tsx`.
|
||||||
|
|
||||||
fail2ban's interpolation system resolves option values at configuration load time by
|
|
||||||
substituting `%(key)s` placeholders with values from the same section or from `[DEFAULT]`.
|
|
||||||
|
|
||||||
The chain that fails is:
|
|
||||||
|
|
||||||
1. Every jail inherits `action = %(action_)s` from `[DEFAULT]` (no override in `bangui-sim.conf`).
|
|
||||||
2. `action_` is defined in `[DEFAULT]` as `%(banaction)s[port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]`.
|
|
||||||
3. `banaction` is **commented out** in `[DEFAULT]`:
|
|
||||||
```ini
|
|
||||||
# Docker/fail2ban-dev-config/fail2ban/jail.conf [DEFAULT]
|
|
||||||
#banaction = iptables-multiport ← this line is disabled
|
|
||||||
```
|
|
||||||
4. Because `banaction` is absent from the interpolation namespace, fail2ban cannot resolve
|
|
||||||
`action_`, which makes it unable to resolve `action`, and the jail fails to load.
|
|
||||||
|
|
||||||
The same root cause affects every jail in `jail.d/` that does not define its own `banaction`,
|
|
||||||
including `blocklist-import.conf`.
|
|
||||||
|
|
||||||
#### Fix
|
|
||||||
|
|
||||||
**File:** `Docker/fail2ban-dev-config/fail2ban/jail.conf`
|
|
||||||
|
|
||||||
Uncomment the `banaction` line inside the `[DEFAULT]` section so the value is globally
|
|
||||||
available to all jails:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
banaction = iptables-multiport
|
|
||||||
banaction_allports = iptables-allports
|
|
||||||
```
|
|
||||||
|
|
||||||
This is safe: the dev compose (`Docker/compose.debug.yml`) already grants the fail2ban
|
|
||||||
container `NET_ADMIN` and `NET_RAW` capabilities, which are the prerequisites for
|
|
||||||
iptables-based banning.
|
|
||||||
|
|
||||||
#### Tasks
|
|
||||||
|
|
||||||
- [x] **BUG-001-T1 — Add `banaction` override via `jail.local` [DEFAULT]**
|
|
||||||
|
|
||||||
Open `Docker/fail2ban-dev-config/fail2ban/jail.conf`.
|
|
||||||
Find the two commented-out lines near the `action_` definition:
|
|
||||||
```ini
|
|
||||||
#banaction = iptables-multiport
|
|
||||||
#banaction_allports = iptables-allports
|
|
||||||
```
|
|
||||||
Remove the leading `#` from both lines so they become active options.
|
|
||||||
Do not change any other part of the file.
|
|
||||||
|
|
||||||
- [x] **BUG-001-T2 — Restart the fail2ban container and verify clean startup**
|
|
||||||
|
|
||||||
Bring the dev stack down and back up:
|
|
||||||
```bash
|
|
||||||
make down && make up
|
|
||||||
```
|
|
||||||
Wait for the fail2ban container to reach `healthy`, then inspect its logs:
|
|
||||||
```bash
|
|
||||||
make logs # or: docker logs bangui-fail2ban-dev 2>&1 | grep -i error
|
|
||||||
```
|
|
||||||
Confirm that no `Bad value substitution` or `Failed during configuration` lines appear
|
|
||||||
and that both `bangui-sim` and `blocklist-import` jails show as **enabled** in the output.
|
|
||||||
|
|
||||||
- [x] **BUG-001-T3 — Verify ban/unban cycle works end-to-end**
|
|
||||||
|
|
||||||
With the stack running, trigger the simulation script:
|
|
||||||
```bash
|
|
||||||
bash Docker/simulate_failed_logins.sh
|
|
||||||
```
|
|
||||||
Then confirm fail2ban has recorded a ban:
|
|
||||||
```bash
|
|
||||||
bash Docker/check_ban_status.sh
|
|
||||||
```
|
|
||||||
The script should report at least one banned IP in the `bangui-sim` jail.
|
|
||||||
Also verify that the BanGUI dashboard reflects the new ban entry.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -85,4 +85,4 @@ def get_settings() -> Settings:
|
|||||||
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
|
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
|
||||||
if required keys are absent or values fail validation.
|
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
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from app.routers import (
|
|||||||
)
|
)
|
||||||
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
|
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.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
||||||
|
from app.utils.jail_config import ensure_jail_configs
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
||||||
@@ -137,7 +138,13 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
log.info("bangui_starting_up", database_path=settings.database_path)
|
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 ---
|
# --- 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: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
await init_db(db)
|
await init_db(db)
|
||||||
@@ -320,17 +327,15 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
|
|||||||
if path.startswith("/api") and not getattr(
|
if path.startswith("/api") and not getattr(
|
||||||
request.app.state, "_setup_complete_cached", False
|
request.app.state, "_setup_complete_cached", False
|
||||||
):
|
):
|
||||||
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
|
from app.services import setup_service # noqa: PLC0415
|
||||||
if db is not None:
|
|
||||||
from app.services import setup_service # noqa: PLC0415
|
|
||||||
|
|
||||||
if await setup_service.is_setup_complete(db):
|
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
|
||||||
request.app.state._setup_complete_cached = True
|
if db is None or not await setup_service.is_setup_complete(db):
|
||||||
else:
|
return RedirectResponse(
|
||||||
return RedirectResponse(
|
url="/api/setup",
|
||||||
url="/api/setup",
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
||||||
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
)
|
||||||
)
|
request.app.state._setup_complete_cached = True
|
||||||
|
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|||||||
@@ -807,6 +807,14 @@ class InactiveJail(BaseModel):
|
|||||||
"inactive jails that appear in this list."
|
"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):
|
class InactiveJailListResponse(BaseModel):
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
import structlog
|
||||||
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
||||||
|
|
||||||
from app.dependencies import AuthDep
|
from app.dependencies import AuthDep
|
||||||
|
|
||||||
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
ActionConfig,
|
ActionConfig,
|
||||||
ActionCreateRequest,
|
ActionCreateRequest,
|
||||||
@@ -97,6 +100,7 @@ from app.services.config_service import (
|
|||||||
ConfigValidationError,
|
ConfigValidationError,
|
||||||
JailNotFoundError,
|
JailNotFoundError,
|
||||||
)
|
)
|
||||||
|
from app.services.jail_service import JailOperationError
|
||||||
from app.tasks.health_check import _run_probe
|
from app.tasks.health_check import _run_probe
|
||||||
from app.utils.fail2ban_client import Fail2BanConnectionError
|
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||||
|
|
||||||
@@ -357,11 +361,17 @@ async def reload_fail2ban(
|
|||||||
_auth: Validated session.
|
_auth: Validated session.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
HTTPException: 409 when fail2ban reports the reload failed.
|
||||||
HTTPException: 502 when fail2ban is unreachable.
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
"""
|
"""
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
try:
|
try:
|
||||||
await jail_service.reload_all(socket_path)
|
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:
|
except Fail2BanConnectionError as exc:
|
||||||
raise _bad_gateway(exc) from exc
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
@@ -381,24 +391,57 @@ async def restart_fail2ban(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Trigger a full fail2ban service restart.
|
"""Trigger a full fail2ban service restart.
|
||||||
|
|
||||||
The fail2ban daemon is completely stopped and then started again,
|
Stops the fail2ban daemon via the Unix domain socket, then starts it
|
||||||
re-reading all configuration files in the process.
|
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:
|
Args:
|
||||||
request: Incoming request.
|
request: Incoming request.
|
||||||
_auth: Validated session.
|
_auth: Validated session.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException: 502 when fail2ban is unreachable.
|
HTTPException: 409 when fail2ban reports the stop command failed.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable for the stop command.
|
||||||
|
HTTPException: 503 when fail2ban does not come back online within
|
||||||
|
10 seconds after being started. Check the fail2ban log for
|
||||||
|
initialisation errors. Use
|
||||||
|
``POST /api/config/jails/{name}/rollback`` if a specific jail
|
||||||
|
is suspect.
|
||||||
"""
|
"""
|
||||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
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:
|
try:
|
||||||
# Perform restart by sending the restart command via the fail2ban socket.
|
|
||||||
# If fail2ban is not running, this will raise an exception, and we return 502.
|
|
||||||
await jail_service.restart(socket_path)
|
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:
|
except Fail2BanConnectionError as exc:
|
||||||
raise _bad_gateway(exc) from 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)
|
# Regex tester (stateless)
|
||||||
@@ -755,6 +798,60 @@ async def deactivate_jail(
|
|||||||
return result
|
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)
|
# 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
|
* ``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
|
* ``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`` — list all action files
|
||||||
* ``GET /api/config/actions/{name}`` — get one action file (with content)
|
* ``GET /api/config/actions/{name}/raw`` — get one action file (raw content)
|
||||||
* ``PUT /api/config/actions/{name}`` — update an action file
|
* ``PUT /api/config/actions/{name}/raw`` — update an action file (raw content)
|
||||||
* ``POST /api/config/actions`` — create a new action file
|
* ``POST /api/config/actions`` — create a new action file
|
||||||
* ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model
|
* ``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
|
* ``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(
|
@router.get(
|
||||||
"/actions/{name}",
|
"/actions/{name}/raw",
|
||||||
response_model=ConfFileContent,
|
response_model=ConfFileContent,
|
||||||
summary="Return an action definition file with its content",
|
summary="Return an action definition file with its content",
|
||||||
)
|
)
|
||||||
@@ -496,7 +496,7 @@ async def get_action_file(
|
|||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/actions/{name}",
|
"/actions/{name}/raw",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
summary="Update an action definition file",
|
summary="Update an action definition file",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ def _build_inactive_jail(
|
|||||||
name: str,
|
name: str,
|
||||||
settings: dict[str, str],
|
settings: dict[str, str],
|
||||||
source_file: str,
|
source_file: str,
|
||||||
|
config_dir: Path | None = None,
|
||||||
) -> InactiveJail:
|
) -> InactiveJail:
|
||||||
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
|
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
|
||||||
|
|
||||||
@@ -436,6 +437,8 @@ def _build_inactive_jail(
|
|||||||
name: Jail section name.
|
name: Jail section name.
|
||||||
settings: Merged key→value dict (DEFAULT values already applied).
|
settings: Merged key→value dict (DEFAULT values already applied).
|
||||||
source_file: Path of the file that last defined this section.
|
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:
|
Returns:
|
||||||
Populated :class:`~app.models.config.InactiveJail`.
|
Populated :class:`~app.models.config.InactiveJail`.
|
||||||
@@ -513,6 +516,11 @@ def _build_inactive_jail(
|
|||||||
bantime_escalation=bantime_escalation,
|
bantime_escalation=bantime_escalation,
|
||||||
source_file=source_file,
|
source_file=source_file,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
|
has_local_override=(
|
||||||
|
(config_dir / "jail.d" / f"{name}.local").is_file()
|
||||||
|
if config_dir is not None
|
||||||
|
else False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -740,7 +748,7 @@ async def _probe_fail2ban_running(socket_path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_fail2ban(
|
async def wait_for_fail2ban(
|
||||||
socket_path: str,
|
socket_path: str,
|
||||||
max_wait_seconds: float = 10.0,
|
max_wait_seconds: float = 10.0,
|
||||||
poll_interval: float = 2.0,
|
poll_interval: float = 2.0,
|
||||||
@@ -764,7 +772,7 @@ async def _wait_for_fail2ban(
|
|||||||
return False
|
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*.
|
"""Start the fail2ban daemon using *start_cmd_parts*.
|
||||||
|
|
||||||
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
|
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
|
||||||
@@ -1111,7 +1119,7 @@ async def list_inactive_jails(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
source = source_files.get(jail_name, config_dir)
|
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(
|
log.info(
|
||||||
"inactive_jails_listed",
|
"inactive_jails_listed",
|
||||||
@@ -1469,6 +1477,57 @@ async def deactivate_jail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_jail_local_override(
|
||||||
|
config_dir: str,
|
||||||
|
socket_path: str,
|
||||||
|
name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
|
||||||
|
This is the clean-up action shown in the config UI when an inactive jail
|
||||||
|
still has a ``.local`` override file (e.g. ``enabled = false``). The
|
||||||
|
file is deleted outright; no fail2ban reload is required because the jail
|
||||||
|
is already inactive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dir: Absolute path to the fail2ban configuration directory.
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
name: Name of the jail whose ``.local`` file should be removed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JailNameError: If *name* contains invalid characters.
|
||||||
|
JailNotFoundInConfigError: If *name* is not defined in any config file.
|
||||||
|
JailAlreadyActiveError: If the jail is currently active (refusing to
|
||||||
|
delete the live config file).
|
||||||
|
ConfigWriteError: If the file cannot be deleted.
|
||||||
|
"""
|
||||||
|
_safe_jail_name(name)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
all_jails, _source_files = await loop.run_in_executor(
|
||||||
|
None, _parse_jails_sync, Path(config_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
if name not in all_jails:
|
||||||
|
raise JailNotFoundInConfigError(name)
|
||||||
|
|
||||||
|
active_names = await _get_active_jail_names(socket_path)
|
||||||
|
if name in active_names:
|
||||||
|
raise JailAlreadyActiveError(name)
|
||||||
|
|
||||||
|
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, lambda: local_path.unlink(missing_ok=True)
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigWriteError(
|
||||||
|
f"Failed to delete {local_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
log.info("jail_local_override_deleted", jail=name, path=str(local_path))
|
||||||
|
|
||||||
|
|
||||||
async def validate_jail_config(
|
async def validate_jail_config(
|
||||||
config_dir: str,
|
config_dir: str,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -1541,11 +1600,11 @@ async def rollback_jail(
|
|||||||
log.info("jail_rolled_back_disabled", jail=name)
|
log.info("jail_rolled_back_disabled", jail=name)
|
||||||
|
|
||||||
# Attempt to start the daemon.
|
# 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)
|
log.info("jail_rollback_start_attempted", jail=name, start_ok=started)
|
||||||
|
|
||||||
# Wait for the socket to come back.
|
# 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
|
socket_path, max_wait_seconds=10.0, poll_interval=2.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -685,24 +685,29 @@ async def reload_all(
|
|||||||
|
|
||||||
|
|
||||||
async def restart(socket_path: str) -> None:
|
async def restart(socket_path: str) -> None:
|
||||||
"""Restart the fail2ban service (daemon).
|
"""Stop the fail2ban daemon via the Unix socket.
|
||||||
|
|
||||||
Sends the 'restart' command to the fail2ban daemon via the Unix socket.
|
Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()``
|
||||||
All jails are stopped and the daemon is restarted, re-reading all
|
on the daemon side and tears down all jails. The caller is responsible
|
||||||
configuration from scratch.
|
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:
|
Args:
|
||||||
socket_path: Path to the fail2ban Unix domain socket.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
JailOperationError: If fail2ban reports the stop command failed.
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||||
cannot be reached.
|
cannot be reached.
|
||||||
"""
|
"""
|
||||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
_ok(await client.send(["restart"]))
|
_ok(await client.send(["stop"]))
|
||||||
log.info("fail2ban_restarted")
|
log.info("fail2ban_stopped_for_restart")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise JailOperationError(str(exc)) from exc
|
raise JailOperationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|||||||
93
backend/app/utils/jail_config.py
Normal file
93
backend/app/utils/jail_config.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Utilities for ensuring required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
BanGUI requires two custom jails — ``manual-Jail`` and ``blocklist-import``
|
||||||
|
— to be present in the fail2ban ``jail.d`` directory. This module provides
|
||||||
|
:func:`ensure_jail_configs` which checks each of the four files
|
||||||
|
(``*.conf`` template + ``*.local`` override) and creates any that are missing
|
||||||
|
with the correct default content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default file contents
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_JAIL_CONF = """\
|
||||||
|
[manual-Jail]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter = manual-Jail
|
||||||
|
logpath = /remotelogs/bangui/auth.log
|
||||||
|
backend = polling
|
||||||
|
maxretry = 3
|
||||||
|
findtime = 120
|
||||||
|
bantime = 60
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MANUAL_JAIL_LOCAL = """\
|
||||||
|
[manual-Jail]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_CONF = """\
|
||||||
|
[blocklist-import]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter =
|
||||||
|
logpath = /dev/null
|
||||||
|
backend = auto
|
||||||
|
maxretry = 1
|
||||||
|
findtime = 1d
|
||||||
|
bantime = 1w
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_LOCAL = """\
|
||||||
|
[blocklist-import]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File registry: (filename, default_content)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_JAIL_FILES: list[tuple[str, str]] = [
|
||||||
|
("manual-Jail.conf", _MANUAL_JAIL_CONF),
|
||||||
|
("manual-Jail.local", _MANUAL_JAIL_LOCAL),
|
||||||
|
("blocklist-import.conf", _BLOCKLIST_IMPORT_CONF),
|
||||||
|
("blocklist-import.local", _BLOCKLIST_IMPORT_LOCAL),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_jail_configs(jail_d_path: Path) -> None:
|
||||||
|
"""Ensure the required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
Checks for ``manual-Jail.conf``, ``manual-Jail.local``,
|
||||||
|
``blocklist-import.conf``, and ``blocklist-import.local`` inside
|
||||||
|
*jail_d_path*. Any file that is missing is created with its default
|
||||||
|
content. Existing files are **never** overwritten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jail_d_path: Path to the fail2ban ``jail.d`` directory. Will be
|
||||||
|
created (including all parents) if it does not already exist.
|
||||||
|
"""
|
||||||
|
jail_d_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for filename, default_content in _JAIL_FILES:
|
||||||
|
file_path = jail_d_path / filename
|
||||||
|
if file_path.exists():
|
||||||
|
log.debug("jail_config_already_exists", path=str(file_path))
|
||||||
|
else:
|
||||||
|
file_path.write_text(default_content, encoding="utf-8")
|
||||||
|
log.info("jail_config_created", path=str(file_path))
|
||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bangui-backend"
|
name = "bangui-backend"
|
||||||
version = "0.1.0"
|
version = "0.9.0"
|
||||||
description = "BanGUI backend — fail2ban web management interface"
|
description = "BanGUI backend — fail2ban web management interface"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -370,6 +370,124 @@ class TestReloadFail2ban:
|
|||||||
|
|
||||||
assert resp.status_code == 204
|
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
|
# POST /api/config/regex-test
|
||||||
|
|||||||
@@ -377,6 +377,102 @@ class TestCreateActionFile:
|
|||||||
assert resp.json()["name"] == "myaction"
|
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
|
# POST /api/config/jail-files
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.main import create_app
|
from app.main import _lifespan, create_app
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared setup payload
|
# Shared setup payload
|
||||||
@@ -286,3 +286,151 @@ class TestSetupCompleteCaching:
|
|||||||
# Cache was warm — is_setup_complete must not have been called.
|
# Cache was warm — is_setup_complete must not have been called.
|
||||||
assert call_count == 0
|
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,
|
activate_jail,
|
||||||
deactivate_jail,
|
deactivate_jail,
|
||||||
list_inactive_jails,
|
list_inactive_jails,
|
||||||
|
rollback_jail,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -289,6 +290,28 @@ class TestBuildInactiveJail:
|
|||||||
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
|
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
|
||||||
assert jail.enabled is True
|
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
|
# _write_local_override_sync
|
||||||
@@ -424,6 +447,121 @@ class TestListInactiveJails:
|
|||||||
assert "sshd" in names
|
assert "sshd" in names
|
||||||
assert "apache-auth" 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
|
# activate_jail
|
||||||
@@ -3173,5 +3311,209 @@ class TestActivateJailRollback:
|
|||||||
# Verify the error message mentions logpath issues.
|
# Verify the error message mentions logpath issues.
|
||||||
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -441,6 +441,33 @@ class TestJailControls:
|
|||||||
)
|
)
|
||||||
assert exc_info.value.name == "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:
|
async def test_start_not_found_raises(self) -> None:
|
||||||
"""start_jail raises JailNotFoundError for unknown jail."""
|
"""start_jail raises JailNotFoundError for unknown jail."""
|
||||||
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
|
||||||
|
|||||||
134
backend/tests/test_utils/test_jail_config.py
Normal file
134
backend/tests/test_utils/test_jail_config.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Tests for app.utils.jail_config.ensure_jail_configs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.utils.jail_config import (
|
||||||
|
_BLOCKLIST_IMPORT_CONF,
|
||||||
|
_BLOCKLIST_IMPORT_LOCAL,
|
||||||
|
_MANUAL_JAIL_CONF,
|
||||||
|
_MANUAL_JAIL_LOCAL,
|
||||||
|
ensure_jail_configs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expected filenames
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_CONF = "manual-Jail.conf"
|
||||||
|
_MANUAL_LOCAL = "manual-Jail.local"
|
||||||
|
_BLOCKLIST_CONF = "blocklist-import.conf"
|
||||||
|
_BLOCKLIST_LOCAL = "blocklist-import.local"
|
||||||
|
|
||||||
|
_ALL_FILES = [_MANUAL_CONF, _MANUAL_LOCAL, _BLOCKLIST_CONF, _BLOCKLIST_LOCAL]
|
||||||
|
|
||||||
|
_CONTENT_MAP: dict[str, str] = {
|
||||||
|
_MANUAL_CONF: _MANUAL_JAIL_CONF,
|
||||||
|
_MANUAL_LOCAL: _MANUAL_JAIL_LOCAL,
|
||||||
|
_BLOCKLIST_CONF: _BLOCKLIST_IMPORT_CONF,
|
||||||
|
_BLOCKLIST_LOCAL: _BLOCKLIST_IMPORT_LOCAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read(jail_d: Path, filename: str) -> str:
|
||||||
|
return (jail_d / filename).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: ensure_jail_configs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureJailConfigs:
|
||||||
|
def test_all_missing_creates_all_four(self, tmp_path: Path) -> None:
|
||||||
|
"""All four files are created when the directory is empty."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert (jail_d / name).exists(), f"{name} should have been created"
|
||||||
|
assert _read(jail_d, name) == _CONTENT_MAP[name]
|
||||||
|
|
||||||
|
def test_all_missing_creates_correct_content(self, tmp_path: Path) -> None:
|
||||||
|
"""Each created file has exactly the expected default content."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# .conf files must set enabled = false
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
content = _read(jail_d, conf_file)
|
||||||
|
assert "enabled = false" in content
|
||||||
|
|
||||||
|
# .local files must set enabled = true and nothing else
|
||||||
|
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
|
||||||
|
content = _read(jail_d, local_file)
|
||||||
|
assert "enabled = true" in content
|
||||||
|
|
||||||
|
def test_all_present_overwrites_nothing(self, tmp_path: Path) -> None:
|
||||||
|
"""Existing files are never overwritten."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
jail_d.mkdir()
|
||||||
|
|
||||||
|
sentinel = "# EXISTING CONTENT — must not be replaced\n"
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
(jail_d / name).write_text(sentinel, encoding="utf-8")
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert _read(jail_d, name) == sentinel, (
|
||||||
|
f"{name} should not have been overwritten"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_local_files_missing_creates_only_locals(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Only the .local files are created when the .conf files already exist."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
jail_d.mkdir()
|
||||||
|
|
||||||
|
sentinel = "# pre-existing conf\n"
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
(jail_d / conf_file).write_text(sentinel, encoding="utf-8")
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# .conf files must remain unchanged
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
assert _read(jail_d, conf_file) == sentinel
|
||||||
|
|
||||||
|
# .local files must have been created with correct content
|
||||||
|
for local_file, expected in (
|
||||||
|
(_MANUAL_LOCAL, _MANUAL_JAIL_LOCAL),
|
||||||
|
(_BLOCKLIST_LOCAL, _BLOCKLIST_IMPORT_LOCAL),
|
||||||
|
):
|
||||||
|
assert (jail_d / local_file).exists(), f"{local_file} should have been created"
|
||||||
|
assert _read(jail_d, local_file) == expected
|
||||||
|
|
||||||
|
def test_creates_jail_d_directory_if_missing(self, tmp_path: Path) -> None:
|
||||||
|
"""The jail.d directory is created automatically when absent."""
|
||||||
|
jail_d = tmp_path / "nested" / "jail.d"
|
||||||
|
assert not jail_d.exists()
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
assert jail_d.is_dir()
|
||||||
|
|
||||||
|
def test_idempotent_on_repeated_calls(self, tmp_path: Path) -> None:
|
||||||
|
"""Calling ensure_jail_configs twice does not alter any file."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# Record content after first call
|
||||||
|
first_pass = {name: _read(jail_d, name) for name in _ALL_FILES}
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert _read(jail_d, name) == first_pass[name], (
|
||||||
|
f"{name} changed on second call"
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.9.3",
|
||||||
"description": "BanGUI frontend — fail2ban web management interface",
|
"description": "BanGUI frontend — fail2ban web management interface",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,22 +7,16 @@
|
|||||||
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import { ENDPOINTS } from "./endpoints";
|
import { ENDPOINTS } from "./endpoints";
|
||||||
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
|
import type { LoginResponse, LogoutResponse } from "../types/auth";
|
||||||
import { sha256Hex } from "../utils/crypto";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate with the master password.
|
* 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.
|
* @param password - The master password entered by the user.
|
||||||
* @returns The login response containing the session token.
|
* @returns The login response containing the session token.
|
||||||
*/
|
*/
|
||||||
export async function login(password: string): Promise<LoginResponse> {
|
export async function login(password: string): Promise<LoginResponse> {
|
||||||
const body: LoginRequest = { password: await sha256Hex(password) };
|
return api.post<LoginResponse>(ENDPOINTS.authLogin, { password });
|
||||||
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,10 +39,8 @@ import type {
|
|||||||
LogPreviewResponse,
|
LogPreviewResponse,
|
||||||
MapColorThresholdsResponse,
|
MapColorThresholdsResponse,
|
||||||
MapColorThresholdsUpdate,
|
MapColorThresholdsUpdate,
|
||||||
PendingRecovery,
|
|
||||||
RegexTestRequest,
|
RegexTestRequest,
|
||||||
RegexTestResponse,
|
RegexTestResponse,
|
||||||
RollbackResponse,
|
|
||||||
ServerSettingsResponse,
|
ServerSettingsResponse,
|
||||||
ServerSettingsUpdate,
|
ServerSettingsUpdate,
|
||||||
JailFileConfig,
|
JailFileConfig,
|
||||||
@@ -265,14 +263,14 @@ export async function fetchActionFiles(): Promise<ConfFilesResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchActionFile(name: string): Promise<ConfFileContent> {
|
export async function fetchActionFile(name: string): Promise<ConfFileContent> {
|
||||||
return get<ConfFileContent>(ENDPOINTS.configAction(name));
|
return get<ConfFileContent>(ENDPOINTS.configActionRaw(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateActionFile(
|
export async function updateActionFile(
|
||||||
name: string,
|
name: string,
|
||||||
req: ConfFileUpdateRequest
|
req: ConfFileUpdateRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await put<undefined>(ENDPOINTS.configAction(name), req);
|
await put<undefined>(ENDPOINTS.configActionRaw(name), req);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createActionFile(
|
export async function createActionFile(
|
||||||
@@ -552,6 +550,18 @@ export async function deactivateJail(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
*
|
||||||
|
* Only valid when the jail is **not** currently active. Use this to clean up
|
||||||
|
* leftover ``.local`` files after a jail has been fully deactivated.
|
||||||
|
*
|
||||||
|
* @param name - The jail name.
|
||||||
|
*/
|
||||||
|
export async function deleteJailLocalOverride(name: string): Promise<void> {
|
||||||
|
await del<undefined>(ENDPOINTS.configJailLocalOverride(name));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// fail2ban log viewer (Task 2)
|
// fail2ban log viewer (Task 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -593,21 +603,3 @@ export async function validateJailConfig(
|
|||||||
): Promise<JailValidationResult> {
|
): Promise<JailValidationResult> {
|
||||||
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
|
return post<JailValidationResult>(ENDPOINTS.configJailValidate(name), undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the pending crash-recovery record, if any.
|
|
||||||
*
|
|
||||||
* Returns null when fail2ban is healthy and no recovery is pending.
|
|
||||||
*/
|
|
||||||
export async function fetchPendingRecovery(): Promise<PendingRecovery | null> {
|
|
||||||
return get<PendingRecovery | null>(ENDPOINTS.configPendingRecovery);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rollback a bad jail — disables it and attempts to restart fail2ban.
|
|
||||||
*
|
|
||||||
* @param name - Name of the jail to disable.
|
|
||||||
*/
|
|
||||||
export async function rollbackJail(name: string): Promise<RollbackResponse> {
|
|
||||||
return post<RollbackResponse>(ENDPOINTS.configJailRollback(name), undefined);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,11 +71,10 @@ export const ENDPOINTS = {
|
|||||||
`/config/jails/${encodeURIComponent(name)}/activate`,
|
`/config/jails/${encodeURIComponent(name)}/activate`,
|
||||||
configJailDeactivate: (name: string): string =>
|
configJailDeactivate: (name: string): string =>
|
||||||
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
||||||
|
configJailLocalOverride: (name: string): string =>
|
||||||
|
`/config/jails/${encodeURIComponent(name)}/local`,
|
||||||
configJailValidate: (name: string): string =>
|
configJailValidate: (name: string): string =>
|
||||||
`/config/jails/${encodeURIComponent(name)}/validate`,
|
`/config/jails/${encodeURIComponent(name)}/validate`,
|
||||||
configJailRollback: (name: string): string =>
|
|
||||||
`/config/jails/${encodeURIComponent(name)}/rollback`,
|
|
||||||
configPendingRecovery: "/config/pending-recovery" as string,
|
|
||||||
configGlobal: "/config/global",
|
configGlobal: "/config/global",
|
||||||
configReload: "/config/reload",
|
configReload: "/config/reload",
|
||||||
configRestart: "/config/restart",
|
configRestart: "/config/restart",
|
||||||
@@ -105,6 +104,7 @@ export const ENDPOINTS = {
|
|||||||
`/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`,
|
`/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`,
|
||||||
configActions: "/config/actions",
|
configActions: "/config/actions",
|
||||||
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
|
configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`,
|
||||||
|
configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`,
|
||||||
configActionParsed: (name: string): string =>
|
configActionParsed: (name: string): string =>
|
||||||
`/config/actions/${encodeURIComponent(name)}/parsed`,
|
`/config/actions/${encodeURIComponent(name)}/parsed`,
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function ServerStatusBar(): React.JSX.Element {
|
|||||||
{/* Version */}
|
{/* Version */}
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{status?.version != null && (
|
{status?.version != null && (
|
||||||
<Tooltip content="fail2ban version" relationship="description">
|
<Tooltip content="fail2ban daemon version" relationship="description">
|
||||||
<Text size={200} className={styles.statValue}>
|
<Text size={200} className={styles.statValue}>
|
||||||
v{status.version}
|
v{status.version}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -139,9 +139,9 @@ export function ServerStatusBar(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</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}>
|
<div className={styles.statGroup}>
|
||||||
<Text size={200}>Failures:</Text>
|
<Text size={200}>Failed Attempts:</Text>
|
||||||
<Text size={200} className={styles.statValue}>
|
<Text size={200} className={styles.statValue}>
|
||||||
{status.total_failures}
|
{status.total_failures}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
|||||||
if (!cancelled) setStatus(res.completed ? "done" : "pending");
|
if (!cancelled) setStatus(res.completed ? "done" : "pending");
|
||||||
})
|
})
|
||||||
.catch((): void => {
|
.catch((): void => {
|
||||||
// If the check fails, optimistically allow through — the backend will
|
// A failed check conservatively redirects to /setup — a crashed
|
||||||
// redirect API calls to /api/setup anyway.
|
// backend cannot serve protected routes anyway.
|
||||||
if (!cancelled) setStatus("done");
|
if (!cancelled) setStatus("pending");
|
||||||
});
|
});
|
||||||
return (): void => {
|
return (): void => {
|
||||||
cancelled = true;
|
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
|
* findtime, maxretry, port and logpath. Calls the activate endpoint on
|
||||||
* confirmation and propagates the result via callbacks.
|
* confirmation and propagates the result via callbacks.
|
||||||
*
|
*
|
||||||
* Task 3 additions:
|
* Runs pre-activation validation when the dialog opens and displays any
|
||||||
* - Runs pre-activation validation when the dialog opens and displays any
|
* warnings or blocking errors before the user confirms.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -52,11 +48,6 @@ export interface ActivateJailDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Called after the jail has been successfully activated. */
|
/** Called after the jail has been successfully activated. */
|
||||||
onActivated: () => void;
|
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,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onActivated,
|
onActivated,
|
||||||
onCrashDetected,
|
|
||||||
}: ActivateJailDialogProps): React.JSX.Element {
|
}: ActivateJailDialogProps): React.JSX.Element {
|
||||||
const [bantime, setBantime] = useState("");
|
const [bantime, setBantime] = useState("");
|
||||||
const [findtime, setFindtime] = useState("");
|
const [findtime, setFindtime] = useState("");
|
||||||
@@ -173,9 +163,6 @@ export function ActivateJailDialog({
|
|||||||
setValidationWarnings(result.validation_warnings);
|
setValidationWarnings(result.validation_warnings);
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
if (!result.fail2ban_running) {
|
|
||||||
onCrashDetected?.();
|
|
||||||
}
|
|
||||||
onActivated();
|
onActivated();
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
@@ -339,9 +326,10 @@ export function ActivateJailDialog({
|
|||||||
style={{ marginTop: tokens.spacingVerticalS }}
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
>
|
>
|
||||||
<MessageBarBody>
|
<MessageBarBody>
|
||||||
<MessageBarTitle>Activation Failed — System Recovered</MessageBarTitle>
|
<MessageBarTitle>Activation Failed — Configuration Rolled Back</MessageBarTitle>
|
||||||
Activation of jail “{jail.name}” failed. The server
|
The configuration for jail “{jail.name}” has been
|
||||||
has been automatically recovered.
|
rolled back to its previous state and fail2ban is running
|
||||||
|
normally. Review the configuration and try activating again.
|
||||||
</MessageBarBody>
|
</MessageBarBody>
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
@@ -351,10 +339,12 @@ export function ActivateJailDialog({
|
|||||||
style={{ marginTop: tokens.spacingVerticalS }}
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
>
|
>
|
||||||
<MessageBarBody>
|
<MessageBarBody>
|
||||||
<MessageBarTitle>Activation Failed — Manual Intervention Required</MessageBarTitle>
|
<MessageBarTitle>Activation Failed — Rollback Unsuccessful</MessageBarTitle>
|
||||||
Activation of jail “{jail.name}” failed and
|
Activation of jail “{jail.name}” failed and the
|
||||||
automatic recovery was unsuccessful. Manual intervention is
|
automatic rollback did not complete. The file{" "}
|
||||||
required.
|
<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>
|
</MessageBarBody>
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { ApiError } from "../../api/client";
|
|||||||
import {
|
import {
|
||||||
addLogPath,
|
addLogPath,
|
||||||
deactivateJail,
|
deactivateJail,
|
||||||
|
deleteJailLocalOverride,
|
||||||
deleteLogPath,
|
deleteLogPath,
|
||||||
fetchInactiveJails,
|
fetchInactiveJails,
|
||||||
fetchJailConfigFileContent,
|
fetchJailConfigFileContent,
|
||||||
@@ -573,7 +574,7 @@ function JailConfigDetail({
|
|||||||
</div>
|
</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" }}>
|
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
|
||||||
{onValidate !== undefined && (
|
{onValidate !== undefined && (
|
||||||
<Button
|
<Button
|
||||||
@@ -585,6 +586,15 @@ function JailConfigDetail({
|
|||||||
{validating ? "Validating…" : "Validate Config"}
|
{validating ? "Validating…" : "Validate Config"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDeactivate !== undefined && (
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
icon={<LockOpen24Regular />}
|
||||||
|
onClick={onDeactivate}
|
||||||
|
>
|
||||||
|
Deactivate Jail
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onActivate !== undefined && (
|
{onActivate !== undefined && (
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
@@ -618,8 +628,8 @@ function JailConfigDetail({
|
|||||||
interface InactiveJailDetailProps {
|
interface InactiveJailDetailProps {
|
||||||
jail: InactiveJail;
|
jail: InactiveJail;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
/** Whether to show and call onCrashDetected on activation crash. */
|
/** Called when the user requests removal of the .local override file. */
|
||||||
onCrashDetected?: () => void;
|
onDeactivate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -636,6 +646,7 @@ interface InactiveJailDetailProps {
|
|||||||
function InactiveJailDetail({
|
function InactiveJailDetail({
|
||||||
jail,
|
jail,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
onDeactivate,
|
||||||
}: InactiveJailDetailProps): React.JSX.Element {
|
}: InactiveJailDetailProps): React.JSX.Element {
|
||||||
const styles = useConfigStyles();
|
const styles = useConfigStyles();
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
@@ -729,6 +740,7 @@ function InactiveJailDetail({
|
|||||||
onSave={async () => { /* read-only — never called */ }}
|
onSave={async () => { /* read-only — never called */ }}
|
||||||
readOnly
|
readOnly
|
||||||
onActivate={onActivate}
|
onActivate={onActivate}
|
||||||
|
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
|
||||||
onValidate={handleValidate}
|
onValidate={handleValidate}
|
||||||
validating={validating}
|
validating={validating}
|
||||||
/>
|
/>
|
||||||
@@ -746,12 +758,7 @@ function InactiveJailDetail({
|
|||||||
*
|
*
|
||||||
* @returns JSX element.
|
* @returns JSX element.
|
||||||
*/
|
*/
|
||||||
export interface JailsTabProps {
|
export function JailsTab(): React.JSX.Element {
|
||||||
/** Called when fail2ban stopped responding after a jail was activated. */
|
|
||||||
onCrashDetected?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
|
|
||||||
const styles = useConfigStyles();
|
const styles = useConfigStyles();
|
||||||
const { jails, loading, error, refresh, updateJail } =
|
const { jails, loading, error, refresh, updateJail } =
|
||||||
useJailConfigs();
|
useJailConfigs();
|
||||||
@@ -786,6 +793,15 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
|||||||
.catch(() => { /* non-critical — list refreshes on next load */ });
|
.catch(() => { /* non-critical — list refreshes on next load */ });
|
||||||
}, [refresh, loadInactive]);
|
}, [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 => {
|
const handleActivated = useCallback((): void => {
|
||||||
setActivateTarget(null);
|
setActivateTarget(null);
|
||||||
setSelectedName(null);
|
setSelectedName(null);
|
||||||
@@ -882,15 +898,21 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
|||||||
>
|
>
|
||||||
{selectedActiveJail !== undefined ? (
|
{selectedActiveJail !== undefined ? (
|
||||||
<JailConfigDetail
|
<JailConfigDetail
|
||||||
|
key={selectedActiveJail.name}
|
||||||
jail={selectedActiveJail}
|
jail={selectedActiveJail}
|
||||||
onSave={updateJail}
|
onSave={updateJail}
|
||||||
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
||||||
/>
|
/>
|
||||||
) : selectedInactiveJail !== undefined ? (
|
) : selectedInactiveJail !== undefined ? (
|
||||||
<InactiveJailDetail
|
<InactiveJailDetail
|
||||||
|
key={selectedInactiveJail.name}
|
||||||
jail={selectedInactiveJail}
|
jail={selectedInactiveJail}
|
||||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||||
onCrashDetected={onCrashDetected}
|
onDeactivate={
|
||||||
|
selectedInactiveJail.has_local_override
|
||||||
|
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</ConfigListDetail>
|
</ConfigListDetail>
|
||||||
@@ -901,7 +923,6 @@ export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Ele
|
|||||||
open={activateTarget !== null}
|
open={activateTarget !== null}
|
||||||
onClose={() => { setActivateTarget(null); }}
|
onClose={() => { setActivateTarget(null); }}
|
||||||
onActivated={handleActivated}
|
onActivated={handleActivated}
|
||||||
onCrashDetected={onCrashDetected}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateJailDialog
|
<CreateJailDialog
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 className={styles.sectionCard}>
|
||||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||||
<AutoSaveIndicator
|
<AutoSaveIndicator
|
||||||
@@ -412,8 +416,6 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Service Health & Log Viewer section */}
|
|
||||||
<ServerHealthSection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* - "Activate" button is enabled when validation passes.
|
* - "Activate" button is enabled when validation passes.
|
||||||
* - Dialog stays open and shows an error when the backend returns active=false.
|
* - Dialog stays open and shows an error when the backend returns active=false.
|
||||||
* - `onActivated` is called and dialog closes when backend returns active=true.
|
* - `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";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -55,6 +54,7 @@ const baseJail: InactiveJail = {
|
|||||||
bantime_escalation: null,
|
bantime_escalation: null,
|
||||||
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
has_local_override: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Successful activation response. */
|
/** Successful activation response. */
|
||||||
@@ -98,7 +98,6 @@ interface DialogProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onActivated?: () => void;
|
onActivated?: () => void;
|
||||||
onCrashDetected?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDialog({
|
function renderDialog({
|
||||||
@@ -106,7 +105,6 @@ function renderDialog({
|
|||||||
open = true,
|
open = true,
|
||||||
onClose = vi.fn(),
|
onClose = vi.fn(),
|
||||||
onActivated = vi.fn(),
|
onActivated = vi.fn(),
|
||||||
onCrashDetected = vi.fn(),
|
|
||||||
}: DialogProps = {}) {
|
}: DialogProps = {}) {
|
||||||
return render(
|
return render(
|
||||||
<FluentProvider theme={webLightTheme}>
|
<FluentProvider theme={webLightTheme}>
|
||||||
@@ -115,7 +113,6 @@ function renderDialog({
|
|||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onActivated={onActivated}
|
onActivated={onActivated}
|
||||||
onCrashDetected={onCrashDetected}
|
|
||||||
/>
|
/>
|
||||||
</FluentProvider>,
|
</FluentProvider>,
|
||||||
);
|
);
|
||||||
@@ -202,28 +199,4 @@ describe("ActivateJailDialog", () => {
|
|||||||
expect(onActivated).toHaveBeenCalledOnce();
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../providers/AuthProvider";
|
import { useAuth } from "../providers/AuthProvider";
|
||||||
import { useServerStatus } from "../hooks/useServerStatus";
|
import { useServerStatus } from "../hooks/useServerStatus";
|
||||||
import { useBlocklistStatus } from "../hooks/useBlocklist";
|
import { useBlocklistStatus } from "../hooks/useBlocklist";
|
||||||
import { RecoveryBanner } from "../components/common/RecoveryBanner";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -146,6 +145,16 @@ const useStyles = makeStyles({
|
|||||||
padding: tokens.spacingVerticalS,
|
padding: tokens.spacingVerticalS,
|
||||||
flexShrink: 0,
|
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 content
|
||||||
main: {
|
main: {
|
||||||
@@ -302,6 +311,11 @@ export function MainLayout(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Footer — Logout */}
|
{/* Footer — Logout */}
|
||||||
<div className={styles.sidebarFooter}>
|
<div className={styles.sidebarFooter}>
|
||||||
|
{!collapsed && (
|
||||||
|
<Text className={styles.versionText}>
|
||||||
|
BanGUI v{__APP_VERSION__}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={collapsed ? "Sign out" : ""}
|
content={collapsed ? "Sign out" : ""}
|
||||||
relationship="label"
|
relationship="label"
|
||||||
@@ -336,8 +350,6 @@ export function MainLayout(): React.JSX.Element {
|
|||||||
</MessageBar>
|
</MessageBar>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Recovery banner — shown when fail2ban crashed after a jail activation */}
|
|
||||||
<RecoveryBanner />
|
|
||||||
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
|
{/* Blocklist import error warning — shown when the last scheduled import had errors */}
|
||||||
{blocklistHasErrors && (
|
{blocklistHasErrors && (
|
||||||
<div className={styles.warningBar} role="alert">
|
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,6 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import type { ChangeEvent, FormEvent } from "react";
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { getSetupStatus, submitSetup } from "../api/setup";
|
import { getSetupStatus, submitSetup } from "../api/setup";
|
||||||
import { sha256Hex } from "../utils/crypto";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -101,20 +100,36 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
const [values, setValues] = useState<FormValues>(DEFAULT_VALUES);
|
const [values, setValues] = useState<FormValues>(DEFAULT_VALUES);
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
|
const [errors, setErrors] = useState<Partial<Record<keyof FormValues, string>>>({});
|
||||||
const [apiError, setApiError] = useState<string | null>(null);
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Redirect to /login if setup has already been completed.
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
getSetupStatus()
|
getSetupStatus()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.completed) navigate("/login", { replace: true });
|
if (!cancelled) {
|
||||||
|
if (res.completed) {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
} else {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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]);
|
}, [navigate]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -161,11 +176,8 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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({
|
await submitSetup({
|
||||||
master_password: hashedPassword,
|
master_password: values.masterPassword,
|
||||||
database_path: values.databasePath,
|
database_path: values.databasePath,
|
||||||
fail2ban_socket: values.fail2banSocket,
|
fail2ban_socket: values.fail2banSocket,
|
||||||
timezone: values.timezone,
|
timezone: values.timezone,
|
||||||
@@ -187,6 +199,21 @@ export function SetupPage(): React.JSX.Element {
|
|||||||
// Render
|
// Render
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spinner size="large" label="Loading…" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
|
|||||||
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;
|
source_file: string;
|
||||||
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
||||||
enabled: boolean;
|
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 {
|
export interface InactiveJailListResponse {
|
||||||
@@ -581,20 +586,6 @@ export interface JailValidationResult {
|
|||||||
issues: JailValidationIssue[];
|
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`. */
|
/** Response from `POST /api/config/jails/{name}/rollback`. */
|
||||||
export interface RollbackResponse {
|
export interface RollbackResponse {
|
||||||
jail_name: string;
|
jail_name: string;
|
||||||
|
|||||||
@@ -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 {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
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 { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { resolve } from "path";
|
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/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
/** BanGUI application version injected at build time from package.json. */
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "src"),
|
"@": resolve(__dirname, "src"),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { resolve } from "path";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
/** Stub app version for tests — mirrors the vite.config.ts define. */
|
||||||
|
__APP_VERSION__: JSON.stringify("0.0.0-test"),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": resolve(__dirname, "src"),
|
"@": resolve(__dirname, "src"),
|
||||||
|
|||||||
Reference in New Issue
Block a user