Compare commits
27 Commits
6bb38dbd8c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f515893ea | |||
| 81f99d0b50 | |||
| 030bca09b7 | |||
| 5b7d1a4360 | |||
| e7834a888e | |||
| abb224e01b | |||
| 57cf93b1e5 | |||
| c41165c294 | |||
| cdf73e2d65 | |||
| 21753c4f06 | |||
| eb859af371 | |||
| 5a5c619a34 | |||
| 00119ed68d | |||
| b81e0cdbb4 | |||
| 41dcd60225 | |||
| 12f04bd8d6 | |||
| d4d04491d2 | |||
| 93dc699825 | |||
| 61daa8bbc0 | |||
| 57a0bbe36e | |||
| f62785aaf2 | |||
| 1e33220f59 | |||
| 1da38361a9 | |||
| 9630aea877 | |||
| 037c18eb00 | |||
| 2e1a4b3b2b | |||
| 4be2469f92 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -105,6 +105,7 @@ Docker/fail2ban-dev-config/**
|
|||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
|
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
|
!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf
|
||||||
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
|
!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf
|
||||||
|
!Docker/fail2ban-dev-config/fail2ban/jail.local
|
||||||
|
|
||||||
# ── Misc ──────────────────────────────────────
|
# ── Misc ──────────────────────────────────────
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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.4
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
# NOTE: The fail2ban-config volume must be pre-populated with the following files:
|
||||||
|
# • fail2ban/jail.conf (or jail.d/*.conf) with the DEFAULT section containing:
|
||||||
|
# banaction = iptables-allports[lockingopt="-w 5"]
|
||||||
|
# This prevents xtables lock contention errors when multiple jails start in parallel.
|
||||||
|
# See https://fail2ban.readthedocs.io/en/latest/development/environment.html
|
||||||
|
|
||||||
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
# ── Backend (FastAPI + uvicorn) ─────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
73
Docker/docker-compose.yml
Normal file
73
Docker/docker-compose.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
fail2ban:
|
||||||
|
image: lscr.io/linuxserver/fail2ban:latest
|
||||||
|
container_name: fail2ban
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
- TZ=Etc/UTC
|
||||||
|
- VERBOSITY=-vv #optional
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /server/server_fail2ban/config:/config
|
||||||
|
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
|
||||||
|
- /var/log:/var/log
|
||||||
|
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
|
||||||
|
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
|
||||||
|
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
|
||||||
|
|
||||||
|
|
||||||
|
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
|
||||||
|
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
|
||||||
|
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
|
||||||
|
container_name: bangui-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
fail2ban:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
- BANGUI_DATABASE_PATH=/data/bangui.db
|
||||||
|
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
||||||
|
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
|
||||||
|
- BANGUI_LOG_LEVEL=info
|
||||||
|
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
|
||||||
|
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
|
||||||
|
volumes:
|
||||||
|
- /server/server_fail2ban/bangui-data:/data
|
||||||
|
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
|
||||||
|
- /server/server_fail2ban/config:/config:rw
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
|
|
||||||
|
# ── Frontend (nginx serving built SPA + API proxy) ──────────
|
||||||
|
frontend:
|
||||||
|
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
|
||||||
|
container_name: bangui-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
ports:
|
||||||
|
- "${BANGUI_PORT:-8080}:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bangui-net:
|
||||||
|
name: bangui-net
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This directory contains the fail2ban configuration and supporting scripts for a
|
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]
|
||||||
@@ -20,7 +20,6 @@ maxretry = 1
|
|||||||
findtime = 1d
|
findtime = 1d
|
||||||
# Block imported IPs for one week.
|
# Block imported IPs for one week.
|
||||||
bantime = 1w
|
bantime = 1w
|
||||||
banaction = iptables-allports
|
|
||||||
|
|
||||||
# Never ban the Docker bridge network or localhost.
|
# Never ban the Docker bridge network or localhost.
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
|||||||
@@ -5,16 +5,15 @@
|
|||||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
# 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
|
||||||
findtime = 120
|
findtime = 120
|
||||||
bantime = 60
|
bantime = 60
|
||||||
banaction = iptables-allports
|
|
||||||
|
|
||||||
# Never ban localhost, the Docker bridge network, or the host machine.
|
# Never ban localhost, the Docker bridge network, or the host machine.
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
6
Docker/fail2ban-dev-config/fail2ban/jail.local
Normal file
6
Docker/fail2ban-dev-config/fail2ban/jail.local
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Local overrides — not overwritten by the container init script.
|
||||||
|
# Provides banaction so all jails can resolve %(action_)s interpolation.
|
||||||
|
|
||||||
|
[DEFAULT]
|
||||||
|
banaction = iptables-multiport
|
||||||
|
banaction_allports = iptables-allports
|
||||||
86
Docker/release.sh
Normal file
86
Docker/release.sh
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bump the project version and push images to the registry.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./release.sh
|
||||||
|
#
|
||||||
|
# The current version is stored in VERSION (next to this script).
|
||||||
|
# You will be asked whether to bump major, minor, or patch.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERSION_FILE="${SCRIPT_DIR}/VERSION"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Read current version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [[ ! -f "${VERSION_FILE}" ]]; then
|
||||||
|
echo "0.0.0" > "${VERSION_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT="$(cat "${VERSION_FILE}")"
|
||||||
|
# Strip leading 'v' for arithmetic
|
||||||
|
VERSION="${CURRENT#v}"
|
||||||
|
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo " BanGUI — Release"
|
||||||
|
echo " Current version: v${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
echo "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "How would you like to bump the version?"
|
||||||
|
echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))"
|
||||||
|
echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)"
|
||||||
|
echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)"
|
||||||
|
echo ""
|
||||||
|
read -rp "Enter choice [1/2/3]: " CHOICE
|
||||||
|
|
||||||
|
case "${CHOICE}" in
|
||||||
|
1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
|
||||||
|
2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;;
|
||||||
|
3) NEW_TAG="v$((MAJOR + 1)).0.0" ;;
|
||||||
|
*)
|
||||||
|
echo "Invalid choice. Aborting." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "New version: ${NEW_TAG}"
|
||||||
|
read -rp "Confirm? [y/N]: " CONFIRM
|
||||||
|
if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Write new version
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "${NEW_TAG}" > "${VERSION_FILE}"
|
||||||
|
echo "Version file updated → ${VERSION_FILE}"
|
||||||
|
|
||||||
|
# Keep frontend/package.json in sync so __APP_VERSION__ matches Docker/VERSION.
|
||||||
|
FRONT_VERSION="${NEW_TAG#v}"
|
||||||
|
FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}"
|
||||||
|
echo "frontend/package.json version updated → ${FRONT_VERSION}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Git tag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
cd "${SCRIPT_DIR}/.."
|
||||||
|
git add Docker/VERSION frontend/package.json
|
||||||
|
git commit -m "chore: release ${NEW_TAG}"
|
||||||
|
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin "${NEW_TAG}"
|
||||||
|
echo "Git tag ${NEW_TAG} created and pushed."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||||
|
bash "${SCRIPT_DIR}/push.sh"
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# simulate_failed_logins.sh
|
# 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>
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
238
Docs/Refactoring.md
Normal file
238
Docs/Refactoring.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# BanGUI — Refactoring Instructions for AI Agents
|
||||||
|
|
||||||
|
This document is the single source of truth for any AI agent performing a refactoring task on the BanGUI codebase.
|
||||||
|
Read it in full before writing a single line of code.
|
||||||
|
The authoritative description of every module, its responsibilities, and the allowed dependency direction is in [Architekture.md](Architekture.md). Always cross-reference it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Golden Rules
|
||||||
|
|
||||||
|
1. **Architecture first.** Every change must comply with the layered architecture defined in [Architekture.md §2](Architekture.md). Dependencies flow inward: `routers → services → repositories`. Never add an import that reverses this direction.
|
||||||
|
2. **One concern per file.** Each module has an explicitly stated purpose in [Architekture.md](Architekture.md). Do not add responsibilities to a module that do not belong there.
|
||||||
|
3. **No behaviour change.** Refactoring must preserve all existing behaviour. If a function's public signature, return value, or side-effects must change, that is a feature — create a separate task for it.
|
||||||
|
4. **Tests stay green.** Run the full test suite (`pytest backend/`) before and after every change. Do not submit work that introduces new failures.
|
||||||
|
5. **Smallest diff wins.** Prefer targeted edits. Do not rewrite a file when a few lines suffice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Before You Start
|
||||||
|
|
||||||
|
### 1.1 Understand the project
|
||||||
|
|
||||||
|
Read the following documents in order:
|
||||||
|
|
||||||
|
1. [Architekture.md](Architekture.md) — full system overview, component map, module purposes, dependency rules.
|
||||||
|
2. [Docs/Backend-Development.md](Backend-Development.md) — coding conventions, testing strategy, environment setup.
|
||||||
|
3. [Docs/Tasks.md](Tasks.md) — open issues and planned work; avoid touching areas that have pending conflicting changes.
|
||||||
|
|
||||||
|
### 1.2 Map the code to the architecture
|
||||||
|
|
||||||
|
Before editing, locate every file that is in scope:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
routers/ HTTP layer — zero business logic
|
||||||
|
services/ Business logic — orchestrates repositories + clients
|
||||||
|
repositories/ Data access — raw SQL only
|
||||||
|
models/ Pydantic schemas
|
||||||
|
tasks/ APScheduler jobs
|
||||||
|
utils/ Pure helpers, no framework deps
|
||||||
|
main.py App factory, lifespan, middleware
|
||||||
|
config.py Pydantic settings
|
||||||
|
dependencies.py FastAPI Depends() wiring
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
api/ Typed fetch wrappers + endpoint constants
|
||||||
|
components/ Presentational UI, no API calls
|
||||||
|
hooks/ All state, side-effects, API calls
|
||||||
|
pages/ Route components — orchestration only
|
||||||
|
providers/ React context
|
||||||
|
types/ TypeScript interfaces
|
||||||
|
utils/ Pure helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm which layer every file you intend to touch belongs to. If unsure, consult [Architekture.md §2.2](Architekture.md) (backend) or [Architekture.md §3.2](Architekture.md) (frontend).
|
||||||
|
|
||||||
|
### 1.3 Run the baseline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
pytest backend/ -x --tb=short
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the number of passing tests. After refactoring, that number must be equal or higher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend Refactoring
|
||||||
|
|
||||||
|
### 2.1 Routers (`app/routers/`)
|
||||||
|
|
||||||
|
**Allowed content:** request parsing, response serialisation, dependency injection via `Depends()`, delegation to a service, HTTP error mapping.
|
||||||
|
**Forbidden content:** SQL queries, business logic, direct use of `fail2ban_client`, any logic that would also make sense in a unit test without an HTTP request.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [ ] Every handler calls exactly one service method per logical operation.
|
||||||
|
- [ ] No `if`/`elif` chains that implement business rules — move these to the service.
|
||||||
|
- [ ] No raw SQL or repository imports.
|
||||||
|
- [ ] All response models are Pydantic schemas from `app/models/`.
|
||||||
|
- [ ] HTTP status codes are consistent with API conventions (200 OK, 201 Created, 204 No Content, 400/422 for client errors, 404 for missing resources, 500 only for unexpected failures).
|
||||||
|
|
||||||
|
### 2.2 Services (`app/services/`)
|
||||||
|
|
||||||
|
**Allowed content:** business rules, coordination between repositories and external clients, validation that goes beyond Pydantic, fail2ban command orchestration.
|
||||||
|
**Forbidden content:** raw SQL, direct aiosqlite calls, FastAPI `HTTPException` (raise domain exceptions instead and let the router or exception handler convert them).
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [ ] Service classes / functions accept plain Python types or domain models — not `Request` or `Response` objects.
|
||||||
|
- [ ] No direct `aiosqlite` usage — go through a repository.
|
||||||
|
- [ ] No `HTTPException` — raise a custom domain exception or a plain `ValueError`/`RuntimeError` with a clear message.
|
||||||
|
- [ ] No circular imports between services — if two services need each other's logic, extract the shared logic to a utility or a third service.
|
||||||
|
|
||||||
|
### 2.3 Repositories (`app/repositories/`)
|
||||||
|
|
||||||
|
**Allowed content:** SQL queries, result mapping to domain models, transaction management.
|
||||||
|
**Forbidden content:** business logic, fail2ban calls, HTTP concerns, logging beyond debug-level traces.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [ ] Every public method accepts a `db: aiosqlite.Connection` parameter — sessions are not managed internally.
|
||||||
|
- [ ] Methods return typed domain models or plain Python primitives, never raw `aiosqlite.Row` objects exposed to callers.
|
||||||
|
- [ ] No business rules (e.g., no "if this setting is missing, create a default" logic — that belongs in the service).
|
||||||
|
|
||||||
|
### 2.4 Models (`app/models/`)
|
||||||
|
|
||||||
|
- Keep **Request**, **Response**, and **Domain** model types clearly separated (see [Architekture.md §2.2](Architekture.md)).
|
||||||
|
- Do not use response models as function arguments inside service or repository code.
|
||||||
|
- Validators (`@field_validator`, `@model_validator`) belong in models only when they concern data shape, not business rules.
|
||||||
|
|
||||||
|
### 2.5 Tasks (`app/tasks/`)
|
||||||
|
|
||||||
|
- Tasks must be thin: fetch inputs → call one service method → log result.
|
||||||
|
- Error handling must be inside the task (APScheduler swallows unhandled exceptions — log them explicitly).
|
||||||
|
- No direct repository or `fail2ban_client` use; go through a service.
|
||||||
|
|
||||||
|
### 2.6 Utils (`app/utils/`)
|
||||||
|
|
||||||
|
- Must have zero framework dependencies (no FastAPI, no aiosqlite imports).
|
||||||
|
- Must be pure or near-pure functions.
|
||||||
|
- `fail2ban_client.py` is the single exception — it wraps the socket protocol but still has no service-layer logic.
|
||||||
|
|
||||||
|
### 2.7 Dependencies (`app/dependencies.py`)
|
||||||
|
|
||||||
|
- This file is the **only** place where service constructors are called and injected.
|
||||||
|
- Do not construct services inside router handlers; always receive them via `Depends()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontend Refactoring
|
||||||
|
|
||||||
|
### 3.1 Pages (`src/pages/`)
|
||||||
|
|
||||||
|
**Allowed content:** composing components and hooks, layout decisions, routing.
|
||||||
|
**Forbidden content:** direct `fetch`/`axios` calls, inline business logic, state management beyond what is needed to coordinate child components.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [ ] All data fetching goes through a hook from `src/hooks/`.
|
||||||
|
- [ ] No API function from `src/api/` is called directly inside a page component.
|
||||||
|
|
||||||
|
### 3.2 Components (`src/components/`)
|
||||||
|
|
||||||
|
**Allowed content:** rendering, styling, event handlers that call prop callbacks.
|
||||||
|
**Forbidden content:** API calls, hook-level state (prefer lifting state to the page or a dedicated hook), direct use of `src/api/`.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [ ] Components receive all data via props.
|
||||||
|
- [ ] Components emit changes via callback props (`onXxx`).
|
||||||
|
- [ ] No `useEffect` that calls an API function — that belongs in a hook.
|
||||||
|
|
||||||
|
### 3.3 Hooks (`src/hooks/`)
|
||||||
|
|
||||||
|
**Allowed content:** `useState`, `useEffect`, `useCallback`, `useRef`; calls to `src/api/`; local state derivation.
|
||||||
|
**Forbidden content:** JSX rendering, Fluent UI components.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
- [ ] Each hook has a single, focused concern matching its name (e.g., `useBans` only manages ban data).
|
||||||
|
- [ ] Hooks return a stable interface: `{ data, loading, error, refetch }` or equivalent.
|
||||||
|
- [ ] Shared logic between hooks is extracted to `src/utils/` (pure) or a parent hook (stateful).
|
||||||
|
|
||||||
|
### 3.4 API layer (`src/api/`)
|
||||||
|
|
||||||
|
- `client.ts` is the only place that calls `fetch`. All other api files call `client.ts`.
|
||||||
|
- `endpoints.ts` is the single source of truth for URL strings.
|
||||||
|
- API functions must be typed: explicit request and response TypeScript interfaces from `src/types/`.
|
||||||
|
|
||||||
|
### 3.5 Types (`src/types/`)
|
||||||
|
|
||||||
|
- Interfaces must match the backend Pydantic response schemas exactly (field names, optionality).
|
||||||
|
- Do not use `any`. Use `unknown` and narrow with type guards when the shape is genuinely unknown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. General Code Quality Rules
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
- Python: `snake_case` for variables/functions, `PascalCase` for classes.
|
||||||
|
- TypeScript: `camelCase` for variables/functions, `PascalCase` for components and types.
|
||||||
|
- File names must match the primary export they contain.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
- Backend: raise typed exceptions; map them to HTTP status codes in `main.py` exception handlers or in the router — nowhere else.
|
||||||
|
- Frontend: all API call error states are represented in hook return values; never swallow errors silently.
|
||||||
|
|
||||||
|
### Logging (backend)
|
||||||
|
- Use `structlog` with bound context loggers — never bare `print()`.
|
||||||
|
- Log at `debug` in repositories, `info` in services for meaningful events, `warning`/`error` in tasks and exception handlers.
|
||||||
|
- Never log sensitive data (passwords, session tokens, raw IP lists larger than a handful of entries).
|
||||||
|
|
||||||
|
### Async correctness (backend)
|
||||||
|
- Every function that touches I/O (database, fail2ban socket, HTTP) must be `async def`.
|
||||||
|
- Never call `asyncio.run()` inside a running event loop.
|
||||||
|
- Do not use `time.sleep()` — use `await asyncio.sleep()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Refactoring Workflow
|
||||||
|
|
||||||
|
Follow this sequence for every refactoring task:
|
||||||
|
|
||||||
|
1. **Read** the relevant section of [Architekture.md](Architekture.md) for the files you will touch.
|
||||||
|
2. **Run** the full test suite to confirm the baseline.
|
||||||
|
3. **Identify** the violation or smell: which rule from this document does it break?
|
||||||
|
4. **Plan** the minimal change: what is the smallest edit that fixes the violation?
|
||||||
|
5. **Edit** the code. One logical change per commit.
|
||||||
|
6. **Verify** imports: nothing new violates the dependency direction.
|
||||||
|
7. **Run** the full test suite. All previously passing tests must still pass.
|
||||||
|
8. **Update** any affected docstrings or inline comments to reflect the new structure.
|
||||||
|
9. **Do not** update `Architekture.md` unless the refactor changes the documented structure — that requires a separate review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Common Violations to Look For
|
||||||
|
|
||||||
|
| Violation | Where it typically appears | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Business logic in a router handler | `app/routers/*.py` | Extract logic to the corresponding service |
|
||||||
|
| Direct `aiosqlite` calls in a service | `app/services/*.py` | Move the query into the matching repository |
|
||||||
|
| `HTTPException` raised inside a service | `app/services/*.py` | Raise a domain exception; catch and convert it in the router or exception handler |
|
||||||
|
| API call inside a React component | `src/components/*.tsx` | Move to a hook; pass data via props |
|
||||||
|
| Hardcoded URL string in a hook or component | `src/hooks/*.ts`, `src/components/*.tsx` | Use the constant from `src/api/endpoints.ts` |
|
||||||
|
| `any` type in TypeScript | anywhere in `src/` | Replace with a concrete interface from `src/types/` |
|
||||||
|
| `print()` statements in production code | `backend/app/**/*.py` | Replace with `structlog` logger |
|
||||||
|
| Synchronous I/O in an async function | `backend/app/**/*.py` | Use the async equivalent |
|
||||||
|
| A repository method that contains an `if` with a business rule | `app/repositories/*.py` | Move the rule to the service layer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Out of Scope
|
||||||
|
|
||||||
|
Do not make the following changes unless explicitly instructed in a separate task:
|
||||||
|
|
||||||
|
- Adding new API endpoints or pages.
|
||||||
|
- Changing database schema or migration files.
|
||||||
|
- Upgrading dependencies.
|
||||||
|
- Altering Docker or CI configuration.
|
||||||
|
- Modifying `Architekture.md` or `Tasks.md`.
|
||||||
929
Docs/Tasks.md
929
Docs/Tasks.md
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||||
@@ -873,6 +881,16 @@ class JailActivationResponse(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Non-fatal warnings from the pre-activation validation step.",
|
description="Non-fatal warnings from the pre-activation validation step.",
|
||||||
)
|
)
|
||||||
|
recovered: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Set when activation failed after writing the config file. "
|
||||||
|
"``True`` means the system automatically rolled back the change and "
|
||||||
|
"restarted fail2ban. ``False`` means the rollback itself also "
|
||||||
|
"failed and manual intervention is required. ``None`` when "
|
||||||
|
"activation succeeded or failed before the file was written."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,15 +361,88 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
# Restart endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/restart",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Restart the fail2ban service",
|
||||||
|
)
|
||||||
|
async def restart_fail2ban(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
) -> None:
|
||||||
|
"""Trigger a full fail2ban service restart.
|
||||||
|
|
||||||
|
Stops the fail2ban daemon via the Unix domain socket, then starts it
|
||||||
|
again using the configured ``fail2ban_start_command``. After starting,
|
||||||
|
probes the socket for up to 10 seconds to confirm the daemon came back
|
||||||
|
online.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 when fail2ban reports the stop command failed.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable for the stop command.
|
||||||
|
HTTPException: 503 when fail2ban does not come back online within
|
||||||
|
10 seconds after being started. Check the fail2ban log for
|
||||||
|
initialisation errors. Use
|
||||||
|
``POST /api/config/jails/{name}/rollback`` if a specific jail
|
||||||
|
is suspect.
|
||||||
|
"""
|
||||||
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||||
|
start_cmd: str = request.app.state.settings.fail2ban_start_command
|
||||||
|
start_cmd_parts: list[str] = start_cmd.split()
|
||||||
|
|
||||||
|
# Step 1: stop the daemon via socket.
|
||||||
|
try:
|
||||||
|
await jail_service.restart(socket_path)
|
||||||
|
except JailOperationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"fail2ban stop command failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
# Step 2: start the daemon via subprocess.
|
||||||
|
await config_file_service.start_daemon(start_cmd_parts)
|
||||||
|
|
||||||
|
# Step 3: probe the socket until fail2ban is responsive or the budget expires.
|
||||||
|
fail2ban_running: bool = await config_file_service.wait_for_fail2ban(
|
||||||
|
socket_path, max_wait_seconds=10.0
|
||||||
|
)
|
||||||
|
if not fail2ban_running:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=(
|
||||||
|
"fail2ban was stopped but did not come back online within 10 seconds. "
|
||||||
|
"Check the fail2ban log for initialisation errors. "
|
||||||
|
"Use POST /api/config/jails/{name}/rollback if a specific jail is suspect."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
log.info("fail2ban_restarted")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Regex tester (stateless)
|
# Regex tester (stateless)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -721,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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from app.models.config import (
|
|||||||
RollbackResponse,
|
RollbackResponse,
|
||||||
)
|
)
|
||||||
from app.services import conffile_parser, jail_service
|
from app.services import conffile_parser, jail_service
|
||||||
|
from app.services.jail_service import JailNotFoundError as JailNotFoundError
|
||||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
@@ -428,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.
|
||||||
|
|
||||||
@@ -435,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`.
|
||||||
@@ -512,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
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -739,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,
|
||||||
@@ -763,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)
|
||||||
@@ -887,6 +896,50 @@ def _write_local_override_sync(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_local_file_sync(local_path: Path, original_content: bytes | None) -> None:
|
||||||
|
"""Restore a ``.local`` file to its pre-activation state.
|
||||||
|
|
||||||
|
If *original_content* is ``None``, the file is deleted (it did not exist
|
||||||
|
before the activation). Otherwise the original bytes are written back
|
||||||
|
atomically via a temp-file rename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_path: Absolute path to the ``.local`` file to restore.
|
||||||
|
original_content: Original raw bytes to write back, or ``None`` to
|
||||||
|
delete the file.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigWriteError: If the write or delete operation fails.
|
||||||
|
"""
|
||||||
|
if original_content is None:
|
||||||
|
try:
|
||||||
|
local_path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ConfigWriteError(
|
||||||
|
f"Failed to delete {local_path} during rollback: {exc}"
|
||||||
|
) from exc
|
||||||
|
return
|
||||||
|
|
||||||
|
tmp_name: str | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="wb",
|
||||||
|
dir=local_path.parent,
|
||||||
|
delete=False,
|
||||||
|
suffix=".tmp",
|
||||||
|
) as tmp:
|
||||||
|
tmp.write(original_content)
|
||||||
|
tmp_name = tmp.name
|
||||||
|
os.replace(tmp_name, local_path)
|
||||||
|
except OSError as exc:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
if tmp_name is not None:
|
||||||
|
os.unlink(tmp_name)
|
||||||
|
raise ConfigWriteError(
|
||||||
|
f"Failed to restore {local_path} during rollback: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _validate_regex_patterns(patterns: list[str]) -> None:
|
def _validate_regex_patterns(patterns: list[str]) -> None:
|
||||||
"""Validate each pattern in *patterns* using Python's ``re`` module.
|
"""Validate each pattern in *patterns* using Python's ``re`` module.
|
||||||
|
|
||||||
@@ -1066,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",
|
||||||
@@ -1163,6 +1216,16 @@ async def activate_jail(
|
|||||||
"logpath": req.logpath,
|
"logpath": req.logpath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# Backup the existing .local file (if any) before overwriting it so that #
|
||||||
|
# we can restore it if activation fails. #
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
|
||||||
|
original_content: bytes | None = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: local_path.read_bytes() if local_path.exists() else None,
|
||||||
|
)
|
||||||
|
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
_write_local_override_sync,
|
_write_local_override_sync,
|
||||||
@@ -1172,10 +1235,52 @@ async def activate_jail(
|
|||||||
overrides,
|
overrides,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# Activation reload — if it fails, roll back immediately #
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
try:
|
try:
|
||||||
await jail_service.reload_all(socket_path, include_jails=[name])
|
await jail_service.reload_all(socket_path, include_jails=[name])
|
||||||
|
except JailNotFoundError as exc:
|
||||||
|
# Jail configuration is invalid (e.g. missing logpath that prevents
|
||||||
|
# fail2ban from loading the jail). Roll back and provide a specific error.
|
||||||
|
log.warning(
|
||||||
|
"reload_after_activate_failed_jail_not_found",
|
||||||
|
jail=name,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
recovered = await _rollback_activation_async(
|
||||||
|
config_dir, name, socket_path, original_content
|
||||||
|
)
|
||||||
|
return JailActivationResponse(
|
||||||
|
name=name,
|
||||||
|
active=False,
|
||||||
|
fail2ban_running=False,
|
||||||
|
recovered=recovered,
|
||||||
|
validation_warnings=warnings,
|
||||||
|
message=(
|
||||||
|
f"Jail {name!r} activation failed: {str(exc)}. "
|
||||||
|
"Check that all logpath files exist and are readable. "
|
||||||
|
"The configuration was "
|
||||||
|
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
||||||
|
recovered = await _rollback_activation_async(
|
||||||
|
config_dir, name, socket_path, original_content
|
||||||
|
)
|
||||||
|
return JailActivationResponse(
|
||||||
|
name=name,
|
||||||
|
active=False,
|
||||||
|
fail2ban_running=False,
|
||||||
|
recovered=recovered,
|
||||||
|
validation_warnings=warnings,
|
||||||
|
message=(
|
||||||
|
f"Jail {name!r} activation failed during reload and the "
|
||||||
|
"configuration was "
|
||||||
|
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------- #
|
# ---------------------------------------------------------------------- #
|
||||||
# Post-reload health probe with retries #
|
# Post-reload health probe with retries #
|
||||||
@@ -1192,16 +1297,21 @@ async def activate_jail(
|
|||||||
log.warning(
|
log.warning(
|
||||||
"fail2ban_down_after_activate",
|
"fail2ban_down_after_activate",
|
||||||
jail=name,
|
jail=name,
|
||||||
message="fail2ban socket unreachable after reload — daemon may have crashed.",
|
message="fail2ban socket unreachable after reload — initiating rollback.",
|
||||||
|
)
|
||||||
|
recovered = await _rollback_activation_async(
|
||||||
|
config_dir, name, socket_path, original_content
|
||||||
)
|
)
|
||||||
return JailActivationResponse(
|
return JailActivationResponse(
|
||||||
name=name,
|
name=name,
|
||||||
active=False,
|
active=False,
|
||||||
fail2ban_running=False,
|
fail2ban_running=False,
|
||||||
|
recovered=recovered,
|
||||||
validation_warnings=warnings,
|
validation_warnings=warnings,
|
||||||
message=(
|
message=(
|
||||||
f"Jail {name!r} was written to config but fail2ban stopped "
|
f"Jail {name!r} activation failed: fail2ban stopped responding "
|
||||||
"responding after reload. The jail configuration may be invalid."
|
"after reload. The configuration was "
|
||||||
|
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1212,16 +1322,21 @@ async def activate_jail(
|
|||||||
log.warning(
|
log.warning(
|
||||||
"jail_activation_unverified",
|
"jail_activation_unverified",
|
||||||
jail=name,
|
jail=name,
|
||||||
message="Jail did not appear in running jails after reload.",
|
message="Jail did not appear in running jails — initiating rollback.",
|
||||||
|
)
|
||||||
|
recovered = await _rollback_activation_async(
|
||||||
|
config_dir, name, socket_path, original_content
|
||||||
)
|
)
|
||||||
return JailActivationResponse(
|
return JailActivationResponse(
|
||||||
name=name,
|
name=name,
|
||||||
active=False,
|
active=False,
|
||||||
fail2ban_running=True,
|
fail2ban_running=True,
|
||||||
|
recovered=recovered,
|
||||||
validation_warnings=warnings,
|
validation_warnings=warnings,
|
||||||
message=(
|
message=(
|
||||||
f"Jail {name!r} was written to config but did not start after "
|
f"Jail {name!r} was written to config but did not start after "
|
||||||
"reload — check the jail configuration (filters, log paths, regex)."
|
"reload. The configuration was "
|
||||||
|
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1235,6 +1350,70 @@ async def activate_jail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _rollback_activation_async(
|
||||||
|
config_dir: str,
|
||||||
|
name: str,
|
||||||
|
socket_path: str,
|
||||||
|
original_content: bytes | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Restore the pre-activation ``.local`` file and reload fail2ban.
|
||||||
|
|
||||||
|
Called internally by :func:`activate_jail` when the activation fails after
|
||||||
|
the config file was already written. Tries to:
|
||||||
|
|
||||||
|
1. Restore the original file content (or delete the file if it was newly
|
||||||
|
created by the activation attempt).
|
||||||
|
2. Reload fail2ban so the daemon runs with the restored configuration.
|
||||||
|
3. Probe fail2ban to confirm it came back up.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dir: Absolute path to the fail2ban configuration directory.
|
||||||
|
name: Name of the jail whose ``.local`` file should be restored.
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
original_content: Raw bytes of the original ``.local`` file, or
|
||||||
|
``None`` if the file did not exist before the activation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if fail2ban is responsive again after the rollback, ``False``
|
||||||
|
if recovery also failed.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
|
||||||
|
|
||||||
|
# Step 1 — restore original file (or delete it).
|
||||||
|
try:
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, _restore_local_file_sync, local_path, original_content
|
||||||
|
)
|
||||||
|
log.info("jail_activation_rollback_file_restored", jail=name)
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
log.error(
|
||||||
|
"jail_activation_rollback_restore_failed", jail=name, error=str(exc)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Step 2 — reload fail2ban with the restored config.
|
||||||
|
try:
|
||||||
|
await jail_service.reload_all(socket_path)
|
||||||
|
log.info("jail_activation_rollback_reload_ok", jail=name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning(
|
||||||
|
"jail_activation_rollback_reload_failed", jail=name, error=str(exc)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Step 3 — wait for fail2ban to come back.
|
||||||
|
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
|
||||||
|
if attempt > 0:
|
||||||
|
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
|
||||||
|
if await _probe_fail2ban_running(socket_path):
|
||||||
|
log.info("jail_activation_rollback_recovered", jail=name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
log.warning("jail_activation_rollback_still_down", jail=name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def deactivate_jail(
|
async def deactivate_jail(
|
||||||
config_dir: str,
|
config_dir: str,
|
||||||
socket_path: str,
|
socket_path: str,
|
||||||
@@ -1298,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,
|
||||||
@@ -1370,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ _SOCKET_TIMEOUT: float = 10.0
|
|||||||
# ensures only one reload stream is in-flight at a time.
|
# ensures only one reload stream is in-flight at a time.
|
||||||
_reload_all_lock: asyncio.Lock = asyncio.Lock()
|
_reload_all_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Capability detection for optional fail2ban transmitter commands (backend, idle).
|
||||||
|
# These commands are not supported in all fail2ban versions. Caching the result
|
||||||
|
# avoids sending unsupported commands every polling cycle and spamming the
|
||||||
|
# fail2ban log with "Invalid command" errors.
|
||||||
|
_backend_cmd_supported: bool | None = None
|
||||||
|
_backend_cmd_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Custom exceptions
|
# Custom exceptions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -185,6 +192,51 @@ async def _safe_get(
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_backend_cmd_supported(
|
||||||
|
client: Fail2BanClient,
|
||||||
|
jail_name: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Detect whether the fail2ban daemon supports optional ``get ... backend`` command.
|
||||||
|
|
||||||
|
Some fail2ban versions (e.g. LinuxServer.io container) do not implement the
|
||||||
|
optional ``get <jail> backend`` and ``get <jail> idle`` transmitter sub-commands.
|
||||||
|
This helper probes the daemon once and caches the result to avoid repeated
|
||||||
|
"Invalid command" errors in the fail2ban log.
|
||||||
|
|
||||||
|
Uses double-check locking to minimize lock contention in concurrent polls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
|
||||||
|
jail_name: Name of any jail to use for the probe command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if the command is supported, ``False`` otherwise.
|
||||||
|
Once determined, the result is cached and reused for all jails.
|
||||||
|
"""
|
||||||
|
global _backend_cmd_supported
|
||||||
|
|
||||||
|
# Fast path: return cached result if already determined.
|
||||||
|
if _backend_cmd_supported is not None:
|
||||||
|
return _backend_cmd_supported
|
||||||
|
|
||||||
|
# Slow path: acquire lock and probe the command once.
|
||||||
|
async with _backend_cmd_lock:
|
||||||
|
# Double-check idiom: another coroutine may have probed while we waited.
|
||||||
|
if _backend_cmd_supported is not None:
|
||||||
|
return _backend_cmd_supported
|
||||||
|
|
||||||
|
# Probe: send the command and catch any exception.
|
||||||
|
try:
|
||||||
|
_ok(await client.send(["get", jail_name, "backend"]))
|
||||||
|
_backend_cmd_supported = True
|
||||||
|
log.debug("backend_cmd_supported_detected")
|
||||||
|
except Exception:
|
||||||
|
_backend_cmd_supported = False
|
||||||
|
log.debug("backend_cmd_unsupported_detected")
|
||||||
|
|
||||||
|
return _backend_cmd_supported
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API — Jail listing & detail
|
# Public API — Jail listing & detail
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -238,7 +290,11 @@ async def _fetch_jail_summary(
|
|||||||
"""Fetch and build a :class:`~app.models.jail.JailSummary` for one jail.
|
"""Fetch and build a :class:`~app.models.jail.JailSummary` for one jail.
|
||||||
|
|
||||||
Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``,
|
Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``,
|
||||||
``backend``, and ``idle`` commands in parallel.
|
``backend``, and ``idle`` commands in parallel (if supported).
|
||||||
|
|
||||||
|
The ``backend`` and ``idle`` commands are optional and not supported in
|
||||||
|
all fail2ban versions. If not supported, this function will not send them
|
||||||
|
to avoid spamming the fail2ban log with "Invalid command" errors.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
|
||||||
@@ -247,15 +303,38 @@ async def _fetch_jail_summary(
|
|||||||
Returns:
|
Returns:
|
||||||
A :class:`~app.models.jail.JailSummary` populated from the responses.
|
A :class:`~app.models.jail.JailSummary` populated from the responses.
|
||||||
"""
|
"""
|
||||||
_r = await asyncio.gather(
|
# Check whether optional backend/idle commands are supported.
|
||||||
|
# This probe happens once per session and is cached to avoid repeated
|
||||||
|
# "Invalid command" errors in the fail2ban log.
|
||||||
|
backend_cmd_is_supported = await _check_backend_cmd_supported(client, name)
|
||||||
|
|
||||||
|
# Build the gather list based on command support.
|
||||||
|
gather_list: list[Any] = [
|
||||||
client.send(["status", name, "short"]),
|
client.send(["status", name, "short"]),
|
||||||
client.send(["get", name, "bantime"]),
|
client.send(["get", name, "bantime"]),
|
||||||
client.send(["get", name, "findtime"]),
|
client.send(["get", name, "findtime"]),
|
||||||
client.send(["get", name, "maxretry"]),
|
client.send(["get", name, "maxretry"]),
|
||||||
client.send(["get", name, "backend"]),
|
]
|
||||||
client.send(["get", name, "idle"]),
|
|
||||||
return_exceptions=True,
|
if backend_cmd_is_supported:
|
||||||
)
|
# Commands are supported; send them for real values.
|
||||||
|
gather_list.extend([
|
||||||
|
client.send(["get", name, "backend"]),
|
||||||
|
client.send(["get", name, "idle"]),
|
||||||
|
])
|
||||||
|
uses_backend_backend_commands = True
|
||||||
|
else:
|
||||||
|
# Commands not supported; return default values without sending.
|
||||||
|
async def _return_default(value: Any) -> tuple[int, Any]:
|
||||||
|
return (0, value)
|
||||||
|
|
||||||
|
gather_list.extend([
|
||||||
|
_return_default("polling"), # backend default
|
||||||
|
_return_default(False), # idle default
|
||||||
|
])
|
||||||
|
uses_backend_backend_commands = False
|
||||||
|
|
||||||
|
_r = await asyncio.gather(*gather_list, return_exceptions=True)
|
||||||
status_raw: Any = _r[0]
|
status_raw: Any = _r[0]
|
||||||
bantime_raw: Any = _r[1]
|
bantime_raw: Any = _r[1]
|
||||||
findtime_raw: Any = _r[2]
|
findtime_raw: Any = _r[2]
|
||||||
@@ -569,7 +648,10 @@ async def reload_all(
|
|||||||
exclude_jails: Jail names to remove from the start stream.
|
exclude_jails: Jail names to remove from the start stream.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
JailOperationError: If fail2ban reports the operation failed.
|
JailNotFoundError: If a jail in *include_jails* does not exist or
|
||||||
|
its configuration is invalid (e.g. missing logpath).
|
||||||
|
JailOperationError: If fail2ban reports the operation failed for
|
||||||
|
a different reason.
|
||||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||||
cannot be reached.
|
cannot be reached.
|
||||||
"""
|
"""
|
||||||
@@ -593,9 +675,43 @@ async def reload_all(
|
|||||||
_ok(await client.send(["reload", "--all", [], stream]))
|
_ok(await client.send(["reload", "--all", [], stream]))
|
||||||
log.info("all_jails_reloaded")
|
log.info("all_jails_reloaded")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
# Detect UnknownJailException (missing or invalid jail configuration)
|
||||||
|
# and re-raise as JailNotFoundError for better error specificity.
|
||||||
|
if _is_not_found_error(exc):
|
||||||
|
# Extract the jail name from include_jails if available.
|
||||||
|
jail_name = include_jails[0] if include_jails else "unknown"
|
||||||
|
raise JailNotFoundError(jail_name) from exc
|
||||||
raise JailOperationError(str(exc)) from exc
|
raise JailOperationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def restart(socket_path: str) -> None:
|
||||||
|
"""Stop the fail2ban daemon via the Unix socket.
|
||||||
|
|
||||||
|
Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()``
|
||||||
|
on the daemon side and tears down all jails. The caller is responsible
|
||||||
|
for starting the daemon again (e.g. via ``fail2ban-client start``).
|
||||||
|
|
||||||
|
Note:
|
||||||
|
``["restart"]`` is a *client-side* orchestration command that is not
|
||||||
|
handled by the fail2ban server transmitter — sending it to the socket
|
||||||
|
raises ``"Invalid command"`` in the daemon.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
JailOperationError: If fail2ban reports the stop command failed.
|
||||||
|
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
|
||||||
|
cannot be reached.
|
||||||
|
"""
|
||||||
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||||
|
try:
|
||||||
|
_ok(await client.send(["stop"]))
|
||||||
|
log.info("fail2ban_stopped_for_restart")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise JailOperationError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API — Ban / Unban
|
# Public API — Ban / Unban
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
93
backend/app/utils/jail_config.py
Normal file
93
backend/app/utils/jail_config.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Utilities for ensuring required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
BanGUI requires two custom jails — ``manual-Jail`` and ``blocklist-import``
|
||||||
|
— to be present in the fail2ban ``jail.d`` directory. This module provides
|
||||||
|
:func:`ensure_jail_configs` which checks each of the four files
|
||||||
|
(``*.conf`` template + ``*.local`` override) and creates any that are missing
|
||||||
|
with the correct default content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default file contents
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_JAIL_CONF = """\
|
||||||
|
[manual-Jail]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter = manual-Jail
|
||||||
|
logpath = /remotelogs/bangui/auth.log
|
||||||
|
backend = polling
|
||||||
|
maxretry = 3
|
||||||
|
findtime = 120
|
||||||
|
bantime = 60
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MANUAL_JAIL_LOCAL = """\
|
||||||
|
[manual-Jail]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_CONF = """\
|
||||||
|
[blocklist-import]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter =
|
||||||
|
logpath = /dev/null
|
||||||
|
backend = auto
|
||||||
|
maxretry = 1
|
||||||
|
findtime = 1d
|
||||||
|
bantime = 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
|
||||||
@@ -502,7 +640,8 @@ class TestActivateJail:
|
|||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"app.services.config_file_service._get_active_jail_names",
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
new=AsyncMock(side_effect=[set(), set()]),
|
# First call: pre-activation (not active); second: post-reload (started).
|
||||||
|
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||||
),
|
),
|
||||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
patch(
|
patch(
|
||||||
@@ -2947,3 +3086,434 @@ class TestActivateJailBlocking:
|
|||||||
assert result.active is True
|
assert result.active is True
|
||||||
mock_js.reload_all.assert_awaited_once()
|
mock_js.reload_all.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# activate_jail — rollback on failure (Task 2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestActivateJailRollback:
|
||||||
|
"""Rollback logic in activate_jail restores the .local file and recovers."""
|
||||||
|
|
||||||
|
async def test_activate_jail_rollback_on_reload_failure(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Rollback when reload_all raises on the activation reload.
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- The .local file is restored to its original content.
|
||||||
|
- The response indicates recovered=True.
|
||||||
|
"""
|
||||||
|
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
original_local = "[apache-auth]\nenabled = false\n"
|
||||||
|
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path.write_text(original_local)
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
reload_call_count = 0
|
||||||
|
|
||||||
|
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
|
||||||
|
nonlocal reload_call_count
|
||||||
|
reload_call_count += 1
|
||||||
|
if reload_call_count == 1:
|
||||||
|
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 local_path.read_text() == original_local
|
||||||
|
|
||||||
|
async def test_activate_jail_rollback_on_health_check_failure(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Rollback when fail2ban is unreachable after the activation reload.
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- The .local file is restored to its original content.
|
||||||
|
- The response indicates recovered=True.
|
||||||
|
"""
|
||||||
|
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
original_local = "[apache-auth]\nenabled = false\n"
|
||||||
|
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path.write_text(original_local)
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
probe_call_count = 0
|
||||||
|
|
||||||
|
async def probe_side_effect(socket_path: str) -> bool:
|
||||||
|
nonlocal probe_call_count
|
||||||
|
probe_call_count += 1
|
||||||
|
# First _POST_RELOAD_MAX_ATTEMPTS probes (health-check after
|
||||||
|
# activation) all fail; subsequent probes (recovery) succeed.
|
||||||
|
from app.services.config_file_service import _POST_RELOAD_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
return probe_call_count > _POST_RELOAD_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
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(side_effect=probe_side_effect),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._validate_jail_config_sync",
|
||||||
|
return_value=JailValidationResult(
|
||||||
|
jail_name="apache-auth", valid=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock()
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is False
|
||||||
|
assert result.recovered is True
|
||||||
|
assert local_path.read_text() == original_local
|
||||||
|
|
||||||
|
async def test_activate_jail_rollback_failure(self, tmp_path: Path) -> None:
|
||||||
|
"""recovered=False when both the activation and recovery reloads fail.
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- The response indicates recovered=False.
|
||||||
|
"""
|
||||||
|
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
original_local = "[apache-auth]\nenabled = false\n"
|
||||||
|
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path.write_text(original_local)
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
|
||||||
|
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
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Both the activation reload and the recovery reload fail.
|
||||||
|
mock_js.reload_all = AsyncMock(
|
||||||
|
side_effect=RuntimeError("fail2ban unavailable")
|
||||||
|
)
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is False
|
||||||
|
assert result.recovered is False
|
||||||
|
|
||||||
|
async def test_activate_jail_rollback_on_jail_not_found_error(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Rollback when reload_all raises JailNotFoundError (invalid config).
|
||||||
|
|
||||||
|
When fail2ban cannot create a jail due to invalid configuration
|
||||||
|
(e.g., missing logpath), it raises UnknownJailException which becomes
|
||||||
|
JailNotFoundError. This test verifies proper handling and rollback.
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- The .local file is restored to its original content.
|
||||||
|
- The response indicates recovered=True.
|
||||||
|
- The error message mentions the logpath issue.
|
||||||
|
"""
|
||||||
|
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||||
|
from app.services.jail_service import JailNotFoundError
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
original_local = "[apache-auth]\nenabled = false\n"
|
||||||
|
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path.write_text(original_local)
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
reload_call_count = 0
|
||||||
|
|
||||||
|
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
|
||||||
|
nonlocal reload_call_count
|
||||||
|
reload_call_count += 1
|
||||||
|
if reload_call_count == 1:
|
||||||
|
# Simulate UnknownJailException from fail2ban due to missing logpath.
|
||||||
|
raise JailNotFoundError("apache-auth")
|
||||||
|
# Recovery reload succeeds.
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
),
|
||||||
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._probe_fail2ban_running",
|
||||||
|
new=AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._validate_jail_config_sync",
|
||||||
|
return_value=JailValidationResult(
|
||||||
|
jail_name="apache-auth", valid=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
|
||||||
|
mock_js.JailNotFoundError = JailNotFoundError
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is False
|
||||||
|
assert result.recovered is True
|
||||||
|
assert local_path.read_text() == original_local
|
||||||
|
# Verify the error message mentions logpath issues.
|
||||||
|
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
|
||||||
|
|
||||||
|
async def test_activate_jail_rollback_deletes_file_when_no_prior_local(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Rollback deletes the .local file when none existed before activation.
|
||||||
|
|
||||||
|
When a jail had no .local override before activation, activate_jail
|
||||||
|
creates one with enabled = true. If reload then crashes, rollback must
|
||||||
|
delete that file (leaving the jail in the same state as before the
|
||||||
|
activation attempt).
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- The .local file is absent after rollback.
|
||||||
|
- The response indicates recovered=True.
|
||||||
|
"""
|
||||||
|
from app.models.config import ActivateJailRequest, JailValidationResult
|
||||||
|
|
||||||
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
|
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
|
||||||
|
local_path = tmp_path / "jail.d" / "apache-auth.local"
|
||||||
|
# No .local file exists before activation.
|
||||||
|
assert not local_path.exists()
|
||||||
|
|
||||||
|
req = ActivateJailRequest()
|
||||||
|
reload_call_count = 0
|
||||||
|
|
||||||
|
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
|
||||||
|
nonlocal reload_call_count
|
||||||
|
reload_call_count += 1
|
||||||
|
if reload_call_count == 1:
|
||||||
|
raise RuntimeError("fail2ban crashed")
|
||||||
|
# Recovery reload succeeds.
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
new=AsyncMock(return_value=set()),
|
||||||
|
),
|
||||||
|
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._probe_fail2ban_running",
|
||||||
|
new=AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._validate_jail_config_sync",
|
||||||
|
return_value=JailValidationResult(
|
||||||
|
jail_name="apache-auth", valid=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
|
||||||
|
result = await activate_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "apache-auth", req
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active is False
|
||||||
|
assert result.recovered is True
|
||||||
|
assert not local_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# rollback_jail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestRollbackJail:
|
||||||
|
"""Integration tests for :func:`~app.services.config_file_service.rollback_jail`."""
|
||||||
|
|
||||||
|
async def test_local_file_written_enabled_false(self, tmp_path: Path) -> None:
|
||||||
|
"""rollback_jail writes enabled=false to jail.d/{name}.local before any socket call."""
|
||||||
|
(tmp_path / "jail.d").mkdir()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.start_daemon",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.wait_for_fail2ban",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
AsyncMock(return_value={"sshd"}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await rollback_jail(str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"])
|
||||||
|
|
||||||
|
local = tmp_path / "jail.d" / "sshd.local"
|
||||||
|
assert local.is_file(), "jail.d/sshd.local must be written"
|
||||||
|
content = local.read_text()
|
||||||
|
assert "enabled = false" in content
|
||||||
|
|
||||||
|
async def test_start_command_invoked_via_subprocess(self, tmp_path: Path) -> None:
|
||||||
|
"""rollback_jail invokes the daemon start command via start_daemon, not via socket."""
|
||||||
|
mock_start = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.config_file_service.start_daemon", mock_start),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.wait_for_fail2ban",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
AsyncMock(return_value={"other"}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await rollback_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_start.assert_awaited_once_with(["fail2ban-client", "start"])
|
||||||
|
|
||||||
|
async def test_fail2ban_running_reflects_socket_probe_not_subprocess_exit(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""fail2ban_running in the response reflects the socket probe result.
|
||||||
|
|
||||||
|
Even when start_daemon returns True (subprocess exit 0), if the socket
|
||||||
|
probe returns False the response must report fail2ban_running=False.
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.start_daemon",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.wait_for_fail2ban",
|
||||||
|
AsyncMock(return_value=False), # socket still unresponsive
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await rollback_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.fail2ban_running is False
|
||||||
|
|
||||||
|
async def test_active_jails_zero_when_fail2ban_not_running(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""active_jails is 0 in the response when fail2ban_running is False."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.start_daemon",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.wait_for_fail2ban",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await rollback_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active_jails == 0
|
||||||
|
|
||||||
|
async def test_active_jails_count_from_socket_when_running(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""active_jails reflects the actual jail count from the socket when fail2ban is up."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.start_daemon",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.wait_for_fail2ban",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
|
AsyncMock(return_value={"sshd", "nginx", "apache-auth"}),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await rollback_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.active_jails == 3
|
||||||
|
|
||||||
|
async def test_fail2ban_down_at_start_still_succeeds_file_write(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""rollback_jail writes the local file even when fail2ban is down at call time."""
|
||||||
|
# fail2ban is down: start_daemon fails and wait_for_fail2ban returns False.
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.start_daemon",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.config_file_service.wait_for_fail2ban",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await rollback_jail(
|
||||||
|
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||||
|
)
|
||||||
|
|
||||||
|
local = tmp_path / "jail.d" / "sshd.local"
|
||||||
|
assert local.is_file(), "local file must be written even when fail2ban is down"
|
||||||
|
assert result.disabled is True
|
||||||
|
assert result.fail2ban_running is False
|
||||||
|
|
||||||
|
|||||||
@@ -184,10 +184,90 @@ class TestListJails:
|
|||||||
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
|
with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError):
|
||||||
await jail_service.list_jails(_SOCKET)
|
await jail_service.list_jails(_SOCKET)
|
||||||
|
|
||||||
|
async def test_backend_idle_commands_unsupported(self) -> None:
|
||||||
|
"""list_jails handles unsupported backend and idle commands gracefully.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
When the fail2ban daemon does not support get ... backend/idle commands,
|
||||||
# get_jail
|
list_jails should not send them, avoiding "Invalid command" errors in the
|
||||||
# ---------------------------------------------------------------------------
|
fail2ban log.
|
||||||
|
"""
|
||||||
|
# Reset the capability cache to test detection.
|
||||||
|
jail_service._backend_cmd_supported = None
|
||||||
|
|
||||||
|
responses = {
|
||||||
|
"status": _make_global_status("sshd"),
|
||||||
|
"status|sshd|short": _make_short_status(),
|
||||||
|
# Capability probe: get backend fails (command not supported).
|
||||||
|
"get|sshd|backend": (1, Exception("Invalid command (no get action or not yet implemented)")),
|
||||||
|
# Subsequent gets should still work.
|
||||||
|
"get|sshd|bantime": (0, 600),
|
||||||
|
"get|sshd|findtime": (0, 600),
|
||||||
|
"get|sshd|maxretry": (0, 5),
|
||||||
|
}
|
||||||
|
with _patch_client(responses):
|
||||||
|
result = await jail_service.list_jails(_SOCKET)
|
||||||
|
|
||||||
|
# Verify the result uses the default values for backend and idle.
|
||||||
|
jail = result.jails[0]
|
||||||
|
assert jail.backend == "polling" # default
|
||||||
|
assert jail.idle is False # default
|
||||||
|
# Capability should now be cached as False.
|
||||||
|
assert jail_service._backend_cmd_supported is False
|
||||||
|
|
||||||
|
async def test_backend_idle_commands_supported(self) -> None:
|
||||||
|
"""list_jails detects and sends backend/idle commands when supported."""
|
||||||
|
# Reset the capability cache to test detection.
|
||||||
|
jail_service._backend_cmd_supported = None
|
||||||
|
|
||||||
|
responses = {
|
||||||
|
"status": _make_global_status("sshd"),
|
||||||
|
"status|sshd|short": _make_short_status(),
|
||||||
|
# Capability probe: get backend succeeds.
|
||||||
|
"get|sshd|backend": (0, "systemd"),
|
||||||
|
# All other commands.
|
||||||
|
"get|sshd|bantime": (0, 600),
|
||||||
|
"get|sshd|findtime": (0, 600),
|
||||||
|
"get|sshd|maxretry": (0, 5),
|
||||||
|
"get|sshd|idle": (0, True),
|
||||||
|
}
|
||||||
|
with _patch_client(responses):
|
||||||
|
result = await jail_service.list_jails(_SOCKET)
|
||||||
|
|
||||||
|
# Verify real values are returned.
|
||||||
|
jail = result.jails[0]
|
||||||
|
assert jail.backend == "systemd" # real value
|
||||||
|
assert jail.idle is True # real value
|
||||||
|
# Capability should now be cached as True.
|
||||||
|
assert jail_service._backend_cmd_supported is True
|
||||||
|
|
||||||
|
async def test_backend_idle_commands_cached_after_first_probe(self) -> None:
|
||||||
|
"""list_jails caches capability result and reuses it across polling cycles."""
|
||||||
|
# Reset the capability cache.
|
||||||
|
jail_service._backend_cmd_supported = None
|
||||||
|
|
||||||
|
responses = {
|
||||||
|
"status": _make_global_status("sshd, nginx"),
|
||||||
|
# Probes happen once per polling cycle (for the first jail listed).
|
||||||
|
"status|sshd|short": _make_short_status(),
|
||||||
|
"status|nginx|short": _make_short_status(),
|
||||||
|
# Capability probe: backend is unsupported.
|
||||||
|
"get|sshd|backend": (1, Exception("Invalid command")),
|
||||||
|
# Subsequent jails do not trigger another probe; they use cached result.
|
||||||
|
# (The mock doesn't have get|nginx|backend because it shouldn't be called.)
|
||||||
|
"get|sshd|bantime": (0, 600),
|
||||||
|
"get|sshd|findtime": (0, 600),
|
||||||
|
"get|sshd|maxretry": (0, 5),
|
||||||
|
"get|nginx|bantime": (0, 600),
|
||||||
|
"get|nginx|findtime": (0, 600),
|
||||||
|
"get|nginx|maxretry": (0, 5),
|
||||||
|
}
|
||||||
|
with _patch_client(responses):
|
||||||
|
result = await jail_service.list_jails(_SOCKET)
|
||||||
|
|
||||||
|
# Both jails should return default values (cached result is False).
|
||||||
|
for jail in result.jails:
|
||||||
|
assert jail.backend == "polling"
|
||||||
|
assert jail.idle is False
|
||||||
|
|
||||||
|
|
||||||
class TestGetJail:
|
class TestGetJail:
|
||||||
@@ -339,6 +419,55 @@ class TestJailControls:
|
|||||||
_SOCKET, include_jails=["new"], exclude_jails=["old"]
|
_SOCKET, include_jails=["new"], exclude_jails=["old"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_reload_all_unknown_jail_raises_jail_not_found(self) -> None:
|
||||||
|
"""reload_all detects UnknownJailException and raises JailNotFoundError.
|
||||||
|
|
||||||
|
When fail2ban cannot load a jail due to invalid configuration (e.g.,
|
||||||
|
missing logpath), it raises UnknownJailException during reload. This
|
||||||
|
test verifies that reload_all detects this and re-raises as
|
||||||
|
JailNotFoundError instead of the generic JailOperationError.
|
||||||
|
"""
|
||||||
|
with _patch_client(
|
||||||
|
{
|
||||||
|
"status": _make_global_status("sshd"),
|
||||||
|
"reload|--all|[]|[['start', 'airsonic-auth'], ['start', 'sshd']]": (
|
||||||
|
1,
|
||||||
|
Exception("UnknownJailException('airsonic-auth')"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
), pytest.raises(jail_service.JailNotFoundError) as exc_info:
|
||||||
|
await jail_service.reload_all(
|
||||||
|
_SOCKET, include_jails=["airsonic-auth"]
|
||||||
|
)
|
||||||
|
assert exc_info.value.name == "airsonic-auth"
|
||||||
|
|
||||||
|
async def test_restart_sends_stop_command(self) -> None:
|
||||||
|
"""restart() sends the ['stop'] command to the fail2ban socket."""
|
||||||
|
with _patch_client({"stop": (0, None)}):
|
||||||
|
await jail_service.restart(_SOCKET) # should not raise
|
||||||
|
|
||||||
|
async def test_restart_operation_error_raises(self) -> None:
|
||||||
|
"""restart() raises JailOperationError when fail2ban rejects the stop."""
|
||||||
|
with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises(
|
||||||
|
JailOperationError
|
||||||
|
):
|
||||||
|
await jail_service.restart(_SOCKET)
|
||||||
|
|
||||||
|
async def test_restart_connection_error_propagates(self) -> None:
|
||||||
|
"""restart() propagates Fail2BanConnectionError when socket is unreachable."""
|
||||||
|
|
||||||
|
class _FailClient:
|
||||||
|
def __init__(self, **_kw: Any) -> None:
|
||||||
|
self.send = AsyncMock(
|
||||||
|
side_effect=Fail2BanConnectionError("no socket", _SOCKET)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.jail_service.Fail2BanClient", _FailClient),
|
||||||
|
pytest.raises(Fail2BanConnectionError),
|
||||||
|
):
|
||||||
|
await jail_service.restart(_SOCKET)
|
||||||
|
|
||||||
async def test_start_not_found_raises(self) -> None:
|
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.4",
|
||||||
"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,
|
||||||
@@ -88,7 +86,7 @@ export async function updateGlobalConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Reload
|
// Reload and Restart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function reloadConfig(
|
export async function reloadConfig(
|
||||||
@@ -96,6 +94,11 @@ export async function reloadConfig(
|
|||||||
await post<undefined>(ENDPOINTS.configReload, undefined);
|
await post<undefined>(ENDPOINTS.configReload, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restartFail2Ban(
|
||||||
|
): Promise<void> {
|
||||||
|
await post<undefined>(ENDPOINTS.configRestart, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Regex tester
|
// Regex tester
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -260,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(
|
||||||
@@ -547,6 +550,18 @@ export async function deactivateJail(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
*
|
||||||
|
* Only valid when the jail is **not** currently active. Use this to clean up
|
||||||
|
* leftover ``.local`` files after a jail has been fully deactivated.
|
||||||
|
*
|
||||||
|
* @param name - The jail name.
|
||||||
|
*/
|
||||||
|
export async function deleteJailLocalOverride(name: string): Promise<void> {
|
||||||
|
await del<undefined>(ENDPOINTS.configJailLocalOverride(name));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// fail2ban log viewer (Task 2)
|
// fail2ban log viewer (Task 2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -588,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,13 +71,13 @@ 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",
|
||||||
configRegexTest: "/config/regex-test",
|
configRegexTest: "/config/regex-test",
|
||||||
configPreviewLog: "/config/preview-log",
|
configPreviewLog: "/config/preview-log",
|
||||||
configMapColorThresholds: "/config/map-color-thresholds",
|
configMapColorThresholds: "/config/map-color-thresholds",
|
||||||
@@ -104,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;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { PieLabelRenderProps } from "recharts";
|
import type { PieLabelRenderProps } from "recharts";
|
||||||
|
import type { LegendPayload } from "recharts/types/component/DefaultLegendContent";
|
||||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||||
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
||||||
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
||||||
@@ -153,12 +154,19 @@ export function TopCountriesPieChart({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format legend entries as "Country Name (xx%)" */
|
/** Format legend entries as "Country Name (xx%)" and colour them to match their slice. */
|
||||||
const legendFormatter = (value: string): string => {
|
const legendFormatter = (
|
||||||
|
value: string,
|
||||||
|
entry: LegendPayload,
|
||||||
|
): React.ReactNode => {
|
||||||
const slice = slices.find((s) => s.name === value);
|
const slice = slices.find((s) => s.name === value);
|
||||||
if (slice == null || total === 0) return value;
|
if (slice == null || total === 0) return value;
|
||||||
const pct = ((slice.value / total) * 100).toFixed(1);
|
const pct = ((slice.value / total) * 100).toFixed(1);
|
||||||
return `${value} (${pct}%)`;
|
return (
|
||||||
|
<span style={{ color: entry.color }}>
|
||||||
|
{value} ({pct}%)
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
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";
|
||||||
@@ -26,6 +22,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
|
MessageBarTitle,
|
||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -51,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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -76,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("");
|
||||||
@@ -85,6 +76,7 @@ export function ActivateJailDialog({
|
|||||||
const [logpath, setLogpath] = useState("");
|
const [logpath, setLogpath] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [recoveryStatus, setRecoveryStatus] = useState<"recovered" | "unrecovered" | null>(null);
|
||||||
|
|
||||||
// Pre-activation validation state
|
// Pre-activation validation state
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
@@ -98,6 +90,7 @@ export function ActivateJailDialog({
|
|||||||
setPort("");
|
setPort("");
|
||||||
setLogpath("");
|
setLogpath("");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setRecoveryStatus(null);
|
||||||
setValidationIssues([]);
|
setValidationIssues([]);
|
||||||
setValidationWarnings([]);
|
setValidationWarnings([]);
|
||||||
};
|
};
|
||||||
@@ -153,19 +146,23 @@ export function ActivateJailDialog({
|
|||||||
activateJail(jail.name, overrides)
|
activateJail(jail.name, overrides)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result.active) {
|
if (!result.active) {
|
||||||
// Backend rejected the activation (e.g. missing logpath or filter).
|
if (result.recovered === true) {
|
||||||
// Show the server's message and keep the dialog open so the user
|
// Activation failed but the system rolled back automatically.
|
||||||
// can read the explanation without the dialog disappearing.
|
setRecoveryStatus("recovered");
|
||||||
setError(result.message);
|
} else if (result.recovered === false) {
|
||||||
|
// Activation failed and rollback also failed.
|
||||||
|
setRecoveryStatus("unrecovered");
|
||||||
|
} else {
|
||||||
|
// Backend rejected before writing (e.g. missing logpath or filter).
|
||||||
|
// Show the server's message and keep the dialog open.
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.validation_warnings.length > 0) {
|
if (result.validation_warnings.length > 0) {
|
||||||
setValidationWarnings(result.validation_warnings);
|
setValidationWarnings(result.validation_warnings);
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
if (!result.fail2ban_running) {
|
|
||||||
onCrashDetected?.();
|
|
||||||
}
|
|
||||||
onActivated();
|
onActivated();
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
@@ -323,6 +320,34 @@ export function ActivateJailDialog({
|
|||||||
onChange={(_e, d) => { setLogpath(d.value); }}
|
onChange={(_e, d) => { setLogpath(d.value); }}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{recoveryStatus === "recovered" && (
|
||||||
|
<MessageBar
|
||||||
|
intent="warning"
|
||||||
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>Activation Failed — Configuration Rolled Back</MessageBarTitle>
|
||||||
|
The configuration for jail “{jail.name}” has been
|
||||||
|
rolled back to its previous state and fail2ban is running
|
||||||
|
normally. Review the configuration and try activating again.
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
{recoveryStatus === "unrecovered" && (
|
||||||
|
<MessageBar
|
||||||
|
intent="error"
|
||||||
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
<MessageBarBody>
|
||||||
|
<MessageBarTitle>Activation Failed — Rollback Unsuccessful</MessageBarTitle>
|
||||||
|
Activation of jail “{jail.name}” failed and the
|
||||||
|
automatic rollback did not complete. The file{" "}
|
||||||
|
<code>jail.d/{jail.name}.local</code> may still contain{" "}
|
||||||
|
<code>enabled = true</code>. Check the fail2ban logs, correct
|
||||||
|
the file manually, and restart fail2ban.
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<MessageBar
|
<MessageBar
|
||||||
intent="error"
|
intent="error"
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* GlobalTab — global fail2ban settings editor.
|
|
||||||
*
|
|
||||||
* Provides form fields for log level, log target, database purge age,
|
|
||||||
* and database max matches.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
MessageBar,
|
|
||||||
MessageBarBody,
|
|
||||||
Select,
|
|
||||||
Spinner,
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import type { GlobalConfigUpdate } from "../../types/config";
|
|
||||||
import { useGlobalConfig } from "../../hooks/useConfig";
|
|
||||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
|
||||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
|
||||||
import { useConfigStyles } from "./configStyles";
|
|
||||||
|
|
||||||
/** Available fail2ban log levels in descending severity order. */
|
|
||||||
const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tab component for editing global fail2ban configuration.
|
|
||||||
*
|
|
||||||
* @returns JSX element.
|
|
||||||
*/
|
|
||||||
export function GlobalTab(): React.JSX.Element {
|
|
||||||
const styles = useConfigStyles();
|
|
||||||
const { config, loading, error, updateConfig } = useGlobalConfig();
|
|
||||||
const [logLevel, setLogLevel] = useState("");
|
|
||||||
const [logTarget, setLogTarget] = useState("");
|
|
||||||
const [dbPurgeAge, setDbPurgeAge] = useState("");
|
|
||||||
const [dbMaxMatches, setDbMaxMatches] = useState("");
|
|
||||||
|
|
||||||
// Sync local state when config loads for the first time.
|
|
||||||
useEffect(() => {
|
|
||||||
if (config && logLevel === "") {
|
|
||||||
setLogLevel(config.log_level);
|
|
||||||
setLogTarget(config.log_target);
|
|
||||||
setDbPurgeAge(String(config.db_purge_age));
|
|
||||||
setDbMaxMatches(String(config.db_max_matches));
|
|
||||||
}
|
|
||||||
// Only run on first config load.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const effectiveLogLevel = logLevel || config?.log_level || "";
|
|
||||||
const effectiveLogTarget = logTarget || config?.log_target || "";
|
|
||||||
const effectiveDbPurgeAge =
|
|
||||||
dbPurgeAge || (config ? String(config.db_purge_age) : "");
|
|
||||||
const effectiveDbMaxMatches =
|
|
||||||
dbMaxMatches || (config ? String(config.db_max_matches) : "");
|
|
||||||
|
|
||||||
const updatePayload = useMemo<GlobalConfigUpdate>(() => {
|
|
||||||
const update: GlobalConfigUpdate = {};
|
|
||||||
if (effectiveLogLevel) update.log_level = effectiveLogLevel;
|
|
||||||
if (effectiveLogTarget) update.log_target = effectiveLogTarget;
|
|
||||||
if (effectiveDbPurgeAge)
|
|
||||||
update.db_purge_age = Number(effectiveDbPurgeAge);
|
|
||||||
if (effectiveDbMaxMatches)
|
|
||||||
update.db_max_matches = Number(effectiveDbMaxMatches);
|
|
||||||
return update;
|
|
||||||
}, [effectiveLogLevel, effectiveLogTarget, effectiveDbPurgeAge, effectiveDbMaxMatches]);
|
|
||||||
|
|
||||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
|
||||||
useAutoSave(updatePayload, updateConfig);
|
|
||||||
|
|
||||||
if (loading) return <Spinner label="Loading global config…" />;
|
|
||||||
if (error)
|
|
||||||
return (
|
|
||||||
<MessageBar intent="error">
|
|
||||||
<MessageBarBody>{error}</MessageBarBody>
|
|
||||||
</MessageBar>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.sectionCard}>
|
|
||||||
<AutoSaveIndicator
|
|
||||||
status={saveStatus}
|
|
||||||
errorText={saveErrorText}
|
|
||||||
onRetry={retrySave}
|
|
||||||
/>
|
|
||||||
<div className={styles.fieldRow}>
|
|
||||||
<Field label="Log Level">
|
|
||||||
<Select
|
|
||||||
value={effectiveLogLevel}
|
|
||||||
onChange={(_e, d) => {
|
|
||||||
setLogLevel(d.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LOG_LEVELS.map((l) => (
|
|
||||||
<option key={l} value={l}>
|
|
||||||
{l}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field label="Log Target">
|
|
||||||
<Input
|
|
||||||
value={effectiveLogTarget}
|
|
||||||
placeholder="STDOUT / /var/log/fail2ban.log"
|
|
||||||
onChange={(_e, d) => {
|
|
||||||
setLogTarget(d.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className={styles.fieldRow}>
|
|
||||||
<Field
|
|
||||||
label="DB Purge Age (s)"
|
|
||||||
hint="Ban records older than this are removed from the fail2ban database."
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={effectiveDbPurgeAge}
|
|
||||||
onChange={(_e, d) => {
|
|
||||||
setDbPurgeAge(d.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label="DB Max Matches"
|
|
||||||
hint="Maximum number of log-line matches stored per ban record."
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={effectiveDbMaxMatches}
|
|
||||||
onChange={(_e, d) => {
|
|
||||||
setDbMaxMatches(d.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -35,6 +35,7 @@ import { ApiError } from "../../api/client";
|
|||||||
import {
|
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
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* MapTab — world map color threshold configuration editor.
|
|
||||||
*
|
|
||||||
* Allows the user to set the low / medium / high ban-count thresholds
|
|
||||||
* that drive country fill colors on the World Map page.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
Input,
|
|
||||||
MessageBar,
|
|
||||||
MessageBarBody,
|
|
||||||
Skeleton,
|
|
||||||
SkeletonItem,
|
|
||||||
Text,
|
|
||||||
tokens,
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import { ApiError } from "../../api/client";
|
|
||||||
import {
|
|
||||||
fetchMapColorThresholds,
|
|
||||||
updateMapColorThresholds,
|
|
||||||
} from "../../api/config";
|
|
||||||
import type { MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
|
||||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
|
||||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
|
||||||
import { useConfigStyles } from "./configStyles";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Inner form — only mounted after data is loaded.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface MapFormProps {
|
|
||||||
initial: MapColorThresholdsResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapForm({ initial }: MapFormProps): React.JSX.Element {
|
|
||||||
const styles = useConfigStyles();
|
|
||||||
const [thresholdHigh, setThresholdHigh] = useState(String(initial.threshold_high));
|
|
||||||
const [thresholdMedium, setThresholdMedium] = useState(String(initial.threshold_medium));
|
|
||||||
const [thresholdLow, setThresholdLow] = useState(String(initial.threshold_low));
|
|
||||||
|
|
||||||
const high = Number(thresholdHigh);
|
|
||||||
const medium = Number(thresholdMedium);
|
|
||||||
const low = Number(thresholdLow);
|
|
||||||
|
|
||||||
const validationError = useMemo<string | null>(() => {
|
|
||||||
if (isNaN(high) || isNaN(medium) || isNaN(low))
|
|
||||||
return "All thresholds must be valid numbers.";
|
|
||||||
if (high <= 0 || medium <= 0 || low <= 0)
|
|
||||||
return "All thresholds must be positive integers.";
|
|
||||||
if (!(high > medium && medium > low))
|
|
||||||
return "Thresholds must satisfy: high > medium > low.";
|
|
||||||
return null;
|
|
||||||
}, [high, medium, low]);
|
|
||||||
|
|
||||||
// Only pass a new payload to useAutoSave when all values are valid.
|
|
||||||
const [validPayload, setValidPayload] = useState<MapColorThresholdsUpdate>({
|
|
||||||
threshold_high: initial.threshold_high,
|
|
||||||
threshold_medium: initial.threshold_medium,
|
|
||||||
threshold_low: initial.threshold_low,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (validationError !== null) return;
|
|
||||||
setValidPayload({ threshold_high: high, threshold_medium: medium, threshold_low: low });
|
|
||||||
}, [high, medium, low, validationError]);
|
|
||||||
|
|
||||||
const saveThresholds = useCallback(
|
|
||||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
|
||||||
await updateMapColorThresholds(payload);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
|
||||||
useAutoSave(validPayload, saveThresholds);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.sectionCard}>
|
|
||||||
<Text as="h3" size={500} weight="semibold" block>
|
|
||||||
Map Color Thresholds
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
size={300}
|
|
||||||
className={styles.infoText}
|
|
||||||
block
|
|
||||||
style={{ marginBottom: tokens.spacingVerticalM }}
|
|
||||||
>
|
|
||||||
Configure the ban count thresholds that determine country fill colors on
|
|
||||||
the World Map. Countries with zero bans remain transparent. Colors
|
|
||||||
smoothly interpolate between thresholds.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
|
||||||
<AutoSaveIndicator
|
|
||||||
status={validationError ? "idle" : saveStatus}
|
|
||||||
errorText={saveErrorText}
|
|
||||||
onRetry={retrySave}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{validationError && (
|
|
||||||
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
|
||||||
<MessageBarBody>{validationError}</MessageBarBody>
|
|
||||||
</MessageBar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.fieldRowThree}>
|
|
||||||
<Field label="Low Threshold (Green)" required>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={thresholdLow}
|
|
||||||
onChange={(_, d) => {
|
|
||||||
setThresholdLow(d.value);
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Medium Threshold (Yellow)" required>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={thresholdMedium}
|
|
||||||
onChange={(_, d) => {
|
|
||||||
setThresholdMedium(d.value);
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="High Threshold (Red)" required>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={thresholdHigh}
|
|
||||||
onChange={(_, d) => {
|
|
||||||
setThresholdHigh(d.value);
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
as="p"
|
|
||||||
size={200}
|
|
||||||
className={styles.infoText}
|
|
||||||
style={{ marginTop: tokens.spacingVerticalS }}
|
|
||||||
>
|
|
||||||
• 1 to {thresholdLow}: Light green → Full green
|
|
||||||
<br />• {thresholdLow} to {thresholdMedium}: Green → Yellow
|
|
||||||
<br />• {thresholdMedium} to {thresholdHigh}: Yellow → Red
|
|
||||||
<br />• {thresholdHigh}+: Solid red
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Outer loader component.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tab component for editing world-map ban-count color thresholds.
|
|
||||||
*
|
|
||||||
* @returns JSX element.
|
|
||||||
*/
|
|
||||||
export function MapTab(): React.JSX.Element {
|
|
||||||
const [thresholds, setThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const data = await fetchMapColorThresholds();
|
|
||||||
setThresholds(data);
|
|
||||||
} catch (err) {
|
|
||||||
setLoadError(
|
|
||||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (!thresholds && !loadError) {
|
|
||||||
return (
|
|
||||||
<Skeleton aria-label="Loading map settings…">
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
|
|
||||||
<SkeletonItem size={32} />
|
|
||||||
<SkeletonItem size={32} />
|
|
||||||
<SkeletonItem size={32} />
|
|
||||||
</div>
|
|
||||||
</Skeleton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loadError)
|
|
||||||
return (
|
|
||||||
<MessageBar intent="error">
|
|
||||||
<MessageBarBody>{loadError}</MessageBarBody>
|
|
||||||
</MessageBar>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!thresholds) return <></>;
|
|
||||||
|
|
||||||
return <MapForm initial={thresholds} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* LogTab — fail2ban log viewer and service health panel.
|
* ServerHealthSection — service health panel and log viewer for ServerTab.
|
||||||
*
|
*
|
||||||
* Renders two sections:
|
* Renders two sections:
|
||||||
* 1. **Service Health panel** — shows online/offline state, version, active
|
* 1. **Service Health panel** — shows online/offline state, version, active
|
||||||
* jail count, total bans, total failures, log level, and log target.
|
* jail count, total bans, total failures, log level, and log target.
|
||||||
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
* 2. **Log viewer** — displays the tail of the fail2ban daemon log file with
|
||||||
* toolbar controls for line count, substring filter, manual refresh, and
|
* toolbar controls for line count, substring filter, manual refresh, and
|
||||||
* optional auto-refresh. Log lines are color-coded by severity.
|
* optional auto-refresh. Log lines are color-coded by severity.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -167,13 +167,11 @@ function detectSeverity(line: string): "error" | "warning" | "debug" | "default"
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log tab component for the Configuration page.
|
* Server health panel and log viewer section for ServerTab.
|
||||||
*
|
|
||||||
* Shows fail2ban service health and a live log viewer with refresh controls.
|
|
||||||
*
|
*
|
||||||
* @returns JSX element.
|
* @returns JSX element.
|
||||||
*/
|
*/
|
||||||
export function LogTab(): React.JSX.Element {
|
export function ServerHealthSection(): React.JSX.Element {
|
||||||
const configStyles = useConfigStyles();
|
const configStyles = useConfigStyles();
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
@@ -317,10 +315,8 @@ export function LogTab(): React.JSX.Element {
|
|||||||
logData != null && logData.total_lines > logData.lines.length;
|
logData != null && logData.total_lines > logData.lines.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* Service Health Panel */}
|
||||||
{/* Service Health Panel */}
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
|
||||||
<div className={configStyles.sectionCard}>
|
<div className={configStyles.sectionCard}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM }}>
|
||||||
<DocumentBulletList24Regular />
|
<DocumentBulletList24Regular />
|
||||||
@@ -384,9 +380,7 @@ export function LogTab(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* Log Viewer */}
|
||||||
{/* Log Viewer */}
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
|
||||||
<div className={configStyles.sectionCard}>
|
<div className={configStyles.sectionCard}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, marginBottom: tokens.spacingVerticalM }}>
|
||||||
<Text weight="semibold" size={400}>
|
<Text weight="semibold" size={400}>
|
||||||
@@ -513,6 +507,6 @@ export function LogTab(): React.JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
* ServerTab — fail2ban server-level settings editor.
|
* ServerTab — fail2ban server-level settings editor.
|
||||||
*
|
*
|
||||||
* Provides form fields for live server settings (log level, log target,
|
* Provides form fields for live server settings (log level, log target,
|
||||||
* DB purge age, DB max matches) and a "Flush Logs" action button.
|
* DB purge age, DB max matches), action buttons (flush logs, reload fail2ban,
|
||||||
|
* restart fail2ban), world map color threshold configuration, and service
|
||||||
|
* health + log viewer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Field,
|
Field,
|
||||||
@@ -15,16 +17,25 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonItem,
|
SkeletonItem,
|
||||||
|
Text,
|
||||||
tokens,
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import {
|
import {
|
||||||
DocumentArrowDown24Regular,
|
DocumentArrowDown24Regular,
|
||||||
|
ArrowSync24Regular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import type { ServerSettingsUpdate } from "../../types/config";
|
import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||||
import { useServerSettings } from "../../hooks/useConfig";
|
import { useServerSettings } from "../../hooks/useConfig";
|
||||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||||
|
import {
|
||||||
|
fetchMapColorThresholds,
|
||||||
|
updateMapColorThresholds,
|
||||||
|
reloadConfig,
|
||||||
|
restartFail2Ban,
|
||||||
|
} from "../../api/config";
|
||||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||||
|
import { ServerHealthSection } from "./ServerHealthSection";
|
||||||
import { useConfigStyles } from "./configStyles";
|
import { useConfigStyles } from "./configStyles";
|
||||||
|
|
||||||
/** Available fail2ban log levels in descending severity order. */
|
/** Available fail2ban log levels in descending severity order. */
|
||||||
@@ -46,6 +57,17 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
const [flushing, setFlushing] = useState(false);
|
const [flushing, setFlushing] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
// Reload/Restart state
|
||||||
|
const [isReloading, setIsReloading] = useState(false);
|
||||||
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
|
|
||||||
|
// Map color thresholds
|
||||||
|
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||||
|
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
|
||||||
|
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
|
||||||
|
const [mapThresholdLow, setMapThresholdLow] = useState("");
|
||||||
|
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
||||||
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
||||||
const effectiveDbPurgeAge =
|
const effectiveDbPurgeAge =
|
||||||
@@ -83,6 +105,99 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [flush]);
|
}, [flush]);
|
||||||
|
|
||||||
|
const handleReload = useCallback(async () => {
|
||||||
|
setIsReloading(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await reloadConfig();
|
||||||
|
setMsg({ text: "fail2ban reloaded successfully", ok: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setMsg({
|
||||||
|
text: err instanceof ApiError ? err.message : "Reload failed.",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsReloading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRestart = useCallback(async () => {
|
||||||
|
setIsRestarting(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await restartFail2Ban();
|
||||||
|
setMsg({ text: "fail2ban restart initiated", ok: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setMsg({
|
||||||
|
text: err instanceof ApiError ? err.message : "Restart failed.",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRestarting(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load map color thresholds on mount.
|
||||||
|
const loadMapThresholds = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data = await fetchMapColorThresholds();
|
||||||
|
setMapThresholds(data);
|
||||||
|
setMapThresholdHigh(String(data.threshold_high));
|
||||||
|
setMapThresholdMedium(String(data.threshold_medium));
|
||||||
|
setMapThresholdLow(String(data.threshold_low));
|
||||||
|
setMapLoadError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setMapLoadError(
|
||||||
|
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMapThresholds();
|
||||||
|
}, [loadMapThresholds]);
|
||||||
|
|
||||||
|
// Map threshold validation and auto-save.
|
||||||
|
const mapHigh = Number(mapThresholdHigh);
|
||||||
|
const mapMedium = Number(mapThresholdMedium);
|
||||||
|
const mapLow = Number(mapThresholdLow);
|
||||||
|
|
||||||
|
const mapValidationError = useMemo<string | null>(() => {
|
||||||
|
if (!mapThresholds) return null;
|
||||||
|
if (isNaN(mapHigh) || isNaN(mapMedium) || isNaN(mapLow))
|
||||||
|
return "All thresholds must be valid numbers.";
|
||||||
|
if (mapHigh <= 0 || mapMedium <= 0 || mapLow <= 0)
|
||||||
|
return "All thresholds must be positive integers.";
|
||||||
|
if (!(mapHigh > mapMedium && mapMedium > mapLow))
|
||||||
|
return "Thresholds must satisfy: high > medium > low.";
|
||||||
|
return null;
|
||||||
|
}, [mapHigh, mapMedium, mapLow, mapThresholds]);
|
||||||
|
|
||||||
|
const [mapValidPayload, setMapValidPayload] = useState<MapColorThresholdsUpdate>({
|
||||||
|
threshold_high: mapThresholds?.threshold_high ?? 0,
|
||||||
|
threshold_medium: mapThresholds?.threshold_medium ?? 0,
|
||||||
|
threshold_low: mapThresholds?.threshold_low ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapValidationError !== null || !mapThresholds) return;
|
||||||
|
setMapValidPayload({
|
||||||
|
threshold_high: mapHigh,
|
||||||
|
threshold_medium: mapMedium,
|
||||||
|
threshold_low: mapLow,
|
||||||
|
});
|
||||||
|
}, [mapHigh, mapMedium, mapLow, mapValidationError, mapThresholds]);
|
||||||
|
|
||||||
|
const saveMapThresholds = useCallback(
|
||||||
|
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||||
|
await updateMapColorThresholds(payload);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
|
||||||
|
useAutoSave(mapValidPayload, saveMapThresholds);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Skeleton aria-label="Loading server settings…">
|
<Skeleton aria-label="Loading server settings…">
|
||||||
@@ -104,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
|
||||||
@@ -154,7 +273,10 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fieldRow}>
|
<div className={styles.fieldRow}>
|
||||||
<Field label="DB Purge Age (s)">
|
<Field
|
||||||
|
label="DB Purge Age (s)"
|
||||||
|
hint="Ban records older than this are removed from the fail2ban database."
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={effectiveDbPurgeAge}
|
value={effectiveDbPurgeAge}
|
||||||
@@ -163,7 +285,10 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="DB Max Matches">
|
<Field
|
||||||
|
label="DB Max Matches"
|
||||||
|
hint="Maximum number of log-line matches stored per ban record."
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={effectiveDbMaxMatches}
|
value={effectiveDbMaxMatches}
|
||||||
@@ -182,6 +307,22 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{flushing ? "Flushing…" : "Flush Logs"}
|
{flushing ? "Flushing…" : "Flush Logs"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
icon={<ArrowSync24Regular />}
|
||||||
|
disabled={isReloading}
|
||||||
|
onClick={() => void handleReload()}
|
||||||
|
>
|
||||||
|
{isReloading ? "Reloading…" : "Reload fail2ban"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
appearance="secondary"
|
||||||
|
icon={<ArrowSync24Regular />}
|
||||||
|
disabled={isRestarting}
|
||||||
|
onClick={() => void handleRestart()}
|
||||||
|
>
|
||||||
|
{isRestarting ? "Restarting…" : "Restart fail2ban"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{msg && (
|
{msg && (
|
||||||
<MessageBar intent={msg.ok ? "success" : "error"}>
|
<MessageBar intent={msg.ok ? "success" : "error"}>
|
||||||
@@ -189,6 +330,92 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Map Color Thresholds section */}
|
||||||
|
{mapLoadError ? (
|
||||||
|
<div className={styles.sectionCard}>
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{mapLoadError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
</div>
|
||||||
|
) : mapThresholds ? (
|
||||||
|
<div className={styles.sectionCard}>
|
||||||
|
<Text as="h3" size={500} weight="semibold" block>
|
||||||
|
Map Color Thresholds
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
size={300}
|
||||||
|
className={styles.infoText}
|
||||||
|
block
|
||||||
|
style={{ marginBottom: tokens.spacingVerticalM }}
|
||||||
|
>
|
||||||
|
Configure the ban count thresholds that determine country fill colors on
|
||||||
|
the World Map. Countries with zero bans remain transparent. Colors
|
||||||
|
smoothly interpolate between thresholds.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||||
|
<AutoSaveIndicator
|
||||||
|
status={mapValidationError ? "idle" : mapSaveStatus}
|
||||||
|
errorText={mapSaveErrorText}
|
||||||
|
onRetry={retryMapSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapValidationError && (
|
||||||
|
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||||
|
<MessageBarBody>{mapValidationError}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.fieldRowThree}>
|
||||||
|
<Field label="Low Threshold (Green)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={mapThresholdLow}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setMapThresholdLow(d.value);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Medium Threshold (Yellow)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={mapThresholdMedium}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setMapThresholdMedium(d.value);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="High Threshold (Red)" required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={mapThresholdHigh}
|
||||||
|
onChange={(_, d) => {
|
||||||
|
setMapThresholdHigh(d.value);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
size={200}
|
||||||
|
className={styles.infoText}
|
||||||
|
style={{ marginTop: tokens.spacingVerticalS }}
|
||||||
|
>
|
||||||
|
• 1 to {mapThresholdLow}: Light green → Full green
|
||||||
|
<br />• {mapThresholdLow} to {mapThresholdMedium}: Green → Yellow
|
||||||
|
<br />• {mapThresholdMedium} to {mapThresholdHigh}: Yellow → Red
|
||||||
|
<br />• {mapThresholdHigh}+: Solid red
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
</div>
|
</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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for the LogTab component (Task 2).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
|
||||||
import { LogTab } from "../LogTab";
|
|
||||||
import type { Fail2BanLogResponse, ServiceStatusResponse } from "../../../types/config";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mocks
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
vi.mock("../../../api/config", () => ({
|
|
||||||
fetchFail2BanLog: vi.fn(),
|
|
||||||
fetchServiceStatus: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
|
|
||||||
|
|
||||||
const mockFetchLog = vi.mocked(fetchFail2BanLog);
|
|
||||||
const mockFetchStatus = vi.mocked(fetchServiceStatus);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fixtures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const onlineStatus: ServiceStatusResponse = {
|
|
||||||
online: true,
|
|
||||||
version: "1.0.2",
|
|
||||||
jail_count: 3,
|
|
||||||
total_bans: 12,
|
|
||||||
total_failures: 5,
|
|
||||||
log_level: "INFO",
|
|
||||||
log_target: "/var/log/fail2ban.log",
|
|
||||||
};
|
|
||||||
|
|
||||||
const offlineStatus: ServiceStatusResponse = {
|
|
||||||
online: false,
|
|
||||||
version: null,
|
|
||||||
jail_count: 0,
|
|
||||||
total_bans: 0,
|
|
||||||
total_failures: 0,
|
|
||||||
log_level: "UNKNOWN",
|
|
||||||
log_target: "UNKNOWN",
|
|
||||||
};
|
|
||||||
|
|
||||||
const logResponse: Fail2BanLogResponse = {
|
|
||||||
log_path: "/var/log/fail2ban.log",
|
|
||||||
lines: [
|
|
||||||
"2025-01-01 12:00:00 INFO sshd Found 1.2.3.4",
|
|
||||||
"2025-01-01 12:00:01 WARNING sshd Too many failures",
|
|
||||||
"2025-01-01 12:00:02 ERROR fail2ban something went wrong",
|
|
||||||
],
|
|
||||||
total_lines: 1000,
|
|
||||||
log_level: "INFO",
|
|
||||||
log_target: "/var/log/fail2ban.log",
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonFileLogResponse: Fail2BanLogResponse = {
|
|
||||||
...logResponse,
|
|
||||||
log_target: "STDOUT",
|
|
||||||
lines: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function renderTab() {
|
|
||||||
return render(
|
|
||||||
<FluentProvider theme={webLightTheme}>
|
|
||||||
<LogTab />
|
|
||||||
</FluentProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("LogTab", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a spinner while loading", () => {
|
|
||||||
// Never resolves during this test.
|
|
||||||
mockFetchStatus.mockReturnValue(new Promise(() => undefined));
|
|
||||||
mockFetchLog.mockReturnValue(new Promise(() => undefined));
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
expect(screen.getByText(/loading log viewer/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the health panel with Running badge when online", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
|
||||||
mockFetchLog.mockResolvedValue(logResponse);
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
|
|
||||||
|
|
||||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("1.0.2")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("3")).toBeInTheDocument(); // active jails
|
|
||||||
expect(screen.getByText("12")).toBeInTheDocument(); // total bans
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the Offline badge and warning when fail2ban is down", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(offlineStatus);
|
|
||||||
mockFetchLog.mockRejectedValue(new Error("not running"));
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => { expect(screen.queryByText(/loading log viewer/i)).toBeNull(); });
|
|
||||||
|
|
||||||
expect(screen.getByText("Offline")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/not running or unreachable/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders log lines in the log viewer", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
|
||||||
mockFetchLog.mockResolvedValue(logResponse);
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/2025-01-01 12:00:00 INFO/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/2025-01-01 12:00:01 WARNING/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/2025-01-01 12:00:02 ERROR/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a non-file target info banner when log_target is STDOUT", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
|
||||||
mockFetchLog.mockResolvedValue(nonFileLogResponse);
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/fail2ban is logging to/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText(/STDOUT/)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/Refresh/)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows empty state when no lines match the filter", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
|
||||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: [] });
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/no log entries found/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows truncation notice when total_lines > lines.length", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
|
||||||
mockFetchLog.mockResolvedValue({ ...logResponse, lines: logResponse.lines, total_lines: 1000 });
|
|
||||||
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/showing last/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls fetchFail2BanLog again on Refresh button click", async () => {
|
|
||||||
mockFetchStatus.mockResolvedValue(onlineStatus);
|
|
||||||
mockFetchLog.mockResolvedValue(logResponse);
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderTab();
|
|
||||||
|
|
||||||
await waitFor(() => { expect(screen.getByText(/Refresh/)).toBeInTheDocument(); });
|
|
||||||
|
|
||||||
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
|
|
||||||
await user.click(refreshBtn);
|
|
||||||
|
|
||||||
await waitFor(() => { expect(mockFetchLog).toHaveBeenCalledTimes(2); });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -30,16 +30,14 @@ export { ExportTab } from "./ExportTab";
|
|||||||
export { FilterForm } from "./FilterForm";
|
export { FilterForm } from "./FilterForm";
|
||||||
export type { FilterFormProps } from "./FilterForm";
|
export type { FilterFormProps } from "./FilterForm";
|
||||||
export { FiltersTab } from "./FiltersTab";
|
export { FiltersTab } from "./FiltersTab";
|
||||||
export { GlobalTab } from "./GlobalTab";
|
|
||||||
export { JailFilesTab } from "./JailFilesTab";
|
export { JailFilesTab } from "./JailFilesTab";
|
||||||
export { JailFileForm } from "./JailFileForm";
|
export { JailFileForm } from "./JailFileForm";
|
||||||
export { JailsTab } from "./JailsTab";
|
export { JailsTab } from "./JailsTab";
|
||||||
export { LogTab } from "./LogTab";
|
|
||||||
export { MapTab } from "./MapTab";
|
|
||||||
export { RawConfigSection } from "./RawConfigSection";
|
export { RawConfigSection } from "./RawConfigSection";
|
||||||
export type { RawConfigSectionProps } from "./RawConfigSection";
|
export type { RawConfigSectionProps } from "./RawConfigSection";
|
||||||
export { RegexList } from "./RegexList";
|
export { RegexList } from "./RegexList";
|
||||||
export type { RegexListProps } from "./RegexList";
|
export type { RegexListProps } from "./RegexList";
|
||||||
export { RegexTesterTab } from "./RegexTesterTab";
|
export { RegexTesterTab } from "./RegexTesterTab";
|
||||||
export { ServerTab } from "./ServerTab";
|
export { ServerTab } from "./ServerTab";
|
||||||
|
export { ServerHealthSection } from "./ServerHealthSection";
|
||||||
export { useConfigStyles } from "./configStyles";
|
export { useConfigStyles } from "./configStyles";
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -185,9 +194,9 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
|
{ label: "Dashboard", to: "/", icon: <GridRegular />, end: true },
|
||||||
{ label: "World Map", to: "/map", icon: <MapRegular /> },
|
{ label: "World Map", to: "/map", icon: <MapRegular /> },
|
||||||
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
|
{ label: "Jails", to: "/jails", icon: <ShieldRegular /> },
|
||||||
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
|
|
||||||
{ label: "History", to: "/history", icon: <HistoryRegular /> },
|
{ label: "History", to: "/history", icon: <HistoryRegular /> },
|
||||||
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
|
{ label: "Blocklists", to: "/blocklists", icon: <ListRegular /> },
|
||||||
|
{ label: "Configuration", to: "/config", icon: <SettingsRegular /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,9 +8,7 @@
|
|||||||
* Jails — per-jail config accordion with inline editing
|
* Jails — per-jail config accordion with inline editing
|
||||||
* Filters — structured filter.d form editor
|
* Filters — structured filter.d form editor
|
||||||
* Actions — structured action.d form editor
|
* Actions — structured action.d form editor
|
||||||
* Global — global fail2ban settings (log level, DB config)
|
* Server — server-level settings, map thresholds, service health + log viewer
|
||||||
* Server — server-level settings + flush logs
|
|
||||||
* Map — map color threshold configuration
|
|
||||||
* Regex Tester — live pattern tester
|
* Regex Tester — live pattern tester
|
||||||
* Export — raw file editors for jail, filter, and action files
|
* Export — raw file editors for jail, filter, and action files
|
||||||
*/
|
*/
|
||||||
@@ -20,10 +18,7 @@ import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-componen
|
|||||||
import {
|
import {
|
||||||
ActionsTab,
|
ActionsTab,
|
||||||
FiltersTab,
|
FiltersTab,
|
||||||
GlobalTab,
|
|
||||||
JailsTab,
|
JailsTab,
|
||||||
LogTab,
|
|
||||||
MapTab,
|
|
||||||
RegexTesterTab,
|
RegexTesterTab,
|
||||||
ServerTab,
|
ServerTab,
|
||||||
} from "../components/config";
|
} from "../components/config";
|
||||||
@@ -58,11 +53,8 @@ type TabValue =
|
|||||||
| "jails"
|
| "jails"
|
||||||
| "filters"
|
| "filters"
|
||||||
| "actions"
|
| "actions"
|
||||||
| "global"
|
|
||||||
| "server"
|
| "server"
|
||||||
| "map"
|
| "regex";
|
||||||
| "regex"
|
|
||||||
| "log";
|
|
||||||
|
|
||||||
export function ConfigPage(): React.JSX.Element {
|
export function ConfigPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@@ -89,22 +81,16 @@ export function ConfigPage(): React.JSX.Element {
|
|||||||
<Tab value="jails">Jails</Tab>
|
<Tab value="jails">Jails</Tab>
|
||||||
<Tab value="filters">Filters</Tab>
|
<Tab value="filters">Filters</Tab>
|
||||||
<Tab value="actions">Actions</Tab>
|
<Tab value="actions">Actions</Tab>
|
||||||
<Tab value="global">Global</Tab>
|
|
||||||
<Tab value="server">Server</Tab>
|
<Tab value="server">Server</Tab>
|
||||||
<Tab value="map">Map</Tab>
|
|
||||||
<Tab value="regex">Regex Tester</Tab>
|
<Tab value="regex">Regex Tester</Tab>
|
||||||
<Tab value="log">Log</Tab>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<div className={styles.tabContent} key={tab}>
|
<div className={styles.tabContent} key={tab}>
|
||||||
{tab === "jails" && <JailsTab />}
|
{tab === "jails" && <JailsTab />}
|
||||||
{tab === "filters" && <FiltersTab />}
|
{tab === "filters" && <FiltersTab />}
|
||||||
{tab === "actions" && <ActionsTab />}
|
{tab === "actions" && <ActionsTab />}
|
||||||
{tab === "global" && <GlobalTab />}
|
|
||||||
{tab === "server" && <ServerTab />}
|
{tab === "server" && <ServerTab />}
|
||||||
{tab === "map" && <MapTab />}
|
|
||||||
{tab === "regex" && <RegexTesterTab />}
|
{tab === "regex" && <RegexTesterTab />}
|
||||||
{tab === "log" && <LogTab />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ vi.mock("../../components/config", () => ({
|
|||||||
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
||||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||||
GlobalTab: () => <div data-testid="global-tab">GlobalTab</div>,
|
|
||||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||||
MapTab: () => <div data-testid="map-tab">MapTab</div>,
|
|
||||||
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
||||||
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
||||||
}));
|
}));
|
||||||
@@ -45,12 +43,6 @@ describe("ConfigPage", () => {
|
|||||||
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to Global tab when Global tab is clicked", () => {
|
|
||||||
renderPage();
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: /global/i }));
|
|
||||||
expect(screen.getByTestId("global-tab")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to Server tab when Server tab is clicked", () => {
|
it("switches to Server tab when Server tab is clicked", () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
||||||
|
|||||||
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 {
|
||||||
@@ -553,6 +558,13 @@ export interface JailActivationResponse {
|
|||||||
fail2ban_running: boolean;
|
fail2ban_running: boolean;
|
||||||
/** Non-fatal pre-activation validation warnings (e.g. missing log path). */
|
/** Non-fatal pre-activation validation warnings (e.g. missing log path). */
|
||||||
validation_warnings: string[];
|
validation_warnings: string[];
|
||||||
|
/**
|
||||||
|
* Set when activation failed after the config file was already written.
|
||||||
|
* `true` = the system rolled back and recovered automatically.
|
||||||
|
* `false` = rollback also failed — manual intervention required.
|
||||||
|
* `undefined` = activation succeeded or failed before the file was written.
|
||||||
|
*/
|
||||||
|
recovered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -574,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;
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ import { tokens } from "@fluentui/react-components";
|
|||||||
export function resolveFluentToken(tokenValue: string): string {
|
export function resolveFluentToken(tokenValue: string): string {
|
||||||
const match = /var\((--[^,)]+)/.exec(tokenValue);
|
const match = /var\((--[^,)]+)/.exec(tokenValue);
|
||||||
if (match == null || match[1] == null) return tokenValue;
|
if (match == null || match[1] == null) return tokenValue;
|
||||||
const resolved = getComputedStyle(document.documentElement)
|
|
||||||
|
// FluentProvider injects CSS custom properties on its own wrapper <div>,
|
||||||
|
// not on :root. Query that element so we resolve actual colour values.
|
||||||
|
const el =
|
||||||
|
document.querySelector(".fui-FluentProvider") ?? document.documentElement;
|
||||||
|
const resolved = getComputedStyle(el)
|
||||||
.getPropertyValue(match[1])
|
.getPropertyValue(match[1])
|
||||||
.trim();
|
.trim();
|
||||||
return resolved !== "" ? resolved : tokenValue;
|
return resolved !== "" ? resolved : tokenValue;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client-side cryptography utilities.
|
|
||||||
*
|
|
||||||
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the SHA-256 hex digest of `input`.
|
|
||||||
*
|
|
||||||
* Hashing passwords before transmission means the plaintext never leaves the
|
|
||||||
* browser, even when HTTPS is not enforced in a development environment.
|
|
||||||
* The backend then applies bcrypt on top of the received hash.
|
|
||||||
*
|
|
||||||
* @param input - The string to hash (e.g. the master password).
|
|
||||||
* @returns Lowercase hex-encoded SHA-256 digest.
|
|
||||||
*/
|
|
||||||
export async function sha256Hex(input: string): Promise<string> {
|
|
||||||
const data = new TextEncoder().encode(input);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
return Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
3
frontend/src/vite-env.d.ts
vendored
3
frontend/src/vite-env.d.ts
vendored
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
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