Compare commits
31 Commits
b81e0cdbb4
...
v0.9.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 376c13370d | |||
| fb6d0e588f | |||
| e44caccb3c | |||
| 15e4a5434e | |||
| 1cc9968d31 | |||
| 80a6bac33e | |||
| 133ab2e82c | |||
| 60f2f35b25 | |||
| 59da34dc3b | |||
| 90f54cf39c | |||
| 93d26e3c60 | |||
| 954dcf7ea6 | |||
| bf8144916a | |||
| 481daa4e1a | |||
| 889976c7ee | |||
| d3d2cb0915 | |||
| bf82e38b6e | |||
| e98fd1de93 | |||
| 8f515893ea | |||
| 81f99d0b50 | |||
| 030bca09b7 | |||
| 5b7d1a4360 | |||
| e7834a888e | |||
| abb224e01b | |||
| 57cf93b1e5 | |||
| c41165c294 | |||
| cdf73e2d65 | |||
| 21753c4f06 | |||
| eb859af371 | |||
| 5a5c619a34 | |||
| 00119ed68d |
@@ -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.10
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# check_ban_status.sh
|
# check_ban_status.sh
|
||||||
#
|
#
|
||||||
# Queries the bangui-sim jail inside the running fail2ban
|
# Queries the manual-Jail jail inside the running fail2ban
|
||||||
# container and optionally unbans a specific IP.
|
# container and optionally unbans a specific IP.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly CONTAINER="bangui-fail2ban-dev"
|
readonly CONTAINER="bangui-fail2ban-dev"
|
||||||
readonly JAIL="bangui-sim"
|
readonly JAIL="manual-Jail"
|
||||||
|
|
||||||
# ── Helper: run a fail2ban-client command inside the container ─
|
# ── Helper: run a fail2ban-client command inside the container ─
|
||||||
f2b() {
|
f2b() {
|
||||||
|
|||||||
73
Docker/docker-compose.yml
Normal file
73
Docker/docker-compose.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
fail2ban:
|
||||||
|
image: lscr.io/linuxserver/fail2ban:latest
|
||||||
|
container_name: fail2ban
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
- TZ=Etc/UTC
|
||||||
|
- VERBOSITY=-vv #optional
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /server/server_fail2ban/config:/config
|
||||||
|
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban
|
||||||
|
- /var/log:/var/log
|
||||||
|
- /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional
|
||||||
|
- /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional
|
||||||
|
- /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional
|
||||||
|
|
||||||
|
|
||||||
|
#- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional
|
||||||
|
#- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional
|
||||||
|
#- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest
|
||||||
|
container_name: bangui-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
fail2ban:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
- BANGUI_DATABASE_PATH=/data/bangui.db
|
||||||
|
- BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock
|
||||||
|
- BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban
|
||||||
|
- BANGUI_LOG_LEVEL=info
|
||||||
|
- BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET}
|
||||||
|
- BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC}
|
||||||
|
volumes:
|
||||||
|
- /server/server_fail2ban/bangui-data:/data
|
||||||
|
- /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro
|
||||||
|
- /server/server_fail2ban/config:/config:rw
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
|
|
||||||
|
# ── Frontend (nginx serving built SPA + API proxy) ──────────
|
||||||
|
frontend:
|
||||||
|
image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest
|
||||||
|
container_name: bangui-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PUID=1011
|
||||||
|
- PGID=1001
|
||||||
|
ports:
|
||||||
|
- "${BANGUI_PORT:-8080}:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- bangui-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bangui-net:
|
||||||
|
name: bangui-net
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This directory contains the fail2ban configuration and supporting scripts for a
|
This directory contains the fail2ban configuration and supporting scripts for a
|
||||||
self-contained development test environment. A simulation script writes fake
|
self-contained development test environment. A simulation script writes fake
|
||||||
authentication-failure log lines, fail2ban detects them via the `bangui-sim`
|
authentication-failure log lines, fail2ban detects them via the `manual-Jail`
|
||||||
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
|
jail, and bans the offending IP — giving a fully reproducible ban/unban cycle
|
||||||
without a real service.
|
without a real service.
|
||||||
|
|
||||||
@@ -71,14 +71,14 @@ Chains steps 1–3 automatically with appropriate sleep intervals.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines |
|
| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines |
|
||||||
| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
|
| `fail2ban/jail.d/manual-Jail.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` |
|
||||||
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
|
| `Docker/logs/auth.log` | Log file written by the simulation script (host path) |
|
||||||
|
|
||||||
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
Inside the container the log file is mounted at `/remotelogs/bangui/auth.log`
|
||||||
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`).
|
||||||
|
|
||||||
To change sensitivity, edit `fail2ban/jail.d/bangui-sim.conf`:
|
To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
maxretry = 3 # failures before a ban
|
maxretry = 3 # failures before a ban
|
||||||
@@ -108,14 +108,14 @@ Test the regex manually:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec bangui-fail2ban-dev \
|
docker exec bangui-fail2ban-dev \
|
||||||
fail2ban-regex /remotelogs/bangui/auth.log bangui-sim
|
fail2ban-regex /remotelogs/bangui/auth.log manual-Jail
|
||||||
```
|
```
|
||||||
|
|
||||||
The output should show matched lines. If nothing matches, check that the log
|
The output should show matched lines. If nothing matches, check that the log
|
||||||
lines match the corresponding `failregex` pattern:
|
lines match the corresponding `failregex` pattern:
|
||||||
|
|
||||||
```
|
```
|
||||||
# bangui-sim (auth log):
|
# manual-Jail (auth log):
|
||||||
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from <IP>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ sudo modprobe ip_tables
|
|||||||
### IP not banned despite enough failures
|
### IP not banned despite enough failures
|
||||||
|
|
||||||
Check whether the source IP falls inside the `ignoreip` range defined in
|
Check whether the source IP falls inside the `ignoreip` range defined in
|
||||||
`fail2ban/jail.d/bangui-sim.conf`:
|
`fail2ban/jail.d/manual-Jail.conf`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
# Matches lines written by Docker/simulate_failed_logins.sh
|
# Matches lines written by Docker/simulate_failed_logins.sh
|
||||||
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
||||||
|
# Jail: manual-Jail
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Definition]
|
[Definition]
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ logpath = /dev/null
|
|||||||
backend = auto
|
backend = auto
|
||||||
maxretry = 1
|
maxretry = 1
|
||||||
findtime = 1d
|
findtime = 1d
|
||||||
# Block imported IPs for one week.
|
# Block imported IPs for 24 hours.
|
||||||
bantime = 1w
|
bantime = 86400
|
||||||
|
|
||||||
# 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,10 +5,10 @@
|
|||||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
# for lines produced by Docker/simulate_failed_logins.sh.
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[bangui-sim]
|
[manual-Jail]
|
||||||
|
|
||||||
enabled = true
|
enabled = true
|
||||||
filter = bangui-sim
|
filter = manual-Jail
|
||||||
logpath = /remotelogs/bangui/auth.log
|
logpath = /remotelogs/bangui/auth.log
|
||||||
backend = polling
|
backend = polling
|
||||||
maxretry = 3
|
maxretry = 3
|
||||||
|
|||||||
@@ -56,11 +56,8 @@ echo " Registry : ${REGISTRY}"
|
|||||||
echo " Tag : ${TAG}"
|
echo " Tag : ${TAG}"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
|
|
||||||
if [[ "${ENGINE}" == "podman" ]]; then
|
log "Logging in to ${REGISTRY}"
|
||||||
if ! podman login --get-login "${REGISTRY}" &>/dev/null; then
|
"${ENGINE}" login "${REGISTRY}"
|
||||||
err "Not logged in. Run:\n podman login ${REGISTRY}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Build
|
# Build
|
||||||
|
|||||||
91
Docker/release.sh
Normal file
91
Docker/release.sh
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
#!/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 (local only; push after container build)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
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}"
|
||||||
|
echo "Local git commit + tag ${NEW_TAG} created."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push containers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}"
|
||||||
|
bash "${SCRIPT_DIR}/push.sh"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Push git commits & tag
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin "${NEW_TAG}"
|
||||||
|
echo "Git commit and tag ${NEW_TAG} pushed."
|
||||||
@@ -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`.
|
||||||
315
Docs/Tasks.md
315
Docs/Tasks.md
@@ -4,142 +4,219 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug Fix: "Raw Action Configuration" always empty — DONE
|
## Open Issues
|
||||||
|
|
||||||
**Summary:** Renamed `GET /actions/{name}` and `PUT /actions/{name}` in `file_config.py` to `GET /actions/{name}/raw` and `PUT /actions/{name}/raw` to eliminate the route-shadowing conflict with `config.py`. Added `configActionRaw` endpoint helper in `endpoints.ts` and updated `fetchActionFile` / `updateActionFile` in `config.ts` to call it. Added `TestGetActionFileRaw` and `TestUpdateActionFileRaw` test classes.
|
> **Architectural Review — 2026-03-16**
|
||||||
|
> The findings below were identified by auditing every backend and frontend module against the rules in [Refactoring.md](Refactoring.md) and [Architekture.md](Architekture.md).
|
||||||
**Problem**
|
> Tasks are grouped by layer and ordered so that lower-level fixes (repositories, services) are done before the layers that depend on them.
|
||||||
When a user opens the *Actions* tab in the Config screen, selects any action, and expands the "Raw Action Configuration" accordion, the text area is always blank. The `fetchContent` callback makes a `GET /api/config/actions/{name}` request expecting a `ConfFileContent` response (`{ content: string, name: string, filename: string }`), but the backend returns an `ActionConfig` (the fully-parsed structured model) instead. The `content` field is therefore `undefined` in the browser, which the `RawConfigSection` component renders as an empty string.
|
|
||||||
|
|
||||||
**Root cause**
|
|
||||||
Both `backend/app/routers/config.py` and `backend/app/routers/file_config.py` are mounted with the prefix `/api/config` (see lines 107 and 63 respectively). Both define a `GET /actions/{name}` route:
|
|
||||||
|
|
||||||
- `config.py` → returns `ActionConfig` (parsed detail)
|
|
||||||
- `file_config.py` → returns `ConfFileContent` (raw file text)
|
|
||||||
|
|
||||||
In `backend/app/main.py`, `config.router` is registered on line 402 and `file_config.router` on line 403. FastAPI matches the first registered route, so the raw-content endpoint is permanently shadowed.
|
|
||||||
|
|
||||||
The filters feature already solved the same conflict by using distinct paths (`/filters/{name}/raw` for raw and `/filters/{name}` for parsed). Actions must follow the same pattern.
|
|
||||||
|
|
||||||
**Fix — backend (`backend/app/routers/file_config.py`)**
|
|
||||||
Rename the two action raw-file routes:
|
|
||||||
|
|
||||||
| Old path | New path |
|
|
||||||
|---|---|
|
|
||||||
| `GET /actions/{name}` | `GET /actions/{name}/raw` |
|
|
||||||
| `PUT /actions/{name}` | `PUT /actions/{name}/raw` |
|
|
||||||
|
|
||||||
Update the module-level docstring comment block at the top of `file_config.py` to reflect the new paths.
|
|
||||||
|
|
||||||
**Fix — frontend (`frontend/src/api/endpoints.ts`)**
|
|
||||||
Add a new helper alongside the existing `configAction` entry:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`,
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix — frontend (`frontend/src/api/config.ts`)**
|
|
||||||
Change `fetchActionFile` and `updateActionFile` to call `ENDPOINTS.configActionRaw(name)` instead of `ENDPOINTS.configAction(name)`.
|
|
||||||
|
|
||||||
**No changes needed elsewhere.** `ActionsTab.tsx` already passes `fetchActionFile` / `updateActionFile` into `RawConfigSection` via `fetchRaw` / `saveRaw`; the resolved URL is the only thing that needs to change.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rename dev jail `bangui-sim` → `manual-Jail` — DONE
|
## Feature: Worldmap Country Tooltip
|
||||||
|
|
||||||
**Summary:** Renamed `jail.d/bangui-sim.conf` → `manual-Jail.conf` and `filter.d/bangui-sim.conf` → `manual-Jail.conf` (via `git mv`), updated all internal references. Updated `check_ban_status.sh`, `simulate_failed_logins.sh`, and `fail2ban-dev-config/README.md` to replace all `bangui-sim` references with `manual-Jail`.
|
> **2026-03-17**
|
||||||
|
> The world map on the Map page colours each country by ban count but provides no immediate information on hover — the user must click a country to see its name in the filter bar below, and must read the small SVG count label to learn the number of bans.
|
||||||
**Scope**
|
>
|
||||||
This is purely a Docker development-environment change. The frontend never hardcodes jail names; it reads them dynamically from the API. Only the files listed below need editing.
|
> Goal: show a lightweight floating tooltip whenever the pointer enters a country, displaying the country's display name and its current ban count, so the information is accessible without a click.
|
||||||
|
|
||||||
**Files to update**
|
|
||||||
|
|
||||||
1. **`Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf`**
|
|
||||||
- Rename the file to `manual-Jail.conf`.
|
|
||||||
- Change the section header from `[bangui-sim]` to `[manual-Jail]`.
|
|
||||||
- Change `filter = bangui-sim` to `filter = manual-Jail`.
|
|
||||||
- Update the file-header comment ("BanGUI — Simulated authentication failure jail" line and any other references to `bangui-sim`).
|
|
||||||
|
|
||||||
2. **`Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf`**
|
|
||||||
- Rename the file to `manual-Jail.conf`.
|
|
||||||
- Update any internal comments that mention `bangui-sim`.
|
|
||||||
|
|
||||||
3. **`Docker/check_ban_status.sh`**
|
|
||||||
- Change `readonly JAIL="bangui-sim"` to `readonly JAIL="manual-Jail"`.
|
|
||||||
- Update the file-header comment block that references `bangui-sim`.
|
|
||||||
|
|
||||||
4. **`Docker/simulate_failed_logins.sh`**
|
|
||||||
- Update all comments that mention `bangui-sim` or `bangui-auth` to refer to `manual-Jail` instead.
|
|
||||||
- Do **not** change the log-line format string (`bangui-auth: authentication failure from <IP>`) unless the filter's `failregex` in the renamed `manual-Jail.conf` is also updated to match the new prefix; keep them in sync.
|
|
||||||
|
|
||||||
5. **`Docker/fail2ban-dev-config/README.md`**
|
|
||||||
- Replace every occurrence of `bangui-sim` with `manual-Jail`.
|
|
||||||
|
|
||||||
After renaming, run `docker compose -f Docker/compose.debug.yml restart fail2ban` and verify with `bash Docker/check_ban_status.sh` that the jail is active under its new name.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug Fix: Config screen content pane does not update when switching jails — DONE
|
### Task WM-1 — Show country name and ban count tooltip on map hover
|
||||||
|
|
||||||
**Summary:** Added `key={selectedActiveJail.name}` to `JailConfigDetail` and `key={selectedInactiveJail.name}` to `InactiveJailDetail` in `JailsTab.tsx`, forcing React to unmount and remount the detail component on jail selection changes.
|
**Scope:** `frontend/src/components/WorldMap.tsx`, `frontend/src/pages/MapPage.tsx`
|
||||||
|
|
||||||
**Problem**
|
`countryNames` (ISO alpha-2 → display name) is already available in `MapPage` from `useMapData` but is not forwarded to `WorldMap`. The map component itself tracks no hover state. This task adds pointer-event handlers to each country `<g>` element, tracks the hovered country in local state together with the last known mouse coordinates, and renders a positionned HTML tooltip `<div>` on top of the SVG.
|
||||||
In the *Jails* tab of the Config screen, clicking a jail name in the left-hand list correctly highlights the new selection, but the right-hand content pane continues to show the previously selected jail (e.g. selecting `blocklist-import` after `manual-Jail` still displays `manual-Jail`'s configuration).
|
|
||||||
|
|
||||||
**Root cause**
|
**Implementation steps:**
|
||||||
In `frontend/src/components/config/JailsTab.tsx`, the child components rendered by `ConfigListDetail` are not given a `key` prop:
|
|
||||||
|
|
||||||
```tsx
|
1. **Extend `WorldMapProps` and `GeoLayerProps`** in `WorldMap.tsx`:
|
||||||
{selectedActiveJail !== undefined ? (
|
- Add `countryNames?: Record<string, string>` to `WorldMapProps` (optional — falls back to the ISO alpha-2 code when absent).
|
||||||
<JailConfigDetail
|
- Thread it through `GeoLayer` the same way the threshold props are already threaded.
|
||||||
jail={selectedActiveJail} // no key prop
|
|
||||||
...
|
|
||||||
/>
|
|
||||||
) : selectedInactiveJail !== undefined ? (
|
|
||||||
<InactiveJailDetail
|
|
||||||
jail={selectedInactiveJail} // no key prop
|
|
||||||
...
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
```
|
|
||||||
|
|
||||||
When the user switches between two jails of the same type (both active or both inactive), React reuses the existing component instance and only updates its props. Any internal state derived from the previous jail — including the `loadedRef` guard inside every nested `RawConfigSection` — is never reset. As a result, forms still show the old jail's values and the raw-config section refuses to re-fetch because `loadedRef.current` is already `true`.
|
2. **Add hover state to `GeoLayer`** — declare:
|
||||||
|
```ts
|
||||||
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
cc: string;
|
||||||
|
count: number;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
```
|
||||||
|
On each country `<g>` element add:
|
||||||
|
- `onMouseEnter` — set `tooltip` with the country code, count, display name (from `countryNames`, falling back to the alpha-2 code), and mouse page coordinates (`e.clientX`, `e.clientY`).
|
||||||
|
- `onMouseMove` — update only the `x`/`y` in the existing tooltip (keep name/count stable).
|
||||||
|
- `onMouseLeave` — set `tooltip` to `null`.
|
||||||
|
|
||||||
Compare with `ActionsTab.tsx`, where `ActionDetail` correctly uses `key={selectedAction.name}`:
|
Skip setting the tooltip for countries where `cc === null` (no ISO mapping available) but keep `onMouseLeave` so re-entering after leaving from an unmapped border still clears the state.
|
||||||
|
|
||||||
```tsx
|
3. **Render the tooltip inside `GeoLayer`** — because `GeoLayer` is rendered inside `ComposableMap` which is inside `mapWrapper`, the tooltip div cannot be positioned relative to the map wrapper from here (the SVG clip/transform would offset it). Instead, use a React **portal** (`ReactDOM.createPortal`) to mount the tooltip directly on `document.body` so it sits in the root stacking context and can be positioned with `position: fixed` using the raw `clientX`/`clientY` coordinates.
|
||||||
<ActionDetail
|
|
||||||
key={selectedAction.name} // ← forces remount on action change
|
|
||||||
action={selectedAction}
|
|
||||||
...
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix — `frontend/src/components/config/JailsTab.tsx`**
|
Tooltip structure (styled with a new `makeStyles` class `tooltip` in `WorldMap.tsx`):
|
||||||
Add `key` props to both detail components so React unmounts and remounts them whenever the selected jail changes:
|
```tsx
|
||||||
|
{tooltip &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className={styles.tooltip}
|
||||||
|
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||||
|
role="tooltip"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||||
|
<span className={styles.tooltipCount}>
|
||||||
|
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
```tsx
|
4. **Tooltip styles** — add three new classes to the `makeStyles` call in `WorldMap.tsx`:
|
||||||
{selectedActiveJail !== undefined ? (
|
```ts
|
||||||
<JailConfigDetail
|
tooltip: {
|
||||||
key={selectedActiveJail.name}
|
position: "fixed",
|
||||||
jail={selectedActiveJail}
|
zIndex: 9999,
|
||||||
onSave={updateJail}
|
pointerEvents: "none",
|
||||||
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
/>
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
) : selectedInactiveJail !== undefined ? (
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
<InactiveJailDetail
|
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||||
key={selectedInactiveJail.name}
|
display: "flex",
|
||||||
jail={selectedInactiveJail}
|
flexDirection: "column",
|
||||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
gap: tokens.spacingVerticalXXS,
|
||||||
onDeactivate={
|
boxShadow: tokens.shadow4,
|
||||||
selectedInactiveJail.has_local_override
|
},
|
||||||
? (): void => { handleDeactivateInactive(selectedInactiveJail.name); }
|
tooltipCountry: {
|
||||||
: undefined
|
fontSize: tokens.fontSizeBase200,
|
||||||
}
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
/>
|
color: tokens.colorNeutralForeground1,
|
||||||
) : null}
|
},
|
||||||
```
|
tooltipCount: {
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
No other files need changing. The `key` change is the minimal, isolated fix.
|
5. **Pass `countryNames` from `MapPage`** — in `MapPage.tsx`, add the `countryNames` prop to the existing `<WorldMap …>` JSX:
|
||||||
|
```tsx
|
||||||
|
<WorldMap
|
||||||
|
countries={countries}
|
||||||
|
countryNames={countryNames}
|
||||||
|
selectedCountry={selectedCountry}
|
||||||
|
onSelectCountry={setSelectedCountry}
|
||||||
|
…
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Countries with zero bans** — the tooltip should still appear when the user hovers over a country with `0` bans (showing the name and "0 bans"), so users know the country is tracked but has no bans. Do not suppress the tooltip for zero-count countries.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Moving the pointer over any mapped country on the Map page shows a floating tooltip within 0 ms (synchronous state update) containing the country's full display name (e.g. `Germany`) on the first line and the ban count (e.g. `42 bans` or `0 bans`) on the second line.
|
||||||
|
- Moving the pointer off a country hides the tooltip immediately.
|
||||||
|
- The tooltip follows the pointer as it moves within a country's borders.
|
||||||
|
- Clicking a country still selects/deselects it exactly as before; the tooltip does not interfere with the click handler.
|
||||||
|
- The tooltip is not interactive (`pointerEvents: none`) and does not steal focus from the map.
|
||||||
|
- `tsc --noEmit` produces no new errors.
|
||||||
|
|
||||||
|
**Status:** ✅ Completed (2026-03-19)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature: Global Unique BanGUI Version
|
||||||
|
|
||||||
|
> **2026-03-17**
|
||||||
|
> The BanGUI application version is currently scattered across three independent files that are not kept in sync:
|
||||||
|
> - `Docker/VERSION` — `v0.9.8` (release artifact, written by the release script)
|
||||||
|
> - `frontend/package.json` — `0.9.8`
|
||||||
|
> - `backend/pyproject.toml` — `0.9.4` ← **out of sync**
|
||||||
|
>
|
||||||
|
> Additionally the BanGUI version is only shown in the sidebar footer (`MainLayout.tsx`). Neither the Dashboard nor the Configuration → Server view exposes the BanGUI application version, only the fail2ban daemon version.
|
||||||
|
>
|
||||||
|
> Goal: one authoritative version string, propagated automatically to all layers, and displayed consistently on both the Dashboard and the Configuration → Server page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task GV-1 — Establish a single source of truth for the BanGUI version
|
||||||
|
|
||||||
|
**Scope:** `Docker/VERSION`, `backend/pyproject.toml`, `frontend/package.json`, `backend/app/__init__.py`
|
||||||
|
|
||||||
|
`Docker/VERSION` is already the file written by the release script (`Docker/release.sh`) and is therefore the natural single source of truth.
|
||||||
|
|
||||||
|
1. Sync the two package manifests to the current release version:
|
||||||
|
- Set `version` in `backend/pyproject.toml` to `0.9.8` (strip the leading `v` that `Docker/VERSION` contains).
|
||||||
|
- `frontend/package.json` is already `0.9.8` — no change needed.
|
||||||
|
2. Make the backend read its version **directly from `Docker/VERSION`** at import time instead of from `pyproject.toml`, so a future release-script bump of `Docker/VERSION` is sufficient. Update `_read_pyproject_version()` in `backend/app/__init__.py`:
|
||||||
|
- Add a new helper `_read_docker_version() -> str` that resolves `Docker/VERSION` relative to the repository root (two `parents` above `backend/app/`), strips the leading `v` and whitespace, and returns the bare semver string.
|
||||||
|
- Change `_read_version()` to try `_read_docker_version()` first, then fall back to `_read_pyproject_version()`, then `importlib.metadata`.
|
||||||
|
3. Make the frontend read its version from `Docker/VERSION` at build time. In `frontend/vite.config.ts`, replace the `pkg.version` import with a `fs.readFileSync('../Docker/VERSION', 'utf-8').trim().replace(/^v/, '')` call so both the dev server and production build always reflect the file.
|
||||||
|
- Update `declare const __APP_VERSION__: string;` in `frontend/src/vite-env.d.ts` if the type declaration needs adjustment (it should not).
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `backend/app/__version__` equals the content of `Docker/VERSION` (without `v` prefix) at runtime.
|
||||||
|
- `frontend` build constant `__APP_VERSION__` equals the same value.
|
||||||
|
- Bumping only `Docker/VERSION` (e.g. `v0.9.9`) causes both layers to pick up the new version without touching any other file.
|
||||||
|
- All existing tests pass (`pytest backend/`).
|
||||||
|
|
||||||
|
**Status:** ✅ Completed (2026-03-19)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task GV-2 — Expose the BanGUI version through the API
|
||||||
|
|
||||||
|
**Scope:** `backend/app/models/server.py`, `backend/app/models/config.py`, `backend/app/routers/dashboard.py`, `backend/app/routers/config.py`
|
||||||
|
|
||||||
|
Add a `bangui_version` field to every API response that already carries the fail2ban daemon `version`, so the frontend can display the BanGUI application version next to it.
|
||||||
|
|
||||||
|
1. **`backend/app/models/server.py`** — Add to `ServerStatusResponse`:
|
||||||
|
```python
|
||||||
|
bangui_version: str = Field(..., description="BanGUI application version.")
|
||||||
|
```
|
||||||
|
2. **`backend/app/models/config.py`** — Add to `ServiceStatusResponse`:
|
||||||
|
```python
|
||||||
|
bangui_version: str = Field(..., description="BanGUI application version.")
|
||||||
|
```
|
||||||
|
3. **`backend/app/routers/dashboard.py`** — In `get_server_status`, import `__version__` from `app` and populate the new field:
|
||||||
|
```python
|
||||||
|
return ServerStatusResponse(status=cached, bangui_version=__version__)
|
||||||
|
```
|
||||||
|
4. **`backend/app/routers/config.py`** — Do the same for the `GET /api/config/service-status` endpoint.
|
||||||
|
|
||||||
|
**Do not** change the existing `version` field (fail2ban daemon version) — keep it exactly as-is so nothing downstream breaks.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- `GET /api/dashboard/status` response JSON contains `"bangui_version": "0.9.8"`.
|
||||||
|
- `GET /api/config/service-status` response JSON contains `"bangui_version": "0.9.8"`.
|
||||||
|
- All existing backend tests pass.
|
||||||
|
- Add one test per endpoint asserting that `bangui_version` matches `app.__version__`.
|
||||||
|
|
||||||
|
**Status:** ✅ Completed (2026-03-19)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task GV-3 — Display the BanGUI version on Dashboard and Configuration → Server
|
||||||
|
|
||||||
|
**Scope:** `frontend/src/components/ServerStatusBar.tsx`, `frontend/src/components/config/ServerHealthSection.tsx`, `frontend/src/types/server.ts`, `frontend/src/types/config.ts`
|
||||||
|
|
||||||
|
After GV-2 the API delivers `bangui_version`; this task makes the frontend show it.
|
||||||
|
|
||||||
|
1. **Type definitions**
|
||||||
|
- `frontend/src/types/server.ts` — Add `bangui_version: string` to the `ServerStatusResponse` interface.
|
||||||
|
- `frontend/src/types/config.ts` — Add `bangui_version: string` to the `ServiceStatusResponse` interface.
|
||||||
|
|
||||||
|
2. **Dashboard — `ServerStatusBar.tsx`**
|
||||||
|
The status bar already renders `v{status.version}` (fail2ban version with a tooltip). Add a second badge directly adjacent to it that reads `BanGUI v{status.bangui_version}` with the tooltip `"BanGUI version"`. Match the existing badge style.
|
||||||
|
|
||||||
|
3. **Configuration → Server — `ServerHealthSection.tsx`**
|
||||||
|
The health section already renders a `Version` row with the fail2ban version. Add a new row below it labelled `BanGUI` (or `BanGUI Version`) that renders `{status.bangui_version}`. Apply the same `statLabel` / `statValue` CSS classes used by the adjacent rows.
|
||||||
|
|
||||||
|
4. **Remove the duplicate from the sidebar** — Once the version is visible on the relevant pages, the sidebar footer in `frontend/src/layouts/MainLayout.tsx` can drop `v{__APP_VERSION__}` to avoid showing the version in three places. Replace it with the plain product name `BanGUI` — **only do this if the design document (`Docs/Web-Design.md`) does not mandate showing the version there**; otherwise leave it and note the decision in a comment.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- Dashboard status bar shows `BanGUI v0.9.8` with an appropriate tooltip.
|
||||||
|
- Configuration → Server health section shows a `BanGUI` version row reading `0.9.8`.
|
||||||
|
- No TypeScript compile errors (`tsc --noEmit`).
|
||||||
|
- Both values originate from the same API field (`bangui_version`) and therefore always match the backend version.
|
||||||
|
|
||||||
|
**Status:** ✅ Completed (2026-03-19)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1 +1,68 @@
|
|||||||
"""BanGUI backend application package."""
|
"""BanGUI backend application package.
|
||||||
|
|
||||||
|
This package exposes the application version based on the project metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
PACKAGE_NAME: Final[str] = "bangui-backend"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pyproject_version() -> str:
|
||||||
|
"""Read the project version from ``pyproject.toml``.
|
||||||
|
|
||||||
|
This is used as a fallback when the package metadata is not available (e.g.
|
||||||
|
when running directly from a source checkout without installing the package).
|
||||||
|
"""
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parents[1]
|
||||||
|
pyproject_path = project_root / "pyproject.toml"
|
||||||
|
if not pyproject_path.exists():
|
||||||
|
raise FileNotFoundError(f"pyproject.toml not found at {pyproject_path}")
|
||||||
|
|
||||||
|
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
||||||
|
return str(data["project"]["version"])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_docker_version() -> str:
|
||||||
|
"""Read the project version from ``Docker/VERSION``.
|
||||||
|
|
||||||
|
This file is the single source of truth for release scripts and must not be
|
||||||
|
out of sync with the frontend and backend versions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
version_path = repo_root / "Docker" / "VERSION"
|
||||||
|
if not version_path.exists():
|
||||||
|
raise FileNotFoundError(f"Docker/VERSION not found at {version_path}")
|
||||||
|
|
||||||
|
version = version_path.read_text(encoding="utf-8").strip()
|
||||||
|
return version.lstrip("v")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
"""Return the current package version.
|
||||||
|
|
||||||
|
Prefer the release artifact in ``Docker/VERSION`` when available so the
|
||||||
|
backend version always matches what the release tooling publishes.
|
||||||
|
|
||||||
|
If that file is missing (e.g. in a production wheel or a local checkout),
|
||||||
|
fall back to ``pyproject.toml`` and finally installed package metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _read_docker_version()
|
||||||
|
except FileNotFoundError:
|
||||||
|
try:
|
||||||
|
return _read_pyproject_version()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return importlib.metadata.version(PACKAGE_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
__version__ = _read_version()
|
||||||
|
|||||||
@@ -85,4 +85,4 @@ def get_settings() -> Settings:
|
|||||||
A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError`
|
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
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.routers import (
|
from app.routers import (
|
||||||
@@ -49,6 +50,7 @@ from app.routers import (
|
|||||||
)
|
)
|
||||||
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
|
from app.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 +139,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 +328,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)
|
||||||
|
|
||||||
@@ -360,7 +366,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
app: FastAPI = FastAPI(
|
app: FastAPI = FastAPI(
|
||||||
title="BanGUI",
|
title="BanGUI",
|
||||||
description="Web interface for monitoring, managing, and configuring fail2ban.",
|
description="Web interface for monitoring, managing, and configuring fail2ban.",
|
||||||
version="0.1.0",
|
version=__version__,
|
||||||
lifespan=_lifespan,
|
lifespan=_lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1002,6 +1002,7 @@ class ServiceStatusResponse(BaseModel):
|
|||||||
|
|
||||||
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
online: bool = Field(..., description="Whether fail2ban is reachable via its socket.")
|
||||||
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
|
version: str | None = Field(default=None, description="fail2ban version string, or None when offline.")
|
||||||
|
bangui_version: str = Field(..., description="BanGUI application version.")
|
||||||
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
jail_count: int = Field(default=0, ge=0, description="Number of currently active jails.")
|
||||||
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
|
total_bans: int = Field(default=0, ge=0, description="Aggregated current ban count across all jails.")
|
||||||
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
total_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ServerStatusResponse(BaseModel):
|
|||||||
model_config = ConfigDict(strict=True)
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
status: ServerStatus
|
status: ServerStatus
|
||||||
|
bangui_version: str = Field(..., description="BanGUI application version.")
|
||||||
|
|
||||||
|
|
||||||
class ServerSettings(BaseModel):
|
class ServerSettings(BaseModel):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
from app.dependencies import AuthDep
|
from app.dependencies import AuthDep
|
||||||
from app.models.ban import (
|
from app.models.ban import (
|
||||||
BanOrigin,
|
BanOrigin,
|
||||||
@@ -69,7 +70,7 @@ async def get_server_status(
|
|||||||
"server_status",
|
"server_status",
|
||||||
ServerStatus(online=False),
|
ServerStatus(online=False),
|
||||||
)
|
)
|
||||||
return ServerStatusResponse(status=cached)
|
return ServerStatusResponse(status=cached, bangui_version=__version__)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
|
||||||
from app.dependencies import AuthDep
|
from app.dependencies import AuthDep
|
||||||
from app.models.ban import TimeRange
|
from app.models.ban import BanOrigin, TimeRange
|
||||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||||
from app.services import geo_service, history_service
|
from app.services import geo_service, history_service
|
||||||
|
|
||||||
@@ -52,6 +52,10 @@ async def get_history(
|
|||||||
default=None,
|
default=None,
|
||||||
description="Restrict results to IPs matching this prefix.",
|
description="Restrict results to IPs matching this prefix.",
|
||||||
),
|
),
|
||||||
|
origin: BanOrigin | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.",
|
||||||
|
),
|
||||||
page: int = Query(default=1, ge=1, description="1-based page number."),
|
page: int = Query(default=1, ge=1, description="1-based page number."),
|
||||||
page_size: int = Query(
|
page_size: int = Query(
|
||||||
default=_DEFAULT_PAGE_SIZE,
|
default=_DEFAULT_PAGE_SIZE,
|
||||||
@@ -89,6 +93,7 @@ async def get_history(
|
|||||||
range_=range,
|
range_=range,
|
||||||
jail=jail,
|
jail=jail,
|
||||||
ip_filter=ip,
|
ip_filter=ip,
|
||||||
|
origin=origin,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
geo_enricher=_enricher,
|
geo_enricher=_enricher,
|
||||||
|
|||||||
@@ -368,8 +368,9 @@ async def update_jail_config(
|
|||||||
await _set("datepattern", update.date_pattern)
|
await _set("datepattern", update.date_pattern)
|
||||||
if update.dns_mode is not None:
|
if update.dns_mode is not None:
|
||||||
await _set("usedns", update.dns_mode)
|
await _set("usedns", update.dns_mode)
|
||||||
if update.backend is not None:
|
# Fail2ban does not support changing the log monitoring backend at runtime.
|
||||||
await _set("backend", update.backend)
|
# The configuration value is retained for read/display purposes but must not
|
||||||
|
# be applied via the socket API.
|
||||||
if update.log_encoding is not None:
|
if update.log_encoding is not None:
|
||||||
await _set("logencoding", update.log_encoding)
|
await _set("logencoding", update.log_encoding)
|
||||||
if update.prefregex is not None:
|
if update.prefregex is not None:
|
||||||
@@ -896,6 +897,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
|
|||||||
Returns:
|
Returns:
|
||||||
:class:`~app.models.config.ServiceStatusResponse`.
|
:class:`~app.models.config.ServiceStatusResponse`.
|
||||||
"""
|
"""
|
||||||
|
from app import __version__ # noqa: TCH001 - expose the app release version
|
||||||
from app.services.health_service import probe # lazy import avoids circular dep
|
from app.services.health_service import probe # lazy import avoids circular dep
|
||||||
|
|
||||||
server_status = await probe(socket_path)
|
server_status = await probe(socket_path)
|
||||||
@@ -921,6 +923,7 @@ async def get_service_status(socket_path: str) -> ServiceStatusResponse:
|
|||||||
return ServiceStatusResponse(
|
return ServiceStatusResponse(
|
||||||
online=server_status.online,
|
online=server_status.online,
|
||||||
version=server_status.version,
|
version=server_status.version,
|
||||||
|
bangui_version=__version__,
|
||||||
jail_count=server_status.active_jails,
|
jail_count=server_status.active_jails,
|
||||||
total_bans=server_status.total_bans,
|
total_bans=server_status.total_bans,
|
||||||
total_failures=server_status.total_failures,
|
total_failures=server_status.total_failures,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Any
|
|||||||
import aiosqlite
|
import aiosqlite
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
from app.models.ban import BLOCKLIST_JAIL, BanOrigin, TIME_RANGE_SECONDS, TimeRange
|
||||||
from app.models.history import (
|
from app.models.history import (
|
||||||
HistoryBanItem,
|
HistoryBanItem,
|
||||||
HistoryListResponse,
|
HistoryListResponse,
|
||||||
@@ -58,6 +58,7 @@ async def list_history(
|
|||||||
*,
|
*,
|
||||||
range_: TimeRange | None = None,
|
range_: TimeRange | None = None,
|
||||||
jail: str | None = None,
|
jail: str | None = None,
|
||||||
|
origin: BanOrigin | None = None,
|
||||||
ip_filter: str | None = None,
|
ip_filter: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||||
@@ -73,6 +74,8 @@ async def list_history(
|
|||||||
socket_path: Path to the fail2ban Unix domain socket.
|
socket_path: Path to the fail2ban Unix domain socket.
|
||||||
range_: Time-range preset. ``None`` means all-time (no time filter).
|
range_: Time-range preset. ``None`` means all-time (no time filter).
|
||||||
jail: If given, restrict results to bans from this jail.
|
jail: If given, restrict results to bans from this jail.
|
||||||
|
origin: Optional origin filter — ``"blocklist"`` restricts results to
|
||||||
|
the ``blocklist-import`` jail, ``"selfblock"`` excludes it.
|
||||||
ip_filter: If given, restrict results to bans for this exact IP
|
ip_filter: If given, restrict results to bans for this exact IP
|
||||||
(or a prefix — the query uses ``LIKE ip_filter%``).
|
(or a prefix — the query uses ``LIKE ip_filter%``).
|
||||||
page: 1-based page number (default: ``1``).
|
page: 1-based page number (default: ``1``).
|
||||||
@@ -99,6 +102,14 @@ async def list_history(
|
|||||||
wheres.append("jail = ?")
|
wheres.append("jail = ?")
|
||||||
params.append(jail)
|
params.append(jail)
|
||||||
|
|
||||||
|
if origin is not None:
|
||||||
|
if origin == "blocklist":
|
||||||
|
wheres.append("jail = ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
elif origin == "selfblock":
|
||||||
|
wheres.append("jail != ?")
|
||||||
|
params.append(BLOCKLIST_JAIL)
|
||||||
|
|
||||||
if ip_filter is not None:
|
if ip_filter is not None:
|
||||||
wheres.append("ip LIKE ?")
|
wheres.append("ip LIKE ?")
|
||||||
params.append(f"{ip_filter}%")
|
params.append(f"{ip_filter}%")
|
||||||
|
|||||||
93
backend/app/utils/jail_config.py
Normal file
93
backend/app/utils/jail_config.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Utilities for ensuring required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
BanGUI requires two custom jails — ``manual-Jail`` and ``blocklist-import``
|
||||||
|
— to be present in the fail2ban ``jail.d`` directory. This module provides
|
||||||
|
:func:`ensure_jail_configs` which checks each of the four files
|
||||||
|
(``*.conf`` template + ``*.local`` override) and creates any that are missing
|
||||||
|
with the correct default content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default file contents
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_JAIL_CONF = """\
|
||||||
|
[manual-Jail]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter = manual-Jail
|
||||||
|
logpath = /remotelogs/bangui/auth.log
|
||||||
|
backend = polling
|
||||||
|
maxretry = 3
|
||||||
|
findtime = 120
|
||||||
|
bantime = 60
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MANUAL_JAIL_LOCAL = """\
|
||||||
|
[manual-Jail]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_CONF = """\
|
||||||
|
[blocklist-import]
|
||||||
|
|
||||||
|
enabled = false
|
||||||
|
filter =
|
||||||
|
logpath = /dev/null
|
||||||
|
backend = auto
|
||||||
|
maxretry = 1
|
||||||
|
findtime = 1d
|
||||||
|
bantime = 86400
|
||||||
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BLOCKLIST_IMPORT_LOCAL = """\
|
||||||
|
[blocklist-import]
|
||||||
|
enabled = true
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File registry: (filename, default_content)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_JAIL_FILES: list[tuple[str, str]] = [
|
||||||
|
("manual-Jail.conf", _MANUAL_JAIL_CONF),
|
||||||
|
("manual-Jail.local", _MANUAL_JAIL_LOCAL),
|
||||||
|
("blocklist-import.conf", _BLOCKLIST_IMPORT_CONF),
|
||||||
|
("blocklist-import.local", _BLOCKLIST_IMPORT_LOCAL),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_jail_configs(jail_d_path: Path) -> None:
|
||||||
|
"""Ensure the required fail2ban jail configuration files exist.
|
||||||
|
|
||||||
|
Checks for ``manual-Jail.conf``, ``manual-Jail.local``,
|
||||||
|
``blocklist-import.conf``, and ``blocklist-import.local`` inside
|
||||||
|
*jail_d_path*. Any file that is missing is created with its default
|
||||||
|
content. Existing files are **never** overwritten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
jail_d_path: Path to the fail2ban ``jail.d`` directory. Will be
|
||||||
|
created (including all parents) if it does not already exist.
|
||||||
|
"""
|
||||||
|
jail_d_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for filename, default_content in _JAIL_FILES:
|
||||||
|
file_path = jail_d_path / filename
|
||||||
|
if file_path.exists():
|
||||||
|
log.debug("jail_config_already_exists", path=str(file_path))
|
||||||
|
else:
|
||||||
|
file_path.write_text(default_content, encoding="utf-8")
|
||||||
|
log.info("jail_config_created", path=str(file_path))
|
||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bangui-backend"
|
name = "bangui-backend"
|
||||||
version = "0.1.0"
|
version = "0.9.8"
|
||||||
description = "BanGUI backend — fail2ban web management interface"
|
description = "BanGUI backend — fail2ban web management interface"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import aiosqlite
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
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 create_app
|
||||||
@@ -2000,6 +2002,7 @@ class TestGetServiceStatus:
|
|||||||
return ServiceStatusResponse(
|
return ServiceStatusResponse(
|
||||||
online=online,
|
online=online,
|
||||||
version="1.0.0" if online else None,
|
version="1.0.0" if online else None,
|
||||||
|
bangui_version=app.__version__,
|
||||||
jail_count=2 if online else 0,
|
jail_count=2 if online else 0,
|
||||||
total_bans=10 if online else 0,
|
total_bans=10 if online else 0,
|
||||||
total_failures=3 if online else 0,
|
total_failures=3 if online else 0,
|
||||||
@@ -2018,6 +2021,7 @@ class TestGetServiceStatus:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["online"] is True
|
assert data["online"] is True
|
||||||
|
assert data["bangui_version"] == app.__version__
|
||||||
assert data["jail_count"] == 2
|
assert data["jail_count"] == 2
|
||||||
assert data["log_level"] == "INFO"
|
assert data["log_level"] == "INFO"
|
||||||
|
|
||||||
@@ -2031,6 +2035,7 @@ class TestGetServiceStatus:
|
|||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
assert data["bangui_version"] == app.__version__
|
||||||
assert data["online"] is False
|
assert data["online"] is False
|
||||||
assert data["log_level"] == "UNKNOWN"
|
assert data["log_level"] == "UNKNOWN"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import aiosqlite
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
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 create_app
|
||||||
@@ -151,6 +153,9 @@ class TestDashboardStatus:
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
||||||
assert "status" in body
|
assert "status" in body
|
||||||
|
assert "bangui_version" in body
|
||||||
|
assert body["bangui_version"] == app.__version__
|
||||||
|
|
||||||
status = body["status"]
|
status = body["status"]
|
||||||
assert "online" in status
|
assert "online" in status
|
||||||
assert "version" in status
|
assert "version" in status
|
||||||
@@ -163,8 +168,10 @@ class TestDashboardStatus:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Endpoint returns the exact values from ``app.state.server_status``."""
|
"""Endpoint returns the exact values from ``app.state.server_status``."""
|
||||||
response = await dashboard_client.get("/api/dashboard/status")
|
response = await dashboard_client.get("/api/dashboard/status")
|
||||||
status = response.json()["status"]
|
body = response.json()
|
||||||
|
status = body["status"]
|
||||||
|
|
||||||
|
assert body["bangui_version"] == app.__version__
|
||||||
assert status["online"] is True
|
assert status["online"] is True
|
||||||
assert status["version"] == "1.0.2"
|
assert status["version"] == "1.0.2"
|
||||||
assert status["active_jails"] == 2
|
assert status["active_jails"] == 2
|
||||||
@@ -177,8 +184,10 @@ class TestDashboardStatus:
|
|||||||
"""Endpoint returns online=False when the cache holds an offline snapshot."""
|
"""Endpoint returns online=False when the cache holds an offline snapshot."""
|
||||||
response = await offline_dashboard_client.get("/api/dashboard/status")
|
response = await offline_dashboard_client.get("/api/dashboard/status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
status = response.json()["status"]
|
body = response.json()
|
||||||
|
status = body["status"]
|
||||||
|
|
||||||
|
assert body["bangui_version"] == app.__version__
|
||||||
assert status["online"] is False
|
assert status["online"] is False
|
||||||
assert status["version"] is None
|
assert status["version"] is None
|
||||||
assert status["active_jails"] == 0
|
assert status["active_jails"] == 0
|
||||||
|
|||||||
@@ -213,6 +213,18 @@ class TestHistoryList:
|
|||||||
_args, kwargs = mock_fn.call_args
|
_args, kwargs = mock_fn.call_args
|
||||||
assert kwargs.get("range_") == "7d"
|
assert kwargs.get("range_") == "7d"
|
||||||
|
|
||||||
|
async def test_forwards_origin_filter(self, history_client: AsyncClient) -> None:
|
||||||
|
"""The ``origin`` query parameter is forwarded to the service."""
|
||||||
|
mock_fn = AsyncMock(return_value=_make_history_list(n=0))
|
||||||
|
with patch(
|
||||||
|
"app.routers.history.history_service.list_history",
|
||||||
|
new=mock_fn,
|
||||||
|
):
|
||||||
|
await history_client.get("/api/history?origin=blocklist")
|
||||||
|
|
||||||
|
_args, kwargs = mock_fn.call_args
|
||||||
|
assert kwargs.get("origin") == "blocklist"
|
||||||
|
|
||||||
async def test_empty_result(self, history_client: AsyncClient) -> None:
|
async def test_empty_result(self, history_client: AsyncClient) -> None:
|
||||||
"""An empty history returns items=[] and total=0."""
|
"""An empty history returns items=[] and total=0."""
|
||||||
with patch(
|
with patch(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,27 @@ class TestUpdateJailConfig:
|
|||||||
assert "bantime" in keys
|
assert "bantime" in keys
|
||||||
assert "maxretry" in keys
|
assert "maxretry" in keys
|
||||||
|
|
||||||
|
async def test_ignores_backend_field(self) -> None:
|
||||||
|
"""update_jail_config does not send a set command for backend."""
|
||||||
|
sent_commands: list[list[Any]] = []
|
||||||
|
|
||||||
|
async def _send(command: list[Any]) -> Any:
|
||||||
|
sent_commands.append(command)
|
||||||
|
return (0, "OK")
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, **_kw: Any) -> None:
|
||||||
|
self.send = AsyncMock(side_effect=_send)
|
||||||
|
|
||||||
|
from app.models.config import JailConfigUpdate
|
||||||
|
|
||||||
|
update = JailConfigUpdate(backend="polling")
|
||||||
|
with patch("app.services.config_service.Fail2BanClient", _FakeClient):
|
||||||
|
await config_service.update_jail_config(_SOCKET, "sshd", update)
|
||||||
|
|
||||||
|
keys = [cmd[2] for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"]
|
||||||
|
assert "backend" not in keys
|
||||||
|
|
||||||
async def test_raises_validation_error_on_bad_regex(self) -> None:
|
async def test_raises_validation_error_on_bad_regex(self) -> None:
|
||||||
"""update_jail_config raises ConfigValidationError for invalid regex."""
|
"""update_jail_config raises ConfigValidationError for invalid regex."""
|
||||||
from app.models.config import JailConfigUpdate
|
from app.models.config import JailConfigUpdate
|
||||||
|
|||||||
138
backend/tests/test_utils/test_jail_config.py
Normal file
138
backend/tests/test_utils/test_jail_config.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Tests for app.utils.jail_config.ensure_jail_configs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.utils.jail_config import (
|
||||||
|
_BLOCKLIST_IMPORT_CONF,
|
||||||
|
_BLOCKLIST_IMPORT_LOCAL,
|
||||||
|
_MANUAL_JAIL_CONF,
|
||||||
|
_MANUAL_JAIL_LOCAL,
|
||||||
|
ensure_jail_configs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Expected filenames
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANUAL_CONF = "manual-Jail.conf"
|
||||||
|
_MANUAL_LOCAL = "manual-Jail.local"
|
||||||
|
_BLOCKLIST_CONF = "blocklist-import.conf"
|
||||||
|
_BLOCKLIST_LOCAL = "blocklist-import.local"
|
||||||
|
|
||||||
|
_ALL_FILES = [_MANUAL_CONF, _MANUAL_LOCAL, _BLOCKLIST_CONF, _BLOCKLIST_LOCAL]
|
||||||
|
|
||||||
|
_CONTENT_MAP: dict[str, str] = {
|
||||||
|
_MANUAL_CONF: _MANUAL_JAIL_CONF,
|
||||||
|
_MANUAL_LOCAL: _MANUAL_JAIL_LOCAL,
|
||||||
|
_BLOCKLIST_CONF: _BLOCKLIST_IMPORT_CONF,
|
||||||
|
_BLOCKLIST_LOCAL: _BLOCKLIST_IMPORT_LOCAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _read(jail_d: Path, filename: str) -> str:
|
||||||
|
return (jail_d / filename).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: ensure_jail_configs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureJailConfigs:
|
||||||
|
def test_all_missing_creates_all_four(self, tmp_path: Path) -> None:
|
||||||
|
"""All four files are created when the directory is empty."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert (jail_d / name).exists(), f"{name} should have been created"
|
||||||
|
assert _read(jail_d, name) == _CONTENT_MAP[name]
|
||||||
|
|
||||||
|
def test_all_missing_creates_correct_content(self, tmp_path: Path) -> None:
|
||||||
|
"""Each created file has exactly the expected default content."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# .conf files must set enabled = false
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
content = _read(jail_d, conf_file)
|
||||||
|
assert "enabled = false" in content
|
||||||
|
|
||||||
|
# Blocklist-import jail must have a 24-hour ban time
|
||||||
|
blocklist_conf = _read(jail_d, _BLOCKLIST_CONF)
|
||||||
|
assert "bantime = 86400" in blocklist_conf
|
||||||
|
|
||||||
|
# .local files must set enabled = true and nothing else
|
||||||
|
for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL):
|
||||||
|
content = _read(jail_d, local_file)
|
||||||
|
assert "enabled = true" in content
|
||||||
|
|
||||||
|
def test_all_present_overwrites_nothing(self, tmp_path: Path) -> None:
|
||||||
|
"""Existing files are never overwritten."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
jail_d.mkdir()
|
||||||
|
|
||||||
|
sentinel = "# EXISTING CONTENT — must not be replaced\n"
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
(jail_d / name).write_text(sentinel, encoding="utf-8")
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert _read(jail_d, name) == sentinel, (
|
||||||
|
f"{name} should not have been overwritten"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_only_local_files_missing_creates_only_locals(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Only the .local files are created when the .conf files already exist."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
jail_d.mkdir()
|
||||||
|
|
||||||
|
sentinel = "# pre-existing conf\n"
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
(jail_d / conf_file).write_text(sentinel, encoding="utf-8")
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# .conf files must remain unchanged
|
||||||
|
for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF):
|
||||||
|
assert _read(jail_d, conf_file) == sentinel
|
||||||
|
|
||||||
|
# .local files must have been created with correct content
|
||||||
|
for local_file, expected in (
|
||||||
|
(_MANUAL_LOCAL, _MANUAL_JAIL_LOCAL),
|
||||||
|
(_BLOCKLIST_LOCAL, _BLOCKLIST_IMPORT_LOCAL),
|
||||||
|
):
|
||||||
|
assert (jail_d / local_file).exists(), f"{local_file} should have been created"
|
||||||
|
assert _read(jail_d, local_file) == expected
|
||||||
|
|
||||||
|
def test_creates_jail_d_directory_if_missing(self, tmp_path: Path) -> None:
|
||||||
|
"""The jail.d directory is created automatically when absent."""
|
||||||
|
jail_d = tmp_path / "nested" / "jail.d"
|
||||||
|
assert not jail_d.exists()
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
assert jail_d.is_dir()
|
||||||
|
|
||||||
|
def test_idempotent_on_repeated_calls(self, tmp_path: Path) -> None:
|
||||||
|
"""Calling ensure_jail_configs twice does not alter any file."""
|
||||||
|
jail_d = tmp_path / "jail.d"
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
# Record content after first call
|
||||||
|
first_pass = {name: _read(jail_d, name) for name in _ALL_FILES}
|
||||||
|
|
||||||
|
ensure_jail_configs(jail_d)
|
||||||
|
|
||||||
|
for name in _ALL_FILES:
|
||||||
|
assert _read(jail_d, name) == first_pass[name], (
|
||||||
|
f"{name} changed on second call"
|
||||||
|
)
|
||||||
15
backend/tests/test_version.py
Normal file
15
backend/tests/test_version.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_matches_docker_version() -> None:
|
||||||
|
"""The backend version should match the signed off Docker release version."""
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
version_file = repo_root / "Docker" / "VERSION"
|
||||||
|
expected = version_file.read_text(encoding="utf-8").strip().lstrip("v")
|
||||||
|
|
||||||
|
assert app.__version__ == expected
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.9.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.9.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.0",
|
"@fluentui/react-components": "^9.55.0",
|
||||||
"@fluentui/react-icons": "^2.0.257",
|
"@fluentui/react-icons": "^2.0.257",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.9.10",
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function fetchHistory(
|
|||||||
): Promise<HistoryListResponse> {
|
): Promise<HistoryListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query.range) params.set("range", query.range);
|
if (query.range) params.set("range", query.range);
|
||||||
|
if (query.origin) params.set("origin", query.origin);
|
||||||
if (query.jail) params.set("jail", query.jail);
|
if (query.jail) params.set("jail", query.jail);
|
||||||
if (query.ip) params.set("ip", query.ip);
|
if (query.ip) params.set("ip", query.ip);
|
||||||
if (query.page !== undefined) params.set("page", String(query.page));
|
if (query.page !== undefined) params.set("page", String(query.page));
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const useStyles = makeStyles({
|
|||||||
*/
|
*/
|
||||||
export function ServerStatusBar(): React.JSX.Element {
|
export function ServerStatusBar(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { status, loading, error, refresh } = useServerStatus();
|
const { status, banguiVersion, loading, error, refresh } = useServerStatus();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.bar} role="status" aria-label="fail2ban server status">
|
<div className={styles.bar} role="status" aria-label="fail2ban server status">
|
||||||
@@ -109,13 +109,21 @@ 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>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{banguiVersion != null && (
|
||||||
|
<Tooltip content="BanGUI version" relationship="description">
|
||||||
|
<Badge appearance="filled" size="small">
|
||||||
|
BanGUI v{banguiVersion}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
{/* Stats (only when online) */}
|
{/* Stats (only when online) */}
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
@@ -139,9 +147,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;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* country filters the companion table.
|
* country filters the companion table.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||||
@@ -50,6 +51,28 @@ const useStyles = makeStyles({
|
|||||||
gap: tokens.spacingVerticalXS,
|
gap: tokens.spacingVerticalXS,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
|
tooltip: {
|
||||||
|
position: "fixed",
|
||||||
|
zIndex: 9999,
|
||||||
|
pointerEvents: "none",
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||||
|
borderRadius: tokens.borderRadiusSmall,
|
||||||
|
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: tokens.spacingVerticalXXS,
|
||||||
|
boxShadow: tokens.shadow4,
|
||||||
|
},
|
||||||
|
tooltipCountry: {
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
fontWeight: tokens.fontWeightSemibold,
|
||||||
|
color: tokens.colorNeutralForeground1,
|
||||||
|
},
|
||||||
|
tooltipCount: {
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
color: tokens.colorNeutralForeground2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -58,6 +81,7 @@ const useStyles = makeStyles({
|
|||||||
|
|
||||||
interface GeoLayerProps {
|
interface GeoLayerProps {
|
||||||
countries: Record<string, number>;
|
countries: Record<string, number>;
|
||||||
|
countryNames?: Record<string, string>;
|
||||||
selectedCountry: string | null;
|
selectedCountry: string | null;
|
||||||
onSelectCountry: (cc: string | null) => void;
|
onSelectCountry: (cc: string | null) => void;
|
||||||
thresholdLow: number;
|
thresholdLow: number;
|
||||||
@@ -67,6 +91,7 @@ interface GeoLayerProps {
|
|||||||
|
|
||||||
function GeoLayer({
|
function GeoLayer({
|
||||||
countries,
|
countries,
|
||||||
|
countryNames,
|
||||||
selectedCountry,
|
selectedCountry,
|
||||||
onSelectCountry,
|
onSelectCountry,
|
||||||
thresholdLow,
|
thresholdLow,
|
||||||
@@ -76,6 +101,17 @@ function GeoLayer({
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||||
|
|
||||||
|
const [tooltip, setTooltip] = useState<
|
||||||
|
| {
|
||||||
|
cc: string;
|
||||||
|
count: number;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(cc: string | null): void => {
|
(cc: string | null): void => {
|
||||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||||
@@ -98,7 +134,7 @@ function GeoLayer({
|
|||||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||||
const isSelected = cc !== null && selectedCountry === cc;
|
const isSelected = cc !== null && selectedCountry === cc;
|
||||||
|
|
||||||
// Compute the fill color based on ban count
|
// Compute the fill color based on ban count
|
||||||
const fillColor = getBanCountColor(
|
const fillColor = getBanCountColor(
|
||||||
count,
|
count,
|
||||||
@@ -106,7 +142,7 @@ function GeoLayer({
|
|||||||
thresholdMedium,
|
thresholdMedium,
|
||||||
thresholdHigh,
|
thresholdHigh,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only calculate centroid if path is available
|
// Only calculate centroid if path is available
|
||||||
let cx: number | undefined;
|
let cx: number | undefined;
|
||||||
let cy: number | undefined;
|
let cy: number | undefined;
|
||||||
@@ -136,6 +172,30 @@ function GeoLayer({
|
|||||||
handleClick(cc);
|
handleClick(cc);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!cc) return;
|
||||||
|
setTooltip({
|
||||||
|
cc,
|
||||||
|
count,
|
||||||
|
name: countryNames?.[cc] ?? cc,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseMove={(e): void => {
|
||||||
|
setTooltip((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(): void => {
|
||||||
|
setTooltip(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Geography
|
<Geography
|
||||||
geography={geo}
|
geography={geo}
|
||||||
@@ -179,6 +239,22 @@ function GeoLayer({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tooltip &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className={styles.tooltip}
|
||||||
|
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||||
|
role="tooltip"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||||
|
<span className={styles.tooltipCount}>
|
||||||
|
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -190,6 +266,8 @@ function GeoLayer({
|
|||||||
export interface WorldMapProps {
|
export interface WorldMapProps {
|
||||||
/** ISO alpha-2 country code → ban count. */
|
/** ISO alpha-2 country code → ban count. */
|
||||||
countries: Record<string, number>;
|
countries: Record<string, number>;
|
||||||
|
/** Optional mapping from country code to display name. */
|
||||||
|
countryNames?: Record<string, string>;
|
||||||
/** Currently selected country filter (null means no filter). */
|
/** Currently selected country filter (null means no filter). */
|
||||||
selectedCountry: string | null;
|
selectedCountry: string | null;
|
||||||
/** Called when the user clicks a country or deselects. */
|
/** Called when the user clicks a country or deselects. */
|
||||||
@@ -204,6 +282,7 @@ export interface WorldMapProps {
|
|||||||
|
|
||||||
export function WorldMap({
|
export function WorldMap({
|
||||||
countries,
|
countries,
|
||||||
|
countryNames,
|
||||||
selectedCountry,
|
selectedCountry,
|
||||||
onSelectCountry,
|
onSelectCountry,
|
||||||
thresholdLow = 20,
|
thresholdLow = 20,
|
||||||
@@ -286,6 +365,7 @@ export function WorldMap({
|
|||||||
>
|
>
|
||||||
<GeoLayer
|
<GeoLayer
|
||||||
countries={countries}
|
countries={countries}
|
||||||
|
countryNames={countryNames}
|
||||||
selectedCountry={selectedCountry}
|
selectedCountry={selectedCountry}
|
||||||
onSelectCountry={onSelectCountry}
|
onSelectCountry={onSelectCountry}
|
||||||
thresholdLow={thresholdLow}
|
thresholdLow={thresholdLow}
|
||||||
|
|||||||
178
frontend/src/components/__tests__/ServerStatusBar.test.tsx
Normal file
178
frontend/src/components/__tests__/ServerStatusBar.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
banguiVersion: 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,
|
||||||
|
},
|
||||||
|
banguiVersion: "1.1.0",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
banguiVersion: "1.1.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,
|
||||||
|
},
|
||||||
|
banguiVersion: "1.2.3",
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
renderBar();
|
||||||
|
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a BanGUI version badge", () => {
|
||||||
|
mockedUseServerStatus.mockReturnValue({
|
||||||
|
status: {
|
||||||
|
online: true,
|
||||||
|
version: "1.2.3",
|
||||||
|
active_jails: 1,
|
||||||
|
total_bans: 0,
|
||||||
|
total_failures: 0,
|
||||||
|
},
|
||||||
|
banguiVersion: "9.9.9",
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
renderBar();
|
||||||
|
expect(screen.getByText("BanGUI v9.9.9")).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,
|
||||||
|
},
|
||||||
|
banguiVersion: "1.2.3",
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
banguiVersion: "1.0.0",
|
||||||
|
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,
|
||||||
|
banguiVersion: 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
frontend/src/components/__tests__/WorldMap.test.tsx
Normal file
51
frontend/src/components/__tests__/WorldMap.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Tests for WorldMap component.
|
||||||
|
*
|
||||||
|
* Verifies that hovering a country shows a tooltip with the country name and ban count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
||||||
|
vi.mock("react-simple-maps", () => ({
|
||||||
|
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
Geography: ({ children }: { children?: React.ReactNode }) => <g>{children}</g>,
|
||||||
|
useGeographies: () => ({
|
||||||
|
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||||
|
path: { centroid: () => [10, 10] },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { WorldMap } from "../WorldMap";
|
||||||
|
|
||||||
|
describe("WorldMap", () => {
|
||||||
|
it("shows a tooltip with country name and ban count on hover", () => {
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<WorldMap
|
||||||
|
countries={{ US: 42 }}
|
||||||
|
countryNames={{ US: "United States" }}
|
||||||
|
selectedCountry={null}
|
||||||
|
onSelectCountry={vi.fn()}
|
||||||
|
/>
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tooltip should not be present initially
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
|
||||||
|
const countryButton = screen.getByRole("button", { name: /US: 42 bans/i });
|
||||||
|
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||||
|
|
||||||
|
const tooltip = screen.getByRole("tooltip");
|
||||||
|
expect(tooltip).toHaveTextContent("United States");
|
||||||
|
expect(tooltip).toHaveTextContent("42 bans");
|
||||||
|
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
||||||
|
|
||||||
|
fireEvent.mouseLeave(countryButton);
|
||||||
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -216,7 +216,6 @@ function JailConfigDetail({
|
|||||||
ignore_regex: ignoreRegex,
|
ignore_regex: ignoreRegex,
|
||||||
date_pattern: datePattern !== "" ? datePattern : null,
|
date_pattern: datePattern !== "" ? datePattern : null,
|
||||||
dns_mode: dnsMode,
|
dns_mode: dnsMode,
|
||||||
backend,
|
|
||||||
log_encoding: logEncoding,
|
log_encoding: logEncoding,
|
||||||
prefregex: prefRegex !== "" ? prefRegex : null,
|
prefregex: prefRegex !== "" ? prefRegex : null,
|
||||||
bantime_escalation: {
|
bantime_escalation: {
|
||||||
@@ -231,7 +230,7 @@ function JailConfigDetail({
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
||||||
dnsMode, backend, logEncoding, prefRegex, escEnabled, escFactor,
|
dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
|
||||||
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
|
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
|
||||||
jail.ban_time, jail.find_time, jail.max_retry,
|
jail.ban_time, jail.find_time, jail.max_retry,
|
||||||
],
|
],
|
||||||
@@ -758,7 +757,12 @@ function InactiveJailDetail({
|
|||||||
*
|
*
|
||||||
* @returns JSX element.
|
* @returns JSX element.
|
||||||
*/
|
*/
|
||||||
export function JailsTab(): React.JSX.Element {
|
interface JailsTabProps {
|
||||||
|
/** Jail name to pre-select when the component mounts. */
|
||||||
|
initialJail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
|
||||||
const styles = useConfigStyles();
|
const styles = useConfigStyles();
|
||||||
const { jails, loading, error, refresh, updateJail } =
|
const { jails, loading, error, refresh, updateJail } =
|
||||||
useJailConfigs();
|
useJailConfigs();
|
||||||
@@ -819,6 +823,13 @@ export function JailsTab(): React.JSX.Element {
|
|||||||
return [...activeItems, ...inactiveItems];
|
return [...activeItems, ...inactiveItems];
|
||||||
}, [jails, inactiveJails]);
|
}, [jails, inactiveJails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialJail || selectedName) return;
|
||||||
|
if (listItems.some((item) => item.name === initialJail)) {
|
||||||
|
setSelectedName(initialJail);
|
||||||
|
}
|
||||||
|
}, [initialJail, listItems, selectedName]);
|
||||||
|
|
||||||
const activeJailMap = useMemo(
|
const activeJailMap = useMemo(
|
||||||
() => new Map(jails.map((j) => [j.name, j])),
|
() => new Map(jails.map((j) => [j.name, j])),
|
||||||
[jails],
|
[jails],
|
||||||
@@ -898,12 +909,14 @@ export function JailsTab(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{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); }}
|
||||||
onDeactivate={
|
onDeactivate={
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ export function ServerHealthSection(): React.JSX.Element {
|
|||||||
<Text className={styles.statValue}>{status.version}</Text>
|
<Text className={styles.statValue}>{status.version}</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{status.bangui_version && (
|
||||||
|
<div className={styles.statCard}>
|
||||||
|
<Text className={styles.statLabel}>BanGUI</Text>
|
||||||
|
<Text className={styles.statValue}>{status.bangui_version}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<Text className={styles.statLabel}>Active Jails</Text>
|
<Text className={styles.statLabel}>Active Jails</Text>
|
||||||
<Text className={styles.statValue}>{status.jail_count}</Text>
|
<Text className={styles.statValue}>{status.jail_count}</Text>
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Service Health & Log Viewer section — shown first so users can
|
||||||
|
immediately see whether fail2ban is reachable before editing settings. */}
|
||||||
|
<ServerHealthSection />
|
||||||
|
|
||||||
<div className={styles.sectionCard}>
|
<div className={styles.sectionCard}>
|
||||||
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
<div style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||||
<AutoSaveIndicator
|
<AutoSaveIndicator
|
||||||
@@ -412,8 +416,6 @@ export function ServerTab(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Service Health & Log Viewer section */}
|
|
||||||
<ServerHealthSection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
84
frontend/src/components/config/__tests__/JailsTab.test.tsx
Normal file
84
frontend/src/components/config/__tests__/JailsTab.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
|
||||||
|
import { JailsTab } from "../JailsTab";
|
||||||
|
import type { JailConfig } from "../../../types/config";
|
||||||
|
import { useAutoSave } from "../../../hooks/useAutoSave";
|
||||||
|
import { useJailConfigs } from "../../../hooks/useConfig";
|
||||||
|
import { useConfigActiveStatus } from "../../../hooks/useConfigActiveStatus";
|
||||||
|
|
||||||
|
vi.mock("../../../hooks/useAutoSave");
|
||||||
|
vi.mock("../../../hooks/useConfig");
|
||||||
|
vi.mock("../../../hooks/useConfigActiveStatus");
|
||||||
|
vi.mock("../../../api/config", () => ({
|
||||||
|
fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [] }),
|
||||||
|
deactivateJail: vi.fn(),
|
||||||
|
deleteJailLocalOverride: vi.fn(),
|
||||||
|
addLogPath: vi.fn(),
|
||||||
|
deleteLogPath: vi.fn(),
|
||||||
|
fetchJailConfigFileContent: vi.fn(),
|
||||||
|
updateJailConfigFile: vi.fn(),
|
||||||
|
validateJailConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseAutoSave = vi.mocked(useAutoSave);
|
||||||
|
const mockUseJailConfigs = vi.mocked(useJailConfigs);
|
||||||
|
const mockUseConfigActiveStatus = vi.mocked(useConfigActiveStatus);
|
||||||
|
|
||||||
|
const basicJail: JailConfig = {
|
||||||
|
name: "sshd",
|
||||||
|
ban_time: 600,
|
||||||
|
max_retry: 5,
|
||||||
|
find_time: 600,
|
||||||
|
fail_regex: [],
|
||||||
|
ignore_regex: [],
|
||||||
|
log_paths: [],
|
||||||
|
date_pattern: null,
|
||||||
|
log_encoding: "auto",
|
||||||
|
backend: "polling",
|
||||||
|
use_dns: "warn",
|
||||||
|
prefregex: "",
|
||||||
|
actions: [],
|
||||||
|
bantime_escalation: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("JailsTab", () => {
|
||||||
|
it("does not include backend in auto-save payload", () => {
|
||||||
|
const autoSavePayloads: Array<Record<string, unknown>> = [];
|
||||||
|
mockUseAutoSave.mockImplementation((value) => {
|
||||||
|
autoSavePayloads.push(value as Record<string, unknown>);
|
||||||
|
return { status: "idle", errorText: null, retry: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseJailConfigs.mockReturnValue({
|
||||||
|
jails: [basicJail],
|
||||||
|
total: 1,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
updateJail: vi.fn(),
|
||||||
|
reloadAll: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseConfigActiveStatus.mockReturnValue({
|
||||||
|
activeJails: new Set<string>(),
|
||||||
|
activeFilters: new Set<string>(),
|
||||||
|
activeActions: new Set<string>(),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<JailsTab initialJail="sshd" />
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(autoSavePayloads.length).toBeGreaterThan(0);
|
||||||
|
const lastPayload = autoSavePayloads[autoSavePayloads.length - 1];
|
||||||
|
|
||||||
|
expect(lastPayload).not.toHaveProperty("backend");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { ServerHealthSection } from "../ServerHealthSection";
|
||||||
|
|
||||||
|
vi.mock("../../../api/config");
|
||||||
|
|
||||||
|
import { fetchFail2BanLog, fetchServiceStatus } from "../../../api/config";
|
||||||
|
|
||||||
|
const mockedFetchServiceStatus = vi.mocked(fetchServiceStatus);
|
||||||
|
const mockedFetchFail2BanLog = vi.mocked(fetchFail2BanLog);
|
||||||
|
|
||||||
|
describe("ServerHealthSection", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the BanGUI version in the service health panel", async () => {
|
||||||
|
mockedFetchServiceStatus.mockResolvedValue({
|
||||||
|
online: true,
|
||||||
|
version: "1.2.3",
|
||||||
|
bangui_version: "1.2.3",
|
||||||
|
jail_count: 2,
|
||||||
|
total_bans: 5,
|
||||||
|
total_failures: 1,
|
||||||
|
log_level: "INFO",
|
||||||
|
log_target: "STDOUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedFetchFail2BanLog.mockResolvedValue({
|
||||||
|
log_path: "/var/log/fail2ban.log",
|
||||||
|
lines: ["2026-01-01 fail2ban[123]: INFO Test"],
|
||||||
|
total_lines: 1,
|
||||||
|
log_level: "INFO",
|
||||||
|
log_target: "STDOUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<ServerHealthSection />
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The service health panel should render and include the BanGUI version.
|
||||||
|
const banGuiLabel = await screen.findByText("BanGUI");
|
||||||
|
expect(banGuiLabel).toBeInTheDocument();
|
||||||
|
|
||||||
|
const banGuiCard = banGuiLabel.closest("div");
|
||||||
|
expect(banGuiCard).toHaveTextContent("1.2.3");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,8 @@ const POLL_INTERVAL_MS = 30_000;
|
|||||||
export interface UseServerStatusResult {
|
export interface UseServerStatusResult {
|
||||||
/** The most recent server status snapshot, or `null` before the first fetch. */
|
/** The most recent server status snapshot, or `null` before the first fetch. */
|
||||||
status: ServerStatus | null;
|
status: ServerStatus | null;
|
||||||
|
/** BanGUI application version string. */
|
||||||
|
banguiVersion: string | null;
|
||||||
/** Whether a fetch is currently in flight. */
|
/** Whether a fetch is currently in flight. */
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Error message string when the last fetch failed, otherwise `null`. */
|
/** Error message string when the last fetch failed, otherwise `null`. */
|
||||||
@@ -32,6 +34,7 @@ export interface UseServerStatusResult {
|
|||||||
*/
|
*/
|
||||||
export function useServerStatus(): UseServerStatusResult {
|
export function useServerStatus(): UseServerStatusResult {
|
||||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||||
|
const [banguiVersion, setBanguiVersion] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ export function useServerStatus(): UseServerStatusResult {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchServerStatus();
|
const data = await fetchServerStatus();
|
||||||
setStatus(data.status);
|
setStatus(data.status);
|
||||||
|
setBanguiVersion(data.bangui_version);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch server status");
|
setError(err instanceof Error ? err.message : "Failed to fetch server status");
|
||||||
@@ -77,5 +81,5 @@ export function useServerStatus(): UseServerStatusResult {
|
|||||||
void doFetch().catch((): void => undefined);
|
void doFetch().catch((): void => undefined);
|
||||||
}, [doFetch]);
|
}, [doFetch]);
|
||||||
|
|
||||||
return { status, loading, error, refresh };
|
return { status, banguiVersion, loading, error, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,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: {
|
||||||
@@ -301,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
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={collapsed ? "Sign out" : ""}
|
content={collapsed ? "Sign out" : ""}
|
||||||
relationship="label"
|
relationship="label"
|
||||||
|
|||||||
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("does not show the BanGUI application version in the sidebar footer", () => {
|
||||||
|
renderLayout();
|
||||||
|
// __APP_VERSION__ is stubbed to "0.0.0-test" via vitest.config.ts define.
|
||||||
|
expect(screen.queryByText(/BanGUI v/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the logo 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")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
* Export — raw file editors for jail, filter, and action files
|
* Export — raw file editors for jail, filter, and action files
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
|
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||||
import {
|
import {
|
||||||
ActionsTab,
|
ActionsTab,
|
||||||
@@ -58,8 +59,16 @@ type TabValue =
|
|||||||
|
|
||||||
export function ConfigPage(): React.JSX.Element {
|
export function ConfigPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const location = useLocation();
|
||||||
const [tab, setTab] = useState<TabValue>("jails");
|
const [tab, setTab] = useState<TabValue>("jails");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { tab?: string; jail?: string } | null;
|
||||||
|
if (state?.tab === "jails") {
|
||||||
|
setTab("jails");
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -86,7 +95,11 @@ export function ConfigPage(): React.JSX.Element {
|
|||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<div className={styles.tabContent} key={tab}>
|
<div className={styles.tabContent} key={tab}>
|
||||||
{tab === "jails" && <JailsTab />}
|
{tab === "jails" && (
|
||||||
|
<JailsTab
|
||||||
|
initialJail={(location.state as { jail?: string } | null)?.jail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tab === "filters" && <FiltersTab />}
|
{tab === "filters" && <FiltersTab />}
|
||||||
{tab === "actions" && <ActionsTab />}
|
{tab === "actions" && <ActionsTab />}
|
||||||
{tab === "server" && <ServerTab />}
|
{tab === "server" && <ServerTab />}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Select,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -42,8 +41,10 @@ import {
|
|||||||
ChevronLeftRegular,
|
ChevronLeftRegular,
|
||||||
ChevronRightRegular,
|
ChevronRightRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
|
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||||
import { useHistory, useIpHistory } from "../hooks/useHistory";
|
import { useHistory, useIpHistory } from "../hooks/useHistory";
|
||||||
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
|
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
|
||||||
|
import type { BanOriginFilter } from "../types/ban";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Constants
|
// Constants
|
||||||
@@ -54,13 +55,6 @@ const HIGH_BAN_THRESHOLD = 5;
|
|||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
|
||||||
{ label: "Last 24 hours", value: "24h" },
|
|
||||||
{ label: "Last 7 days", value: "7d" },
|
|
||||||
{ label: "Last 30 days", value: "30d" },
|
|
||||||
{ label: "Last 365 days", value: "365d" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -381,7 +375,8 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [range, setRange] = useState<TimeRange | undefined>(undefined);
|
const [range, setRange] = useState<TimeRange>("24h");
|
||||||
|
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||||
const [jailFilter, setJailFilter] = useState("");
|
const [jailFilter, setJailFilter] = useState("");
|
||||||
const [ipFilter, setIpFilter] = useState("");
|
const [ipFilter, setIpFilter] = useState("");
|
||||||
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>({
|
||||||
@@ -397,11 +392,12 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
const applyFilters = useCallback((): void => {
|
const applyFilters = useCallback((): void => {
|
||||||
setAppliedQuery({
|
setAppliedQuery({
|
||||||
range: range,
|
range: range,
|
||||||
|
origin: originFilter !== "all" ? originFilter : undefined,
|
||||||
jail: jailFilter.trim() || undefined,
|
jail: jailFilter.trim() || undefined,
|
||||||
ip: ipFilter.trim() || undefined,
|
ip: ipFilter.trim() || undefined,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
}, [range, jailFilter, ipFilter]);
|
}, [range, originFilter, jailFilter, ipFilter]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
@@ -452,24 +448,16 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
<div className={styles.filterRow}>
|
<div className={styles.filterRow}>
|
||||||
<div className={styles.filterLabel}>
|
<DashboardFilterBar
|
||||||
<Text size={200}>Time range</Text>
|
timeRange={range}
|
||||||
<Select
|
onTimeRangeChange={(value) => {
|
||||||
aria-label="Time range"
|
setRange(value);
|
||||||
value={range ?? ""}
|
}}
|
||||||
onChange={(_ev, data): void => {
|
originFilter={originFilter}
|
||||||
setRange(data.value === "" ? undefined : (data.value as TimeRange));
|
onOriginFilterChange={(value) => {
|
||||||
}}
|
setOriginFilter(value);
|
||||||
size="small"
|
}}
|
||||||
>
|
/>
|
||||||
<option value="">All time</option>
|
|
||||||
{TIME_RANGE_OPTIONS.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.filterLabel}>
|
<div className={styles.filterLabel}>
|
||||||
<Text size={200}>Jail</Text>
|
<Text size={200}>Jail</Text>
|
||||||
@@ -506,7 +494,8 @@ export function HistoryPage(): React.JSX.Element {
|
|||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
setRange(undefined);
|
setRange("24h");
|
||||||
|
setOriginFilter("all");
|
||||||
setJailFilter("");
|
setJailFilter("");
|
||||||
setIpFilter("");
|
setIpFilter("");
|
||||||
setAppliedQuery({ page_size: PAGE_SIZE });
|
setAppliedQuery({ page_size: PAGE_SIZE });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* geo-location details.
|
* geo-location details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
SearchRegular,
|
SearchRegular,
|
||||||
StopRegular,
|
StopRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { Link } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||||
import type { JailSummary } from "../types/jail";
|
import type { JailSummary } from "../types/jail";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
@@ -151,77 +151,88 @@ function fmtSeconds(s: number): string {
|
|||||||
return `${String(Math.round(s / 3600))}h`;
|
return `${String(Math.round(s / 3600))}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Jail overview columns
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const jailColumns: TableColumnDefinition<JailSummary>[] = [
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "name",
|
|
||||||
renderHeaderCell: () => "Jail",
|
|
||||||
renderCell: (j) => (
|
|
||||||
<Link to={`/jails/${encodeURIComponent(j.name)}`} style={{ textDecoration: "none" }}>
|
|
||||||
<Text style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}>
|
|
||||||
{j.name}
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "status",
|
|
||||||
renderHeaderCell: () => "Status",
|
|
||||||
renderCell: (j) => {
|
|
||||||
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
|
||||||
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
|
||||||
return <Badge appearance="filled" color="success">running</Badge>;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "backend",
|
|
||||||
renderHeaderCell: () => "Backend",
|
|
||||||
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "banned",
|
|
||||||
renderHeaderCell: () => "Banned",
|
|
||||||
renderCell: (j) => (
|
|
||||||
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "failed",
|
|
||||||
renderHeaderCell: () => "Failed",
|
|
||||||
renderCell: (j) => (
|
|
||||||
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "findTime",
|
|
||||||
renderHeaderCell: () => "Find Time",
|
|
||||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "banTime",
|
|
||||||
renderHeaderCell: () => "Ban Time",
|
|
||||||
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
|
||||||
}),
|
|
||||||
createTableColumn<JailSummary>({
|
|
||||||
columnId: "maxRetry",
|
|
||||||
renderHeaderCell: () => "Max Retry",
|
|
||||||
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sub-component: Jail overview section
|
// Sub-component: Jail overview section
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function JailOverviewSection(): React.JSX.Element {
|
function JailOverviewSection(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||||
useJails();
|
useJails();
|
||||||
const [opError, setOpError] = useState<string | null>(null);
|
const [opError, setOpError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const jailColumns = useMemo<TableColumnDefinition<JailSummary>[]>(
|
||||||
|
() => [
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "name",
|
||||||
|
renderHeaderCell: () => "Jail",
|
||||||
|
renderCell: (j) => (
|
||||||
|
<Button
|
||||||
|
appearance="transparent"
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 0, minWidth: 0, justifyContent: "flex-start" }}
|
||||||
|
onClick={() =>
|
||||||
|
navigate("/config", {
|
||||||
|
state: { tab: "jails", jail: j.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{ fontFamily: "Consolas, 'Courier New', monospace", fontSize: "0.85rem" }}
|
||||||
|
>
|
||||||
|
{j.name}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "status",
|
||||||
|
renderHeaderCell: () => "Status",
|
||||||
|
renderCell: (j) => {
|
||||||
|
if (!j.running) return <Badge appearance="filled" color="danger">stopped</Badge>;
|
||||||
|
if (j.idle) return <Badge appearance="filled" color="warning">idle</Badge>;
|
||||||
|
return <Badge appearance="filled" color="success">running</Badge>;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "backend",
|
||||||
|
renderHeaderCell: () => "Backend",
|
||||||
|
renderCell: (j) => <Text size={200}>{j.backend}</Text>,
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "banned",
|
||||||
|
renderHeaderCell: () => "Banned",
|
||||||
|
renderCell: (j) => (
|
||||||
|
<Text size={200}>{j.status ? String(j.status.currently_banned) : "—"}</Text>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "failed",
|
||||||
|
renderHeaderCell: () => "Failed",
|
||||||
|
renderCell: (j) => (
|
||||||
|
<Text size={200}>{j.status ? String(j.status.currently_failed) : "—"}</Text>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "findTime",
|
||||||
|
renderHeaderCell: () => "Find Time",
|
||||||
|
renderCell: (j) => <Text size={200}>{fmtSeconds(j.find_time)}</Text>,
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "banTime",
|
||||||
|
renderHeaderCell: () => "Ban Time",
|
||||||
|
renderCell: (j) => <Text size={200}>{fmtSeconds(j.ban_time)}</Text>,
|
||||||
|
}),
|
||||||
|
createTableColumn<JailSummary>({
|
||||||
|
columnId: "maxRetry",
|
||||||
|
renderHeaderCell: () => "Max Retry",
|
||||||
|
renderCell: (j) => <Text size={200}>{String(j.max_retry)}</Text>,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
const handle = (fn: () => Promise<void>): void => {
|
const handle = (fn: () => Promise<void>): void => {
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
fn().catch((err: unknown) => {
|
fn().catch((err: unknown) => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
MessageBarBody,
|
MessageBarBody,
|
||||||
Select,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -22,19 +21,17 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
Text,
|
Text,
|
||||||
Toolbar,
|
|
||||||
ToolbarButton,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
tokens,
|
tokens,
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
import { ArrowCounterclockwiseRegular, DismissRegular } from "@fluentui/react-icons";
|
||||||
|
import { DashboardFilterBar } from "../components/DashboardFilterBar";
|
||||||
import { WorldMap } from "../components/WorldMap";
|
import { WorldMap } from "../components/WorldMap";
|
||||||
import { useMapData } from "../hooks/useMapData";
|
import { useMapData } from "../hooks/useMapData";
|
||||||
import { fetchMapColorThresholds } from "../api/config";
|
import { fetchMapColorThresholds } from "../api/config";
|
||||||
import type { TimeRange } from "../types/map";
|
import type { TimeRange } from "../types/map";
|
||||||
import type { BanOriginFilter } from "../types/ban";
|
import type { BanOriginFilter } from "../types/ban";
|
||||||
import { BAN_ORIGIN_FILTER_LABELS } from "../types/ban";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -56,34 +53,23 @@ const useStyles = makeStyles({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: tokens.spacingHorizontalM,
|
gap: tokens.spacingHorizontalM,
|
||||||
},
|
},
|
||||||
filterBar: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: tokens.spacingHorizontalM,
|
|
||||||
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
|
|
||||||
background: tokens.colorNeutralBackground3,
|
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
|
||||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
|
||||||
},
|
|
||||||
tableWrapper: {
|
tableWrapper: {
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
maxHeight: "420px",
|
maxHeight: "420px",
|
||||||
borderRadius: tokens.borderRadiusMedium,
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||||
},
|
},
|
||||||
|
filterBar: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: tokens.spacingHorizontalM,
|
||||||
|
padding: tokens.spacingVerticalS,
|
||||||
|
borderRadius: tokens.borderRadiusMedium,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Time-range options
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const TIME_RANGE_OPTIONS: { label: string; value: TimeRange }[] = [
|
|
||||||
{ label: "Last 24 hours", value: "24h" },
|
|
||||||
{ label: "Last 7 days", value: "7d" },
|
|
||||||
{ label: "Last 30 days", value: "30d" },
|
|
||||||
{ label: "Last 365 days", value: "365d" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MapPage
|
// MapPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -136,41 +122,20 @@ export function MapPage(): React.JSX.Element {
|
|||||||
World Map
|
World Map
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Toolbar size="small">
|
<div style={{ display: "flex", alignItems: "center", gap: tokens.spacingHorizontalM, flexWrap: "wrap" }}>
|
||||||
<Select
|
<DashboardFilterBar
|
||||||
aria-label="Time range"
|
timeRange={range}
|
||||||
value={range}
|
onTimeRangeChange={(value) => {
|
||||||
onChange={(_ev, data): void => {
|
setRange(value);
|
||||||
setRange(data.value as TimeRange);
|
|
||||||
setSelectedCountry(null);
|
setSelectedCountry(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
originFilter={originFilter}
|
||||||
>
|
onOriginFilterChange={(value) => {
|
||||||
{TIME_RANGE_OPTIONS.map((o) => (
|
setOriginFilter(value);
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Origin filter */}
|
|
||||||
<Select
|
|
||||||
aria-label="Origin filter"
|
|
||||||
value={originFilter}
|
|
||||||
onChange={(_ev, data): void => {
|
|
||||||
setOriginFilter(data.value as BanOriginFilter);
|
|
||||||
setSelectedCountry(null);
|
setSelectedCountry(null);
|
||||||
}}
|
}}
|
||||||
size="small"
|
/>
|
||||||
>
|
<Button
|
||||||
{(["all", "blocklist", "selfblock"] as BanOriginFilter[]).map((f) => (
|
|
||||||
<option key={f} value={f}>
|
|
||||||
{BAN_ORIGIN_FILTER_LABELS[f]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<ArrowCounterclockwiseRegular />}
|
icon={<ArrowCounterclockwiseRegular />}
|
||||||
onClick={(): void => {
|
onClick={(): void => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -178,7 +143,7 @@ export function MapPage(): React.JSX.Element {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
/>
|
/>
|
||||||
</Toolbar>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------------------------------------------------------------- */}
|
{/* ---------------------------------------------------------------- */}
|
||||||
@@ -202,6 +167,7 @@ export function MapPage(): React.JSX.Element {
|
|||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<WorldMap
|
<WorldMap
|
||||||
countries={countries}
|
countries={countries}
|
||||||
|
countryNames={countryNames}
|
||||||
selectedCountry={selectedCountry}
|
selectedCountry={selectedCountry}
|
||||||
onSelectCountry={setSelectedCountry}
|
onSelectCountry={setSelectedCountry}
|
||||||
thresholdLow={thresholdLow}
|
thresholdLow={thresholdLow}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { ConfigPage } from "../ConfigPage";
|
|||||||
|
|
||||||
// Mock all tab components to avoid deep render trees and API calls.
|
// Mock all tab components to avoid deep render trees and API calls.
|
||||||
vi.mock("../../components/config", () => ({
|
vi.mock("../../components/config", () => ({
|
||||||
JailsTab: () => <div data-testid="jails-tab">JailsTab</div>,
|
JailsTab: ({ initialJail }: { initialJail?: string }) => (
|
||||||
|
<div data-testid="jails-tab" data-initial-jail={initialJail}>
|
||||||
|
JailsTab
|
||||||
|
</div>
|
||||||
|
),
|
||||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||||
@@ -53,4 +57,22 @@ describe("ConfigPage", () => {
|
|||||||
renderPage();
|
renderPage();
|
||||||
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selects the Jails tab based on location state", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter
|
||||||
|
initialEntries={[
|
||||||
|
{ pathname: "/config", state: { tab: "jails", jail: "sshd" } },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<ConfigPage />
|
||||||
|
</FluentProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const jailsTab = screen.getByTestId("jails-tab");
|
||||||
|
expect(jailsTab).toBeInTheDocument();
|
||||||
|
expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
58
frontend/src/pages/__tests__/HistoryPage.test.tsx
Normal file
58
frontend/src/pages/__tests__/HistoryPage.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { HistoryPage } from "../HistoryPage";
|
||||||
|
|
||||||
|
let lastQuery: Record<string, unknown> | null = null;
|
||||||
|
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
|
||||||
|
lastQuery = query;
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
setPage: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../hooks/useHistory", () => ({
|
||||||
|
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
|
||||||
|
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../components/WorldMap", () => ({
|
||||||
|
WorldMap: () => <div data-testid="world-map" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/config", () => ({
|
||||||
|
fetchMapColorThresholds: async () => ({
|
||||||
|
threshold_low: 10,
|
||||||
|
threshold_medium: 50,
|
||||||
|
threshold_high: 100,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HistoryPage", () => {
|
||||||
|
it("renders DashboardFilterBar and applies origin+range filters", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<HistoryPage />
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial load should include the default query.
|
||||||
|
expect(lastQuery).toEqual({ page_size: 50 });
|
||||||
|
|
||||||
|
// Change the time-range and origin filter, then apply.
|
||||||
|
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||||
|
await user.click(screen.getByRole("button", { name: /Apply/i }));
|
||||||
|
|
||||||
|
expect(lastQuery).toMatchObject({ range: "7d", origin: "blocklist" });
|
||||||
|
});
|
||||||
|
});
|
||||||
74
frontend/src/pages/__tests__/JailsPage.test.tsx
Normal file
74
frontend/src/pages/__tests__/JailsPage.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { JailsPage } from "../JailsPage";
|
||||||
|
import type { JailSummary } from "../../types/jail";
|
||||||
|
|
||||||
|
const mockNavigate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", async () => {
|
||||||
|
const actual = (await vi.importActual<typeof import("react-router-dom")>(
|
||||||
|
"react-router-dom",
|
||||||
|
)) as unknown as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => mockNavigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../hooks/useJails", () => ({
|
||||||
|
useJails: () => ({
|
||||||
|
jails: [
|
||||||
|
{
|
||||||
|
name: "sshd",
|
||||||
|
enabled: true,
|
||||||
|
running: true,
|
||||||
|
idle: false,
|
||||||
|
backend: "systemd",
|
||||||
|
find_time: 600,
|
||||||
|
ban_time: 3600,
|
||||||
|
max_retry: 5,
|
||||||
|
status: {
|
||||||
|
currently_banned: 1,
|
||||||
|
total_banned: 10,
|
||||||
|
currently_failed: 0,
|
||||||
|
total_failed: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as JailSummary[],
|
||||||
|
total: 1,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
startJail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
stopJail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setIdle: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reloadJail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reloadAll: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<JailsPage />
|
||||||
|
</FluentProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("JailsPage", () => {
|
||||||
|
it("navigates to Configuration → Jails when a jail is clicked", async () => {
|
||||||
|
renderPage();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
await user.click(screen.getByText("sshd"));
|
||||||
|
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith("/config", {
|
||||||
|
state: { tab: "jails", jail: "sshd" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/pages/__tests__/MapPage.test.tsx
Normal file
67
frontend/src/pages/__tests__/MapPage.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { MapPage } from "../MapPage";
|
||||||
|
|
||||||
|
const mockFetchMapColorThresholds = vi.fn(async () => ({
|
||||||
|
threshold_low: 10,
|
||||||
|
threshold_medium: 50,
|
||||||
|
threshold_high: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let lastArgs: { range: string; origin: string } = { range: "", origin: "" };
|
||||||
|
const mockUseMapData = vi.fn((range: string, origin: string) => {
|
||||||
|
lastArgs = { range, origin };
|
||||||
|
return {
|
||||||
|
countries: {},
|
||||||
|
countryNames: {},
|
||||||
|
bans: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refresh: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../hooks/useMapData", () => ({
|
||||||
|
useMapData: (range: string, origin: string) => mockUseMapData(range, origin),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/config", async () => ({
|
||||||
|
fetchMapColorThresholds: mockFetchMapColorThresholds,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockWorldMap = vi.fn((_props: unknown) => <div data-testid="world-map" />);
|
||||||
|
vi.mock("../components/WorldMap", () => ({
|
||||||
|
WorldMap: (props: unknown) => {
|
||||||
|
mockWorldMap(props);
|
||||||
|
return <div data-testid="world-map" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("MapPage", () => {
|
||||||
|
it("renders DashboardFilterBar and updates data when filters change", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<MapPage />
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial load should call useMapData with default filters.
|
||||||
|
expect(lastArgs).toEqual({ range: "24h", origin: "all" });
|
||||||
|
|
||||||
|
// Map should receive country names from the hook so tooltips can show human-readable labels.
|
||||||
|
expect(mockWorldMap).toHaveBeenCalled();
|
||||||
|
const firstCallArgs = mockWorldMap.mock.calls[0]?.[0];
|
||||||
|
expect(firstCallArgs).toMatchObject({ countryNames: {} });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
|
||||||
|
expect(lastArgs.range).toBe("7d");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
|
||||||
|
expect(lastArgs.origin).toBe("blocklist");
|
||||||
|
});
|
||||||
|
});
|
||||||
83
frontend/src/pages/__tests__/SetupPage.test.tsx
Normal file
83
frontend/src/pages/__tests__/SetupPage.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
import { SetupPage } from "../SetupPage";
|
||||||
|
|
||||||
|
// Mock the setup API so tests never hit a real network.
|
||||||
|
vi.mock("../../api/setup", () => ({
|
||||||
|
getSetupStatus: vi.fn(),
|
||||||
|
submitSetup: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getSetupStatus } from "../../api/setup";
|
||||||
|
|
||||||
|
const mockedGetSetupStatus = vi.mocked(getSetupStatus);
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<MemoryRouter initialEntries={["/setup"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={<div data-testid="login-page">Login</div>}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</FluentProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SetupPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a full-screen spinner while the setup status check is in flight", () => {
|
||||||
|
// getSetupStatus never resolves — spinner should be visible immediately.
|
||||||
|
mockedGetSetupStatus.mockReturnValue(new Promise(() => {}));
|
||||||
|
renderPage();
|
||||||
|
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||||
|
// Form should NOT be visible yet.
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: /bangui setup/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the setup form once the status check resolves (not complete)", async () => {
|
||||||
|
// Task 0.4: form must not flash before the check resolves.
|
||||||
|
mockedGetSetupStatus.mockResolvedValue({ completed: false });
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /bangui setup/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// Spinner should be gone.
|
||||||
|
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when setup is already complete", async () => {
|
||||||
|
mockedGetSetupStatus.mockResolvedValue({ completed: true });
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("login-page")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the form and logs a warning when the status check fails", async () => {
|
||||||
|
// Task 0.4: catch block must log a warning and keep the form visible.
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
mockedGetSetupStatus.mockRejectedValue(new Error("Connection refused"));
|
||||||
|
renderPage();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /bangui setup/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -661,6 +661,8 @@ export interface ServiceStatusResponse {
|
|||||||
online: boolean;
|
online: boolean;
|
||||||
/** fail2ban version string, or null when offline. */
|
/** fail2ban version string, or null when offline. */
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
/** BanGUI application version (from the API). */
|
||||||
|
bangui_version: string;
|
||||||
/** Number of currently active jails. */
|
/** Number of currently active jails. */
|
||||||
jail_count: number;
|
jail_count: number;
|
||||||
/** Aggregated current ban count across all jails. */
|
/** Aggregated current ban count across all jails. */
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ export interface IpDetailResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Query parameters supported by GET /api/history */
|
/** Query parameters supported by GET /api/history */
|
||||||
|
import type { BanOriginFilter } from "./ban";
|
||||||
|
|
||||||
export interface HistoryQuery {
|
export interface HistoryQuery {
|
||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
|
origin?: BanOriginFilter;
|
||||||
jail?: string;
|
jail?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -21,4 +21,6 @@ export interface ServerStatus {
|
|||||||
/** Response shape for ``GET /api/dashboard/status``. */
|
/** Response shape for ``GET /api/dashboard/status``. */
|
||||||
export interface ServerStatusResponse {
|
export interface ServerStatusResponse {
|
||||||
status: ServerStatus;
|
status: ServerStatus;
|
||||||
|
/** BanGUI application version (from the API). */
|
||||||
|
bangui_version: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client-side cryptography utilities.
|
|
||||||
*
|
|
||||||
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the SHA-256 hex digest of `input`.
|
|
||||||
*
|
|
||||||
* Hashing passwords before transmission means the plaintext never leaves the
|
|
||||||
* browser, even when HTTPS is not enforced in a development environment.
|
|
||||||
* The backend then applies bcrypt on top of the received hash.
|
|
||||||
*
|
|
||||||
* @param input - The string to hash (e.g. the master password).
|
|
||||||
* @returns Lowercase hex-encoded SHA-256 digest.
|
|
||||||
*/
|
|
||||||
export async function sha256Hex(input: string): Promise<string> {
|
|
||||||
const data = new TextEncoder().encode(input);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
return Array.from(new Uint8Array(hashBuffer))
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
3
frontend/src/vite-env.d.ts
vendored
3
frontend/src/vite-env.d.ts
vendored
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** BanGUI application version — injected at build time via Vite define. */
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
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 appVersion = readFileSync(
|
||||||
|
resolve(__dirname, "../Docker/VERSION"),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.replace(/^v/, "");
|
||||||
|
|
||||||
// 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 Docker/VERSION. */
|
||||||
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
},
|
||||||
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"),
|
||||||
|
|||||||
10
pytest.ini
Normal file
10
pytest.ini
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[pytest]
|
||||||
|
# Ensure pytest-asyncio is in auto mode for async tests without explicit markers.
|
||||||
|
asyncio_mode = auto
|
||||||
|
|
||||||
|
# Run the backend test suite from the repository root.
|
||||||
|
testpaths = backend/tests
|
||||||
|
pythonpath = backend
|
||||||
|
|
||||||
|
# Keep coverage output consistent with backend/pyproject.toml settings.
|
||||||
|
addopts = --cov=backend/app --cov-report=term-missing
|
||||||
Reference in New Issue
Block a user