diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..65bbdf2 --- /dev/null +++ b/.containerignore @@ -0,0 +1,49 @@ +# ────────────────────────────────────────────── +# BanGUI — .dockerignore / .containerignore +# Works with both Docker and Podman. +# ────────────────────────────────────────────── + +# Version control +.git +.gitignore + +# Virtual environments +.venv +venv +env + +# IDE / editor +.vscode +.idea +*.swp +*.swo +*~ + +# Python caches +__pycache__ +*.pyc +*.pyo +.mypy_cache +.ruff_cache +.pytest_cache +.coverage +htmlcov + +# Node +frontend/node_modules +frontend/.vite + +# Build artifacts +dist +build +*.egg-info + +# Documentation (keep README at root if needed) +Docs + +# Tests (not needed in production images) +backend/tests + +# OS files +.DS_Store +Thumbs.db diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..65bbdf2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# ────────────────────────────────────────────── +# BanGUI — .dockerignore / .containerignore +# Works with both Docker and Podman. +# ────────────────────────────────────────────── + +# Version control +.git +.gitignore + +# Virtual environments +.venv +venv +env + +# IDE / editor +.vscode +.idea +*.swp +*.swo +*~ + +# Python caches +__pycache__ +*.pyc +*.pyo +.mypy_cache +.ruff_cache +.pytest_cache +.coverage +htmlcov + +# Node +frontend/node_modules +frontend/.vite + +# Build artifacts +dist +build +*.egg-info + +# Documentation (keep README at root if needed) +Docs + +# Tests (not needed in production images) +backend/tests + +# OS files +.DS_Store +Thumbs.db diff --git a/.github/agents/ProcessTasks.agent.md b/.github/agents/ProcessTasks.agent.md new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fdb02a --- /dev/null +++ b/.gitignore @@ -0,0 +1,113 @@ +# ───────────────────────────────────────────── +# BanGUI — root .gitignore +# ───────────────────────────────────────────── + +# ── Python ──────────────────────────────────── +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +.Python + +# Virtualenvs +.venv/ +venv/ +env/ +ENV/ +.python-version + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg +MANIFEST + +# Testing & coverage +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.tox/ +nosetests.xml +coverage.xml +*.cover + +# Type checkers & linters +.mypy_cache/ +.ruff_cache/ +.dmypy.json +dmypy.json +pyrightconfig.json +.pytype/ + +# ── Node / Frontend ─────────────────────────── +node_modules/ +.pnp +.pnp.js + +# Build output +frontend/dist/ +frontend/.vite/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# ── Secrets / Environment ───────────────────── +.env +.env.* +!.env.example +*.pem +secrets.json + +# ── Databases ───────────────────────────────── +*.sqlite3 +*.db +*.db-shm +*.db-wal + +# ── OS artefacts ────────────────────────────── +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# ── Editor / IDE ────────────────────────────── +.idea/ +*.iml +*.sublime-project +*.sublime-workspace +.vscode/settings.json +.vscode/launch.json +.vscode/*.log +*.swp +*.swo +*~ + +# ── Docker dev config ───────────────────────── +# Ignore auto-generated linuxserver/fail2ban config files, +# but track our custom filter, jail, and documentation. +Docker/fail2ban-dev-config/** +!Docker/fail2ban-dev-config/README.md +!Docker/fail2ban-dev-config/fail2ban/ +!Docker/fail2ban-dev-config/fail2ban/filter.d/ +!Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf +!Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-access.conf +!Docker/fail2ban-dev-config/fail2ban/jail.d/ +!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf +!Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-access.conf +!Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf + +# ── Misc ────────────────────────────────────── +*.log +*.tmp +*.bak +*.orig diff --git a/Docker/Dockerfile.backend b/Docker/Dockerfile.backend new file mode 100644 index 0000000..e097e67 --- /dev/null +++ b/Docker/Dockerfile.backend @@ -0,0 +1,69 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Backend image (Python / FastAPI) +# +# Compatible with Docker and Podman. +# Build context must be the project root. +# +# Usage: +# docker build -t bangui-backend -f Docker/Dockerfile.backend . +# podman build -t bangui-backend -f Docker/Dockerfile.backend . +# ────────────────────────────────────────────────────────────── + +# ── Stage 1: build dependencies ────────────────────────────── +FROM python:3.12-slim AS builder + +WORKDIR /build + +# Install build-time system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/pyproject.toml /build/ + +# Install Python dependencies into a virtual-env so we can copy it cleanly +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir . + +# ── Stage 2: runtime image ─────────────────────────────────── +FROM python:3.12-slim AS runtime + +LABEL maintainer="BanGUI" \ + description="BanGUI backend — fail2ban web management API" + +# Non-root user for security +RUN groupadd --gid 1000 bangui \ + && useradd --uid 1000 --gid bangui --shell /bin/bash --create-home bangui + +WORKDIR /app + +# Copy the pre-built virtual-env +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Copy application source +COPY backend/app /app/app +COPY fail2ban-master /app/fail2ban-master + +# Data directory for the SQLite database +RUN mkdir -p /data && chown bangui:bangui /data +VOLUME ["/data"] + +# Default environment values (override at runtime) +ENV BANGUI_DATABASE_PATH="/data/bangui.db" \ + BANGUI_FAIL2BAN_SOCKET="/var/run/fail2ban/fail2ban.sock" \ + BANGUI_LOG_LEVEL="info" + +EXPOSE 8000 + +USER bangui + +# Health-check using the built-in health endpoint +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1 + +CMD ["uvicorn", "app.main:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Docker/Dockerfile.frontend b/Docker/Dockerfile.frontend new file mode 100644 index 0000000..0f2658c --- /dev/null +++ b/Docker/Dockerfile.frontend @@ -0,0 +1,45 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Frontend image (React / Vite → nginx) +# +# Compatible with Docker and Podman. +# Build context must be the project root. +# +# Usage: +# docker build -t bangui-frontend -f Docker/Dockerfile.frontend . +# podman build -t bangui-frontend -f Docker/Dockerfile.frontend . +# ────────────────────────────────────────────────────────────── + +# ── Stage 1: install & build ───────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /build + +# Install dependencies first (layer caching) +COPY frontend/package.json frontend/package-lock.json* /build/ +RUN npm ci --ignore-scripts + +# Copy source and build +COPY frontend/ /build/ +RUN npm run build + +# ── Stage 2: serve with nginx ──────────────────────────────── +FROM nginx:1.27-alpine AS runtime + +LABEL maintainer="BanGUI" \ + description="BanGUI frontend — fail2ban web management UI" + +# Remove default nginx content +RUN rm -rf /usr/share/nginx/html/* + +# Copy built assets +COPY --from=builder /build/dist /usr/share/nginx/html + +# Custom nginx config for SPA routing + API reverse proxy +COPY Docker/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO /dev/null http://localhost:80/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Docker/check_ban_status.sh b/Docker/check_ban_status.sh new file mode 100644 index 0000000..74a10f1 --- /dev/null +++ b/Docker/check_ban_status.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# check_ban_status.sh +# +# Queries the bangui-sim jail inside the running fail2ban +# container and optionally unbans a specific IP. +# +# Usage: +# bash Docker/check_ban_status.sh +# bash Docker/check_ban_status.sh --unban 192.168.100.99 +# +# Requirements: +# The bangui-fail2ban-dev container must be running. +# (docker compose -f Docker/compose.debug.yml up -d fail2ban) +# ────────────────────────────────────────────────────────────── + +set -euo pipefail + +readonly CONTAINER="bangui-fail2ban-dev" +readonly JAIL="bangui-sim" + +# ── Helper: run a fail2ban-client command inside the container ─ +f2b() { + docker exec "${CONTAINER}" fail2ban-client "$@" +} + +# ── Parse arguments ─────────────────────────────────────────── +UNBAN_IP="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --unban) + if [[ -z "${2:-}" ]]; then + echo "ERROR: --unban requires an IP address argument." >&2 + exit 1 + fi + UNBAN_IP="$2" + shift 2 + ;; + *) + echo "ERROR: Unknown argument: '$1'" >&2 + echo "Usage: $0 [--unban ]" >&2 + exit 1 + ;; + esac +done + +# ── Unban mode ──────────────────────────────────────────────── +if [[ -n "${UNBAN_IP}" ]]; then + echo "Unbanning ${UNBAN_IP} from jail '${JAIL}' ..." + f2b set "${JAIL}" unbanip "${UNBAN_IP}" + echo "Done. '${UNBAN_IP}' has been removed from the ban list." + echo "" +fi + +# ── Jail status ─────────────────────────────────────────────── +echo "═══════════════════════════════════════════" +echo " Jail status: ${JAIL}" +echo "═══════════════════════════════════════════" +f2b status "${JAIL}" + +# ── Banned IPs with timestamps ──────────────────────────────── +echo "" +echo "═══════════════════════════════════════════" +echo " Banned IPs with timestamps: ${JAIL}" +echo "═══════════════════════════════════════════" +f2b get "${JAIL}" banip --with-time || echo "(no IPs currently banned)" diff --git a/Docker/compose.debug.yml b/Docker/compose.debug.yml new file mode 100644 index 0000000..84c9eb2 --- /dev/null +++ b/Docker/compose.debug.yml @@ -0,0 +1,123 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Debug / Development Compose +# +# Compatible with: +# docker compose -f Docker/compose.debug.yml up +# podman compose -f Docker/compose.debug.yml up +# podman-compose -f Docker/compose.debug.yml up +# +# Features: +# - Source code mounted as volumes (hot-reload) +# - Uvicorn --reload for backend auto-restart +# - Vite dev server for frontend with HMR +# - Ports exposed on host for direct access +# - Debug log level enabled +# ────────────────────────────────────────────────────────────── + +name: bangui-dev + +services: + # ── fail2ban ───────────────────────────────────────────────── + fail2ban: + image: lscr.io/linuxserver/fail2ban:latest + container_name: bangui-fail2ban-dev + restart: unless-stopped + cap_add: + - NET_ADMIN + - NET_RAW + network_mode: host + environment: + TZ: "${BANGUI_TIMEZONE:-UTC}" + PUID: 0 + PGID: 0 + volumes: + - ./fail2ban-dev-config:/config + - fail2ban-dev-run:/var/run/fail2ban + - /var/log:/var/log:ro + - ./logs:/remotelogs/bangui + healthcheck: + test: ["CMD", "fail2ban-client", "ping"] + interval: 15s + timeout: 5s + start_period: 15s + retries: 3 + + # ── Backend (FastAPI + uvicorn with --reload) ─────────────── + backend: + build: + context: .. + dockerfile: Docker/Dockerfile.backend + target: runtime + container_name: bangui-backend-dev + restart: unless-stopped + user: "0" + depends_on: + fail2ban: + condition: service_healthy + environment: + BANGUI_DATABASE_PATH: "/data/bangui.db" + BANGUI_FAIL2BAN_SOCKET: "/var/run/fail2ban/fail2ban.sock" + BANGUI_FAIL2BAN_CONFIG_DIR: "/config/fail2ban" + BANGUI_LOG_LEVEL: "debug" + BANGUI_SESSION_SECRET: "${BANGUI_SESSION_SECRET:-dev-secret-do-not-use-in-production}" + BANGUI_TIMEZONE: "${BANGUI_TIMEZONE:-UTC}" + volumes: + - ../backend/app:/app/app:z + - ../fail2ban-master:/app/fail2ban-master:ro,z + - bangui-dev-data:/data + - fail2ban-dev-run:/var/run/fail2ban:ro + - ./fail2ban-dev-config:/config:rw + ports: + - "${BANGUI_BACKEND_PORT:-8000}:8000" + command: + [ + "uvicorn", "app.main:create_app", "--factory", + "--host", "0.0.0.0", "--port", "8000", + "--reload", "--reload-dir", "/app/app" + ] + healthcheck: + test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/health\", timeout=4)'"] + interval: 15s + timeout: 5s + start_period: 45s + retries: 5 + networks: + - bangui-dev-net + + # ── Frontend (Vite dev server with HMR) ───────────────────── + frontend: + image: node:22-alpine + container_name: bangui-frontend-dev + restart: unless-stopped + working_dir: /app + environment: + NODE_ENV: development + volumes: + - ../frontend:/app:z + - frontend-node-modules:/app/node_modules + ports: + - "${BANGUI_FRONTEND_PORT:-5173}:5173" + command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"] + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:5173/"] + interval: 15s + timeout: 5s + start_period: 30s + retries: 5 + networks: + - bangui-dev-net + +volumes: + bangui-dev-data: + driver: local + frontend-node-modules: + driver: local + fail2ban-dev-run: + driver: local + +networks: + bangui-dev-net: + driver: bridge diff --git a/Docker/compose.prod.yml b/Docker/compose.prod.yml new file mode 100644 index 0000000..1348282 --- /dev/null +++ b/Docker/compose.prod.yml @@ -0,0 +1,104 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Production Compose +# +# Compatible with: +# docker compose -f Docker/compose.prod.yml up -d +# podman compose -f Docker/compose.prod.yml up -d +# podman-compose -f Docker/compose.prod.yml up -d +# +# Prerequisites: +# Create a .env file at the project root (or pass --env-file): +# BANGUI_SESSION_SECRET= +# ────────────────────────────────────────────────────────────── + +name: bangui + +services: + # ── fail2ban ───────────────────────────────────────────────── + fail2ban: + image: lscr.io/linuxserver/fail2ban:latest + container_name: bangui-fail2ban + restart: unless-stopped + cap_add: + - NET_ADMIN + - NET_RAW + network_mode: host + environment: + TZ: "${BANGUI_TIMEZONE:-UTC}" + PUID: 0 + PGID: 0 + volumes: + - fail2ban-config:/config + - fail2ban-run:/var/run/fail2ban + - /var/log:/var/log:ro + healthcheck: + test: ["CMD", "fail2ban-client", "ping"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + # ── Backend (FastAPI + uvicorn) ───────────────────────────── + backend: + build: + context: .. + dockerfile: Docker/Dockerfile.backend + container_name: bangui-backend + restart: unless-stopped + depends_on: + fail2ban: + condition: service_healthy + environment: + 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: + - bangui-data:/data + - fail2ban-run:/var/run/fail2ban:ro + - fail2ban-config:/config:rw + expose: + - "8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + networks: + - bangui-net + + # ── Frontend (nginx serving built SPA + API proxy) ────────── + frontend: + build: + context: .. + dockerfile: Docker/Dockerfile.frontend + container_name: bangui-frontend + restart: unless-stopped + ports: + - "${BANGUI_PORT:-8080}:80" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:80/"] + interval: 30s + timeout: 5s + start_period: 5s + retries: 3 + networks: + - bangui-net + +volumes: + bangui-data: + driver: local + fail2ban-config: + driver: local + fail2ban-run: + driver: local + +networks: + bangui-net: + driver: bridge diff --git a/Docker/fail2ban-dev-config/README.md b/Docker/fail2ban-dev-config/README.md new file mode 100644 index 0000000..8d41b71 --- /dev/null +++ b/Docker/fail2ban-dev-config/README.md @@ -0,0 +1,142 @@ +# BanGUI — Fail2ban Dev Test Environment + +This directory contains the fail2ban configuration and supporting scripts for a +self-contained development test environment. A simulation script writes fake +authentication-failure log lines, fail2ban detects them via the `bangui-sim` +jail, and bans the offending IP — giving a fully reproducible ban/unban cycle +without a real service. + +--- + +## Prerequisites + +- Docker or Podman installed and running. +- `docker compose` (v2) or `podman-compose` available on the `PATH`. +- The repo checked out; all commands run from the **repo root**. + +--- + +## Quick Start + +### 1 — Start the fail2ban container + +```bash +docker compose -f Docker/compose.debug.yml up -d fail2ban +# or: make up (starts the full dev stack) +``` + +Wait ~15 s for the health-check to pass (`docker ps` shows `healthy`). + +### 2 — Run the login-failure simulation + +```bash +bash Docker/simulate_failed_logins.sh +``` + +Default: writes **5** failure lines for IP `192.168.100.99` to +`Docker/logs/auth.log`. +Optional overrides: + +```bash +bash Docker/simulate_failed_logins.sh +# e.g. bash Docker/simulate_failed_logins.sh 10 203.0.113.42 +``` + +### 3 — Verify the IP was banned + +```bash +bash Docker/check_ban_status.sh +``` + +The output shows the current jail counters and the list of banned IPs with their +ban expiry timestamps. + +### 4 — Unban and re-test + +```bash +bash Docker/check_ban_status.sh --unban 192.168.100.99 +``` + +### One-command smoke test (Makefile shortcut) + +```bash +make dev-ban-test +``` + +Chains steps 1–3 automatically with appropriate sleep intervals. + +--- + +## Configuration Reference + +| File | Purpose | +|------|---------| +| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines | +| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` | +| `Docker/logs/auth.log` | Log file written by the simulation script (host path) | + +Inside the container the log file is mounted at `/remotelogs/bangui/auth.log` +(see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`). + +To change sensitivity, edit `fail2ban/jail.d/bangui-sim.conf`: + +```ini +maxretry = 3 # failures before a ban +findtime = 120 # look-back window in seconds +bantime = 60 # ban duration in seconds +``` + +--- + +## Troubleshooting + +### Log file not detected + +The jail uses `backend = polling` for reliability inside Docker containers. +If fail2ban still does not pick up new lines, verify the volume mount in +`Docker/compose.debug.yml`: + +```yaml +- ./logs:/remotelogs/bangui +``` + +and confirm `Docker/logs/auth.log` exists after running the simulation script. + +### Filter regex mismatch + +Test the regex manually: + +```bash +docker exec bangui-fail2ban-dev \ + fail2ban-regex /remotelogs/bangui/auth.log bangui-sim +``` + +The output should show matched lines. If nothing matches, check that the log +lines match the corresponding `failregex` pattern: + +``` +# bangui-sim (auth log): +YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from +``` + +### iptables / permission errors + +The fail2ban container requires `NET_ADMIN` and `NET_RAW` capabilities and +`network_mode: host`. Both are already set in `Docker/compose.debug.yml`. If +you see iptables errors, check that the host kernel has iptables loaded: + +```bash +sudo modprobe ip_tables +``` + +### IP not banned despite enough failures + +Check whether the source IP falls inside the `ignoreip` range defined in +`fail2ban/jail.d/bangui-sim.conf`: + +```ini +ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 +``` + +The default simulation IP `192.168.100.99` is outside these ranges and will be +banned normally. diff --git a/Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf b/Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf new file mode 100644 index 0000000..275b83f --- /dev/null +++ b/Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf @@ -0,0 +1,12 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Simulated authentication failure filter +# +# Matches lines written by Docker/simulate_failed_logins.sh +# Format: bangui-auth: authentication failure from +# ────────────────────────────────────────────────────────────── + +[Definition] + +failregex = ^.* bangui-auth: authentication failure from \s*$ + +ignoreregex = diff --git a/Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf b/Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf new file mode 100644 index 0000000..59cb310 --- /dev/null +++ b/Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf @@ -0,0 +1,20 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Simulated authentication failure jail +# +# Watches Docker/logs/auth.log (mounted at /remotelogs/bangui) +# for lines produced by Docker/simulate_failed_logins.sh. +# ────────────────────────────────────────────────────────────── + +[bangui-sim] + +enabled = true +filter = bangui-sim +logpath = /remotelogs/bangui/auth.log +backend = polling +maxretry = 3 +findtime = 120 +bantime = 60 +banaction = iptables-allports + +# Never ban localhost, the Docker bridge network, or the host machine. +ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 diff --git a/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf b/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf new file mode 100644 index 0000000..1271f58 --- /dev/null +++ b/Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf @@ -0,0 +1,26 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Blocklist-import jail +# +# Dedicated jail for IPs banned via the BanGUI blocklist import +# feature. This is a manual-ban jail: it does not watch any log +# file. All bans are injected programmatically via +# fail2ban-client set blocklist-import banip +# which the BanGUI backend uses through its fail2ban socket +# client. +# ────────────────────────────────────────────────────────────── + +[blocklist-import] + +enabled = true +# No log-based detection — only manual banip commands are used. +filter = +logpath = /dev/null +backend = auto +maxretry = 1 +findtime = 1d +# Block imported IPs for one week. +bantime = 1w +banaction = iptables-allports + +# Never ban the Docker bridge network or localhost. +ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 diff --git a/Docker/nginx.conf b/Docker/nginx.conf new file mode 100644 index 0000000..5910ccf --- /dev/null +++ b/Docker/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # ── Gzip compression ───────────────────────────────────── + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + gzip_min_length 256; + + # ── API reverse proxy → backend container ───────────────── + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + + # ── Static assets with long-term caching ────────────────── + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # ── SPA fallback — serve index.html for client routes ───── + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/Docker/push.sh b/Docker/push.sh new file mode 100644 index 0000000..299d845 --- /dev/null +++ b/Docker/push.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Build and push BanGUI container images to the Gitea registry. +# +# Usage: +# ./push.sh # builds & pushes with tag "latest" +# ./push.sh v1.2.3 # builds & pushes with tag "v1.2.3" +# ./push.sh v1.2.3 --no-build # pushes existing images only +# +# Prerequisites: +# podman login git.lpl-mind.de (or: docker login git.lpl-mind.de) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +REGISTRY="git.lpl-mind.de" +NAMESPACE="lukas.pupkalipinski" +PROJECT="bangui" + +BACKEND_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/backend" +FRONTEND_IMAGE="${REGISTRY}/${NAMESPACE}/${PROJECT}/frontend" + +TAG="${1:-latest}" +SKIP_BUILD=false +if [[ "${2:-}" == "--no-build" ]]; then + SKIP_BUILD=true +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { echo -e "\n>>> $*"; } +err() { echo -e "\nERROR: $*" >&2; exit 1; } + +# Detect container engine (podman preferred, docker fallback) +if command -v podman &>/dev/null; then + ENGINE="podman" +elif command -v docker &>/dev/null; then + ENGINE="docker" +else + err "Neither podman nor docker is installed." +fi + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- +echo "============================================" +echo " BanGUI — Build & Push" +echo " Engine : ${ENGINE}" +echo " Registry : ${REGISTRY}" +echo " Tag : ${TAG}" +echo "============================================" + +if [[ "${ENGINE}" == "podman" ]]; then + if ! podman login --get-login "${REGISTRY}" &>/dev/null; then + err "Not logged in. Run:\n podman login ${REGISTRY}" + fi +fi + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- +if [[ "${SKIP_BUILD}" == false ]]; then + log "Building backend image → ${BACKEND_IMAGE}:${TAG}" + "${ENGINE}" build \ + -t "${BACKEND_IMAGE}:${TAG}" \ + -f "${SCRIPT_DIR}/Dockerfile.backend" \ + "${PROJECT_ROOT}" + + log "Building frontend image → ${FRONTEND_IMAGE}:${TAG}" + "${ENGINE}" build \ + -t "${FRONTEND_IMAGE}:${TAG}" \ + -f "${SCRIPT_DIR}/Dockerfile.frontend" \ + "${PROJECT_ROOT}" +fi + +# --------------------------------------------------------------------------- +# Push +# --------------------------------------------------------------------------- +log "Pushing ${BACKEND_IMAGE}:${TAG}" +"${ENGINE}" push "${BACKEND_IMAGE}:${TAG}" + +log "Pushing ${FRONTEND_IMAGE}:${TAG}" +"${ENGINE}" push "${FRONTEND_IMAGE}:${TAG}" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "============================================" +echo " Push complete!" +echo "" +echo " Images:" +echo " ${BACKEND_IMAGE}:${TAG}" +echo " ${FRONTEND_IMAGE}:${TAG}" +echo "" +echo " Deploy on server:" +echo " ${ENGINE} login ${REGISTRY}" +echo " ${ENGINE} compose -f Docker/compose.prod.yml pull" +echo " ${ENGINE} compose -f Docker/compose.prod.yml up -d" +echo "============================================" \ No newline at end of file diff --git a/Docker/simulate_failed_logins.sh b/Docker/simulate_failed_logins.sh new file mode 100644 index 0000000..3a01691 --- /dev/null +++ b/Docker/simulate_failed_logins.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────── +# simulate_failed_logins.sh +# +# Writes synthetic authentication-failure log lines to a file +# that matches the bangui-sim fail2ban filter. +# +# Usage: +# bash Docker/simulate_failed_logins.sh [COUNT] [SOURCE_IP] [LOG_FILE] +# +# Defaults: +# COUNT : 5 +# SOURCE_IP: 192.168.100.99 +# LOG_FILE : Docker/logs/auth.log (relative to repo root) +# +# Log line format (must match bangui-sim failregex exactly): +# YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from +# ────────────────────────────────────────────────────────────── + +set -euo pipefail + +# ── Defaults ────────────────────────────────────────────────── +readonly DEFAULT_COUNT=5 +readonly DEFAULT_IP="192.168.100.99" + +# Resolve script location so defaults work regardless of cwd. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly DEFAULT_LOG_FILE="${SCRIPT_DIR}/logs/auth.log" + +# ── Arguments ───────────────────────────────────────────────── +COUNT="${1:-${DEFAULT_COUNT}}" +SOURCE_IP="${2:-${DEFAULT_IP}}" +LOG_FILE="${3:-${DEFAULT_LOG_FILE}}" + +# ── Validate COUNT is a positive integer ────────────────────── +if ! [[ "${COUNT}" =~ ^[1-9][0-9]*$ ]]; then + echo "ERROR: COUNT must be a positive integer, got: '${COUNT}'" >&2 + exit 1 +fi + +# ── Ensure log directory exists ─────────────────────────────── +LOG_DIR="$(dirname "${LOG_FILE}")" +mkdir -p "${LOG_DIR}" + +# ── Write failure lines ─────────────────────────────────────── +echo "Writing ${COUNT} authentication-failure line(s) for ${SOURCE_IP} to ${LOG_FILE} ..." + +for ((i = 1; i <= COUNT; i++)); do + TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')" + printf '%s bangui-auth: authentication failure from %s\n' \ + "${TIMESTAMP}" "${SOURCE_IP}" >> "${LOG_FILE}" + sleep 0.5 +done + +# ── Summary ─────────────────────────────────────────────────── +echo "Done." +echo " Lines written : ${COUNT}" +echo " Source IP : ${SOURCE_IP}" +echo " Log file : ${LOG_FILE}" diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 93310c5..10a0fcc 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -123,6 +123,7 @@ backend/ │ │ └── import_log_repo.py # Import run history records │ ├── tasks/ # APScheduler background jobs │ │ ├── blocklist_import.py# Scheduled blocklist download and application +│ │ ├── geo_cache_flush.py # Periodic geo cache persistence (dirty-set flush to SQLite) │ │ └── health_check.py # Periodic fail2ban connectivity probe │ └── utils/ # Helpers, constants, shared types │ ├── fail2ban_client.py # Async wrapper around the fail2ban socket protocol @@ -151,7 +152,8 @@ The HTTP interface layer. Each router maps URL paths to handler functions. Route | `dashboard.py` | `/api/dashboard` | Server status bar data, recent bans for the dashboard | | `jails.py` | `/api/jails` | List jails, jail detail, start/stop/reload/idle controls | | `bans.py` | `/api/bans` | Ban an IP, unban an IP, unban all, list currently banned IPs | -| `config.py` | `/api/config` | Read and write fail2ban jail/filter/server configuration | +| `config.py` | `/api/config` | Read and write fail2ban jail/filter/server configuration via the socket; also serves the fail2ban log tail and service status for the Log tab | +| `file_config.py` | `/api/config` | Read and write fail2ban config files on disk (jail.d/, filter.d/, action.d/) — list, get, and overwrite raw file contents, toggle jail enabled/disabled | | `history.py` | `/api/history` | Query historical bans, per-IP timeline | | `blocklist.py` | `/api/blocklists` | CRUD blocklist sources, trigger import, view import logs | | `geo.py` | `/api/geo` | IP geolocation lookup, ASN and RIR data | @@ -167,7 +169,10 @@ The business logic layer. Services orchestrate operations, enforce rules, and co | `setup_service.py` | Validates setup input, persists initial configuration, ensures setup runs only once | | `jail_service.py` | Retrieves jail list and details from fail2ban, aggregates metrics (banned count, failure count), sends start/stop/reload/idle commands | | `ban_service.py` | Executes ban and unban commands via the fail2ban socket, queries the currently banned IP list, validates IPs before banning | -| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload | +| `config_service.py` | Reads active jail and filter configuration from fail2ban, writes configuration changes, validates regex patterns, triggers reload; reads the fail2ban log file tail and queries service status for the Log tab | +| `file_config_service.py` | Reads and writes raw fail2ban config files on disk (jail.d/, filter.d/, action.d/); lists files, reads content, overwrites files, toggles enabled/disabled | +| `config_file_service.py` | Parses jail.conf / jail.local / jail.d/* to discover inactive jails; writes .local overrides to activate or deactivate jails; triggers fail2ban reload | +| `conffile_parser.py` | Parses fail2ban `.conf` files into structured Python types (jail config, filter config, action config); also serialises back to text | | `history_service.py` | Queries the fail2ban database for historical ban records, builds per-IP timelines, computes ban counts and repeat-offender flags | | `blocklist_service.py` | Downloads blocklists via aiohttp, validates IPs/CIDRs, applies bans through fail2ban or iptables, logs import results | | `geo_service.py` | Resolves IP addresses to country, ASN, and RIR using external APIs or a local database, caches results | @@ -200,6 +205,7 @@ APScheduler background jobs that run on a schedule without user interaction. | Task | Purpose | |---|---| | `blocklist_import.py` | Downloads all enabled blocklist sources, validates entries, applies bans, records results in the import log | +| `geo_cache_flush.py` | Periodically flushes newly resolved IPs from the in-memory dirty set to the `geo_cache` SQLite table (default: every 60 seconds). GET requests populate only the in-memory cache; this task persists them without blocking any request. | | `health_check.py` | Periodically pings the fail2ban socket and updates the cached server status so the frontend always has fresh data | #### Utils (`app/utils/`) @@ -285,6 +291,8 @@ frontend/ │ │ ├── WorldMap.tsx # Country-outline map with ban counts │ │ ├── ImportLogTable.tsx # Blocklist import run history │ │ ├── ConfirmDialog.tsx # Reusable confirmation modal +│ │ ├── RequireAuth.tsx # Route guard: redirects unauthenticated users to /login +│ │ ├── SetupGuard.tsx # Route guard: redirects to /setup if setup incomplete │ │ └── ... # (additional shared components) │ ├── hooks/ # Custom React hooks (stateful logic + API calls) │ │ ├── useAuth.ts # Login state, login/logout actions @@ -300,8 +308,8 @@ frontend/ │ ├── pages/ # Route-level page components (one per route) │ │ ├── SetupPage.tsx # First-run wizard │ │ ├── LoginPage.tsx # Password prompt -│ │ ├── DashboardPage.tsx # Ban overview, status bar, access list -│ │ ├── WorldMapPage.tsx # Geographical ban map + access table +│ │ ├── DashboardPage.tsx # Ban overview, status bar +│ │ ├── WorldMapPage.tsx # Geographical ban map │ │ ├── JailsPage.tsx # Jail list, detail, controls, ban/unban │ │ ├── ConfigPage.tsx # Configuration viewer/editor │ │ ├── HistoryPage.tsx # Ban history browser @@ -325,6 +333,7 @@ frontend/ │ ├── utils/ # Pure helper functions │ │ ├── formatDate.ts # Date/time formatting with timezone support │ │ ├── formatIp.ts # IP display formatting +│ │ ├── crypto.ts # Browser-native SHA-256 helper (SubtleCrypto) │ │ └── constants.ts # Frontend constants (time presets, etc.) │ ├── App.tsx # Root: FluentProvider + BrowserRouter + routes │ ├── main.tsx # Vite entry point @@ -344,8 +353,8 @@ Top-level route components. Each page composes layout, components, and hooks to |---|---|---| | `SetupPage` | `/setup` | First-run wizard: set master password, database path, fail2ban connection, preferences | | `LoginPage` | `/login` | Single-field password prompt; redirects to requested page after success | -| `DashboardPage` | `/` | Server status bar, ban list table, access list tab, time-range selector | -| `WorldMapPage` | `/map` | World map with per-country ban counts, companion access table, country filter | +| `DashboardPage` | `/` | Server status bar, ban list table, time-range selector | +| `WorldMapPage` | `/map` | World map with per-country ban counts, country filter | | `JailsPage` | `/jails` | Jail overview list, jail detail panel, controls (start/stop/reload), ban/unban forms, IP lookup, whitelist management | | `ConfigPage` | `/config` | View and edit jail parameters, filter regex, server settings, regex tester, add log observation | | `HistoryPage` | `/history` | Browse all past bans, filter by jail/IP/time, per-IP timeline drill-down | @@ -366,6 +375,11 @@ Reusable UI building blocks. Components receive data via props, emit changes via | `RegexTester` | Side-by-side sample log + regex input with live match highlighting | | `ImportLogTable` | Table displaying blocklist import history | | `ConfirmDialog` | Reusable Fluent UI Dialog for destructive action confirmations | +| `RequireAuth` | Route guard: renders children only when authenticated; otherwise redirects to `/login?next=` | +| `SetupGuard` | Route guard: checks `GET /api/setup` on mount and redirects to `/setup` if not complete; shows a spinner while loading | +| `config/ConfigListDetail` | Reusable two-pane master/detail layout used by the Jails, Filters, and Actions config tabs. Left pane lists items with active/inactive badges (active sorted first, keyboard navigable); right pane renders the selected item's detail content. Collapses to a dropdown on narrow screens. | +| `config/RawConfigSection` | Collapsible section that lazily loads the raw text of a config file into a monospace textarea. Provides a Save button backed by a configurable save callback; shows idle/saving/saved/error feedback. Used by all three config tabs. | +| `config/AutoSaveIndicator` | Small inline indicator showing the current save state (idle, saving, saved, error) for form fields that auto-save on change. | #### Hooks (`src/hooks/`) @@ -376,7 +390,12 @@ Encapsulate all stateful logic, side effects, and API calls. Components and page | `useAuth` | Manages login state, provides `login()`, `logout()`, and `isAuthenticated` | | `useBans` | Fetches ban list for a given time range, returns `{ bans, loading, error }` | | `useJails` | Fetches jail list and individual jail detail | -| `useConfig` | Reads and writes fail2ban configuration | +| `useConfig` | Reads and writes fail2ban jail configuration via the socket-based API | +| `useFilterConfig` | Fetches and manages a single filter file's parsed configuration | +| `useActionConfig` | Fetches and manages a single action file's parsed configuration | +| `useJailFileConfig` | Fetches and manages a single jail.d config file | +| `useConfigActiveStatus` | Derives active status sets for jails, filters, and actions by correlating the live jail list with the config file lists; returns `{ activeJails, activeFilters, activeActions, loading, error, refresh }` | +| `useAutoSave` | Debounced auto-save hook: invokes a save callback after the user stops typing, tracks saving/saved/error state | | `useHistory` | Queries historical ban data with filters | | `useBlocklists` | Manages blocklist sources and import triggers | | `useServerStatus` | Polls the server status endpoint at an interval | @@ -394,7 +413,7 @@ A thin typed wrapper around `fetch`. All HTTP communication is centralised here | `dashboard.ts` | `fetchStatus()`, `fetchRecentBans()` | | `jails.ts` | `fetchJails()`, `fetchJailDetail()`, `startJail()`, `stopJail()`, `reloadJail()` | | `bans.ts` | `banIp()`, `unbanIp()`, `unbanAll()`, `fetchBannedIps()` | -| `config.ts` | `fetchConfig()`, `updateConfig()`, `testRegex()` | +| `config.ts` | Socket-based config: `fetchJailConfigs()`, `updateJailConfig()`, `testRegex()`. File-based config: `fetchJailFiles()`, `fetchJailFile()`, `writeJailFile()`, `setJailFileEnabled()`, `fetchFilterFiles()`, `fetchFilterFile()`, `writeFilterFile()`, `fetchActionFiles()`, `fetchActionFile()`, `writeActionFile()`, `reloadConfig()` | | `history.ts` | `fetchHistory()`, `fetchIpTimeline()` | | `blocklist.ts` | `fetchSources()`, `addSource()`, `removeSource()`, `triggerImport()`, `fetchImportLog()` | | `geo.ts` | `lookupIp()` | @@ -410,7 +429,8 @@ React context providers for application-wide concerns. | Provider | Purpose | |---|---| -| `AuthProvider` | Holds authentication state, wraps protected routes, redirects unauthenticated users to `/login` | +| `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()` | +| `TimezoneProvider` | Reads the configured IANA timezone from the backend and supplies it to all children via `useTimezone()` | | `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` | #### Theme (`src/theme/`) @@ -419,7 +439,14 @@ Fluent UI custom theme definitions and design token constants. No component logi #### Utils (`src/utils/`) -Pure helper functions with no React or framework dependency. Date formatting, IP display formatting, shared constants. +Pure helper functions with no React or framework dependency. Date formatting, IP display formatting, shared constants, and cryptographic utilities. + +| Utility | Purpose | +|---|---| +| `formatDate.ts` | Date/time formatting with IANA timezone support | +| `formatIp.ts` | IP address display formatting | +| `crypto.ts` | `sha256Hex(input)` — SHA-256 digest via browser-native `SubtleCrypto` API; used to hash passwords before transmission | +| `constants.ts` | Frontend constants (time presets, etc.) | --- @@ -573,6 +600,7 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas |---|---| | `settings` | Key-value store for application configuration (master password hash, fail2ban socket path, database path, timezone, session duration) | | `sessions` | Active session tokens with expiry timestamps | +| `geo_cache` | Resolved IP geolocation results (ip, country_code, country_name, asn, org, cached_at). Loaded into memory at startup via `load_cache_from_db()`; new entries are flushed back by the `geo_cache_flush` background task. | | `blocklist_sources` | Registered blocklist URLs (id, name, url, enabled, created_at, updated_at) | | `import_logs` | Record of every blocklist import run (id, source_id, timestamp, ips_imported, ips_skipped, errors, status) | @@ -593,6 +621,8 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas - Session expiry is configurable (set during setup, stored in `settings`). - The frontend `AuthProvider` checks session validity on mount and redirects to `/login` if invalid. - The backend `dependencies.py` provides an `authenticated` dependency that validates the session cookie on every protected endpoint. +- **Session validation cache** — validated session tokens are cached in memory for 10 seconds (`_session_cache` dict in `dependencies.py`) to avoid a SQLite round-trip on every request from the same browser. The cache is invalidated immediately on logout. +- **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. --- @@ -606,6 +636,7 @@ APScheduler 4.x (async mode) manages recurring background tasks. │ (async, in-process) │ ├──────────────────────┤ │ blocklist_import │ ── runs on configured schedule (default: daily 03:00) +│ geo_cache_flush │ ── runs every 60 seconds │ health_check │ ── runs every 30 seconds └──────────────────────┘ ``` diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index 8a4e444..e2932dc 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -111,6 +111,15 @@ backend/ - Group endpoints into routers by feature domain (`routers/jails.py`, `routers/bans.py`, …). - Use appropriate HTTP status codes: `201` for creation, `204` for deletion with no body, `404` for not found, etc. - Use **HTTPException** or custom exception handlers — never return error dicts manually. +- **GET endpoints are read-only — never call `db.commit()` or execute INSERT/UPDATE/DELETE inside a GET handler.** If a GET path produces side-effects (e.g., caching resolved data), that write belongs in a background task, a scheduled flush, or a separate POST endpoint. Users and HTTP caches assume GET is idempotent and non-mutating. + + ```python + # Good — pass db=None on GET so geo_service never commits + result = await geo_service.lookup_batch(ips, http_session, db=None) + + # Bad — triggers INSERT + COMMIT per IP inside a GET handler + result = await geo_service.lookup_batch(ips, http_session, db=app_db) + ``` ```python from fastapi import APIRouter, Depends, HTTPException, status @@ -156,6 +165,26 @@ class BanResponse(BaseModel): - Use `aiohttp.ClientSession` for HTTP calls, `aiosqlite` for database access. - Use `asyncio.TaskGroup` (Python 3.11+) when you need to run independent coroutines concurrently. - Long-running startup/shutdown logic goes into the **FastAPI lifespan** context manager. +- **Never call `db.commit()` inside a loop.** With aiosqlite, every commit serialises through a background thread and forces an `fsync`. N rows × 1 commit = N fsyncs. Accumulate all writes in the loop, then issue a single `db.commit()` once after the loop ends. The difference between 5,000 commits and 1 commit can be seconds vs milliseconds. + + ```python + # Good — one commit for the whole batch + for ip, info in results.items(): + await db.execute(INSERT_SQL, (ip, info.country_code, ...)) + await db.commit() # ← single fsync + + # Bad — one fsync per row + for ip, info in results.items(): + await db.execute(INSERT_SQL, (ip, info.country_code, ...)) + await db.commit() # ← fsync on every iteration + ``` +- **Prefer `executemany()` over calling `execute()` in a loop** when inserting or updating multiple rows with the same SQL template. aiosqlite passes the entire batch to SQLite in one call, reducing Python↔thread overhead on top of the single-commit saving. + + ```python + # Good + await db.executemany(INSERT_SQL, [(ip, cc, cn, asn, org) for ip, info in results.items()]) + await db.commit() + ``` - Shared resources (DB connections, HTTP sessions) are created once during startup and closed during shutdown — never inside request handlers. ```python @@ -427,4 +456,7 @@ class SqliteBanRepository: | Handle errors with custom exceptions | Use bare `except:` | | Keep routers thin, logic in services | Put business logic in routers | | Use `datetime.now(datetime.UTC)` | Use naive datetimes | -| Run ruff + mypy before committing | Push code that doesn't pass linting | \ No newline at end of file +| Run ruff + mypy before committing | Push code that doesn't pass linting | +| Keep GET endpoints read-only (no `db.commit()`) | Call `db.commit()` / INSERT inside GET handlers | +| Batch DB writes; issue one `db.commit()` after the loop | Commit inside a loop (1 fsync per row) | +| Use `executemany()` for bulk inserts | Call `execute()` + `commit()` per row in a loop | \ No newline at end of file diff --git a/Docs/Features.md b/Docs/Features.md index aba3372..d66495b 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -8,7 +8,9 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces - Displayed automatically on first launch when no configuration exists. - As long as no configuration is saved, every route redirects to the setup page. -- Once setup is complete and a configuration is saved, the setup page is never shown again and cannot be accessed. +- Once setup is complete and a configuration is saved, the setup page redirects to the login page and cannot be used again. +- The `SetupGuard` component checks the setup status on every protected route; if setup is not complete it redirects the user to `/setup`. +- **Security:** The master password is SHA-256 hashed in the browser using the native `SubtleCrypto` API before it is transmitted. The backend then bcrypt-hashes the received hash with an auto-generated salt. The plaintext password never leaves the browser and is never stored. ### Options @@ -51,12 +53,6 @@ The main landing page after login. Shows recent ban activity at a glance. - Last 30 days (month) - Last 365 days (year) -### Access List - -- A secondary view (tab or toggle) on the same page showing **all recorded accesses**, not just bans. -- Uses the same table format: time, IP address, requested URL, country, domain, subdomain. -- Shares the same time-range presets so the user can compare total traffic against banned traffic for the same period. - --- ## 4. World Map View @@ -65,21 +61,24 @@ A geographical overview of ban activity. ### Map -- A full world map rendered with country outlines only (no fill colours, no satellite imagery). -- For every country that has at least one banned IP in the selected time range, the total count is displayed centred inside that country's borders. -- Countries with zero banned IPs show no number and no label — they remain blank. +- A full world map rendered with country outlines, showing ban activity through color-coded fills (no satellite imagery). +- **Color coding:** Countries are colored based on their ban count for the selected time range: + - **Red:** High ban count (100+ bans by default) + - **Yellow:** Medium ban count (50 bans by default) + - **Green:** Low ban count (20 bans by default) + - **Transparent (no fill):** Zero bans + - Colors are smoothly interpolated between the thresholds (e.g., 35 bans shows a yellow-green blend) + - The color threshold values are configurable through the application settings +- **Interactive zoom and pan:** Users can zoom in/out using mouse wheel or touch gestures, and pan by clicking and dragging. This allows detailed inspection of densely-affected regions. Zoom controls (zoom in, zoom out, reset view) are provided as overlay buttons in the top-right corner. +- For every country that has bans, the total count is displayed centred inside that country's borders in the selected time range. +- Countries with zero banned IPs show no number and no label — they remain blank and transparent. +- Clicking a country filters the companion table below to show only bans from that country. - Time-range selector with the same quick presets: - Last 24 hours - Last 7 days - Last 30 days - Last 365 days -### Access List (Map context) - -- A companion table below or beside the map listing all accesses for the selected time range. -- Same columns as the Ban Overview tables: time, IP, URL, country, domain, subdomain. -- Selecting a country on the map filters the table to show only entries from that country. - --- ## 5. Jail Management @@ -91,6 +90,8 @@ A dedicated view for managing fail2ban jails and taking manual ban actions. - A list of all jails showing their name, current status (running / stopped / idle), backend type, and key metrics. - For each jail: number of currently banned IPs, total bans since start, current failures detected, and total failures. - Quick indicators for the jail's find time, ban time, and max retries. +- A toggle to also show **Inactive Jails** — jails that are defined in fail2ban config files but are not currently running. +- Each inactive jail has an **Activate** button that enables and reloads it immediately, with optional overrides for ban time, find time, max retries, port, and log path. ### Jail Detail @@ -151,9 +152,14 @@ A page to inspect and modify the fail2ban configuration without leaving the web ### View Configuration -- Display all active fail2ban jails and their current settings. -- For each jail, show the associated filter and its regex patterns in a readable format. -- Show global fail2ban settings (ban time, find time, max retries, etc.). +- The **Jails**, **Filters**, and **Actions** tabs each use a **master/detail list layout**: + - A scrollable left pane lists all items (jail names, filter filenames, action filenames). + - Each item displays an **Active** or **Inactive** badge. Active items are sorted to the top; items within each group are sorted alphabetically. + - A jail is "active" if fail2ban reports it as enabled at runtime. A filter or action is "active" if it is referenced by at least one enabled jail. + - Inactive jails (present in config files but not running) are discoverable from the Jails tab. Selecting one shows its config file settings and allows activating it. + - Clicking an item loads its structured configuration form in the right detail pane. + - On narrow screens (< 900 px) the list pane collapses into a dropdown above the detail pane. +- Show global fail2ban settings (ban time, find time, max retries, etc.) on the Global Settings tab. ### Edit Configuration @@ -166,6 +172,16 @@ A page to inspect and modify the fail2ban configuration without leaving the web - Configure ban-time escalation: enable incremental banning and set factor, formula, multipliers, maximum ban time, and random jitter. - Save changes and optionally reload fail2ban to apply them immediately. - Validation feedback if a regex pattern or setting value is invalid before saving. +- **Activate** an inactive jail directly from the Jails tab detail pane, with optional parameter overrides. +- **Deactivate** a running jail from the Jails tab; writes ``enabled = false`` to a local override file and reloads fail2ban. + +### Raw Configuration Editing + +- Every jail, filter, and action detail pane includes a collapsible **Raw Configuration** section at the bottom. +- The section shows the complete raw text of the config file (`.conf`) in an editable monospace textarea. +- The user can edit the raw text directly and click **Save Raw** to overwrite the file on disk. +- The textarea loads lazily — the raw file content is only fetched when the section is first expanded. +- A save-state indicator shows idle / saving / saved / error feedback after each save attempt. ### Add Log Observation @@ -194,6 +210,37 @@ A page to inspect and modify the fail2ban configuration without leaving the web - Set the database purge age — how long historical ban records are kept before automatic cleanup. - Set the maximum number of log-line matches stored per ban record in the database. +### Map Settings + +- Configure the three color thresholds that determine how countries are colored on the World Map view based on their ban count: + - **Low Threshold (Green):** Ban count at which the color transitions from light green to full green (default: 20). + - **Medium Threshold (Yellow):** Ban count at which the color transitions from green to yellow (default: 50). + - **High Threshold (Red):** Ban count at which the color transitions from yellow to red (default: 100). +- Countries with ban counts between thresholds display smoothly interpolated colors. +- Countries with zero bans remain transparent (no fill). +- Changes take effect immediately on the World Map view without requiring a page reload. + +### Log + +- A dedicated **Log** tab on the Configuration page shows fail2ban service health and a live log viewer in one place. +- **Service Health panel** (always visible): + - Online/offline **badge** (Running / Offline). + - When online: version, active jail count, currently banned IPs, and currently failed attempts as stat cards. + - Log level and log target displayed as meta labels. + - Warning banner when fail2ban is offline, prompting the user to check the server and socket configuration. +- **Log Viewer** (shown when fail2ban logs to a file): + - Displays the tail of the fail2ban log file in a scrollable monospace container. + - Log lines are **color-coded by severity**: errors and critical messages in red, warnings in yellow, debug lines in grey, and informational lines in the default color. + - Toolbar controls: + - **Filter** — substring input with 300 ms debounce; only lines containing the filter text are shown. + - **Lines** — selector for how many tail lines to fetch (100 / 200 / 500 / 1000). + - **Refresh** button for an on-demand reload. + - **Auto-refresh** toggle with interval selector (5 s / 10 s / 30 s) for live monitoring. + - Truncation notice when the total log file line count exceeds the requested tail limit. + - Container automatically scrolls to the bottom after each data update. +- When fail2ban is configured to log to a non-file target (STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL), an informational banner explains that file-based log viewing is unavailable. +- The log file path is validated against a safe prefix allowlist on the backend to prevent path-traversal reads. + --- ## 7. Ban History diff --git a/Docs/Instructions.md b/Docs/Instructions.md index cc0c3be..17670ca 100644 --- a/Docs/Instructions.md +++ b/Docs/Instructions.md @@ -226,3 +226,34 @@ Verify against [Architekture.md](Architekture.md) and the project structure rule - **Never** push directly to `main` — always use feature branches. - **Never** skip the review step — sloppy code compounds over time. - **Never** leave a task half-done — finish it or revert it. + +--- + +## 7. Dev Quick-Reference + +### Start / stop the stack + +```bash +make up # start all containers (from repo root) +make down # stop all containers +make logs # tail logs +``` + +Backend: `http://127.0.0.1:8000` · Frontend (Vite proxy): `http://127.0.0.1:5173` + +### API login (dev) + +The frontend SHA256-hashes the password before sending it to the API. +The session cookie is named `bangui_session`. + +```bash +# Dev master password: Hallo123! +HASHED=$(echo -n "Hallo123!" | sha256sum | awk '{print $1}') +TOKEN=$(curl -s -X POST http://127.0.0.1:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d "{\"password\":\"$HASHED\"}" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') + +# Use token in subsequent requests: +curl -H "Cookie: bangui_session=$TOKEN" http://127.0.0.1:8000/api/dashboard/status +``` diff --git a/Docs/Tasks.md b/Docs/Tasks.md index dc3cf18..697ae92 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -4,406 +4,97 @@ This document breaks the entire BanGUI project into development stages, ordered --- -## Stage 1 — Project Scaffolding +## Task 1 — Move "Configuration" to the Last Position in the Sidebar ✅ DONE -Everything in this stage is about creating the project skeleton — folder structures, configuration files, and tooling — so that development can begin on solid ground. No application logic is written here. +**Summary:** Moved the `Configuration` entry in `NAV_ITEMS` to the last position in `frontend/src/layouts/MainLayout.tsx`. -### 1.1 Initialise the backend project +**File:** `frontend/src/layouts/MainLayout.tsx` -Create the `backend/` directory with the full folder structure defined in [Backend-Development.md § 3](Backend-Development.md). Set up `pyproject.toml` with all required dependencies (FastAPI, Pydantic v2, aiosqlite, aiohttp, APScheduler 4.x, structlog, pydantic-settings) and dev dependencies (pytest, pytest-asyncio, httpx, ruff, mypy). Configure ruff for 120-character line length and double-quote strings. Configure mypy in strict mode. Add a `.env.example` with placeholder keys for `BANGUI_DATABASE_PATH`, `BANGUI_FAIL2BAN_SOCKET`, and `BANGUI_SESSION_SECRET`. Make sure the bundled fail2ban client at `./fail2ban-master` is importable by configuring the path in `pyproject.toml` or a startup shim as described in [Backend-Development.md § 2](Backend-Development.md). +The `NAV_ITEMS` array (around line 183) defines the sidebar menu order. Currently the order is: Dashboard, World Map, Jails, **Configuration**, History, Blocklists. Move the Configuration entry so it is the **last** element in the array. The resulting order must be: -### 1.2 Initialise the frontend project +1. Dashboard +2. World Map +3. Jails +4. History +5. Blocklists +6. Configuration -Scaffold a Vite + React + TypeScript project inside `frontend/`. Install `@fluentui/react-components`, `@fluentui/react-icons`, and `react-router-dom`. Set up `tsconfig.json` with `"strict": true`. Configure ESLint with `@typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-config-prettier`. Add Prettier with the project defaults. Create the directory structure from [Web-Development.md § 4](Web-Development.md): `src/api/`, `src/components/`, `src/hooks/`, `src/layouts/`, `src/pages/`, `src/providers/`, `src/theme/`, `src/types/`, `src/utils/`. Create a minimal `App.tsx` that wraps the application in `` and `` as shown in [Web-Development.md § 5](Web-Development.md). - -### 1.3 Set up the Fluent UI custom theme - -Create the light and dark brand-colour themes inside `frontend/src/theme/`. Follow the colour rules in [Web-Design.md § 2](Web-Design.md): use the Fluent UI Theme Designer to generate a brand ramp, ensure the primary colour meets the 4.5 : 1 contrast ratio, and export both `lightTheme` and `darkTheme`. Wire the theme into `App.tsx` via the `FluentProvider` `theme` prop. - -### 1.4 Create the central API client - -Build the typed API client in `frontend/src/api/client.ts`. It should be a thin wrapper around `fetch` that returns typed responses, includes credentials, and throws a custom `ApiError` on non-OK responses. Define the `BASE_URL` from `import.meta.env.VITE_API_URL` with a fallback to `"/api"`. Create `frontend/src/api/endpoints.ts` for path constants. See [Web-Development.md § 3](Web-Development.md) for the pattern. - -### 1.5 Create the FastAPI application factory - -Implement `backend/app/main.py` with the `create_app()` factory function. Register the async lifespan context manager that opens the aiosqlite database connection, creates a shared `aiohttp.ClientSession`, and initialises the APScheduler instance on startup, then closes all three on shutdown. Store these on `app.state`. Register a placeholder router so the app can start and respond to a health-check request. See [Backend-Development.md § 6](Backend-Development.md) and [Architekture.md § 2](Architekture.md) for details. - -### 1.6 Create the Pydantic settings model - -Implement `backend/app/config.py` using pydantic-settings. Define the `Settings` class with fields for `database_path`, `fail2ban_socket`, `session_secret`, `session_duration_minutes`, and `timezone`. Load from environment variables prefixed `BANGUI_` and from `.env`. Validate at startup — the app must fail fast with a clear error if required values are missing. See [Backend-Development.md § 11](Backend-Development.md). - -### 1.7 Set up the application database schema - -Design and create the SQLite schema for BanGUI's own data. The database needs tables for application settings (key-value pairs for master password hash, database path, fail2ban socket path, preferences), sessions (token, created-at, expires-at), blocklist sources (name, URL, enabled flag), and import log entries (timestamp, source URL, IPs imported, IPs skipped, errors). Write an initialisation function that creates these tables on first run via aiosqlite. This schema is for BanGUI's internal state — it does not replace the fail2ban database. See [Architekture.md § 2.2](Architekture.md) for the repository breakdown. - -### 1.8 Write the fail2ban socket client wrapper - -Implement `backend/app/utils/fail2ban_client.py` — an async wrapper around the fail2ban Unix domain socket protocol. Study `./fail2ban-master/fail2ban/client/csocket.py` and `./fail2ban-master/fail2ban/client/fail2banclient.py` to understand the wire protocol (pickle-based command/response). The wrapper should provide async methods for sending commands and receiving responses, handle connection errors gracefully, and log every interaction with structlog. This module is the single point of contact between BanGUI and the fail2ban daemon. See [Backend-Development.md § 2 (fail2ban Client Usage)](Backend-Development.md) and [Architekture.md § 2.2 (Utils)](Architekture.md). +Only the position in the array changes. Do not modify the label, path, or icon of any item. --- -## Stage 2 — Authentication & Setup Flow +## Task 2 — Auto-Recovery When Jail Activation Fails ✅ DONE -This stage implements the very first user experience: the setup wizard that runs on first launch and the login system that protects every subsequent visit. All other features depend on these being complete. +**Summary:** Added `recovered: bool | None` field to `JailActivationResponse` model. Implemented `_restore_local_file_sync` and `_rollback_activation_async` helpers. Updated `activate_jail` to back up the original `.local` file, roll back on any post-write failure (reload error, health-check failure, or jail not starting), and return `recovered=True/False`. Updated `ActivateJailDialog.tsx` to show warning/critical banners based on the `recovered` field. Added 3 new backend tests covering all rollback scenarios. -### 2.1 Implement the setup service and repository +**Context:** When a user activates a jail via `POST /api/config/jails/{name}/activate`, the backend writes `enabled = true` to `jail.d/{name}.local` and then reloads fail2ban. If the new configuration is invalid or the server crashes after reload, fail2ban stays broken and all jails go offline. The system must automatically recover by rolling back the change and restarting fail2ban. -Build `backend/app/services/setup_service.py` and `backend/app/repositories/settings_repo.py`. The setup service accepts the initial configuration (master password, database path, fail2ban socket path, general preferences), hashes the password with a secure algorithm (e.g. bcrypt or argon2), and persists everything through the settings repository. It must enforce the one-time-only rule: once a configuration is saved, setup cannot run again. Add a method to check whether setup has been completed (i.e. whether any configuration exists in the database). See [Features.md § 1](Features.md). +### Backend Changes -### 2.2 Implement the setup router +**File:** `backend/app/services/config_file_service.py` — `activate_jail()` method (around line 1086) -Create `backend/app/routers/setup.py` with a `POST /api/setup` endpoint that accepts a Pydantic request model containing all setup fields and delegates to the setup service. If setup has already been completed, return a `409 Conflict`. Define request and response models in `backend/app/models/setup.py`. +Wrap the reload-and-verify sequence in error handling that performs a rollback on failure: -### 2.3 Implement the setup-redirect middleware +1. **Before writing** the `.local` override file, check whether a `.local` file for that jail already exists. If it does, read and keep its content in memory as a backup. If it does not exist, remember that no file existed. +2. **Write** the override file with `enabled = true` (existing logic). +3. **Reload** fail2ban via `jail_service.reload_all()` (existing logic). +4. **Health-check / verify** that fail2ban is responsive and the jail appears in the active list (existing logic). +5. **If any step after the write fails** (reload error, health-check timeout, jail not appearing): + - **Rollback the config**: restore the original `.local` file content (or delete the file if it did not exist before). + - **Restart fail2ban**: call `jail_service.reload_all()` again so fail2ban recovers with the old configuration. + - **Health-check again** to confirm fail2ban is back. + - Return an appropriate error response (HTTP 502 or 500) with a message that explains the activation failed **and** the system was recovered. Include a field `recovered: true` in the JSON body so the frontend can display a recovery notice. +6. If rollback itself fails, return an error with `recovered: false` so the frontend can display a critical alert. -Add middleware to the FastAPI app that checks on every incoming request whether setup has been completed. If not, redirect all requests (except those to `/api/setup` itself) to `/api/setup` with a `307 Temporary Redirect` or return a `403` with a clear message. Once setup is done, the middleware becomes a no-op. See [Features.md § 1](Features.md). +**File:** `backend/app/routers/config.py` — `activate_jail` endpoint (around line 584) -### 2.4 Implement the authentication service +Propagate the `recovered` field in the error response. No extra logic is needed in the router if the service already raises an appropriate exception or returns a result object with the recovery status. -Build `backend/app/services/auth_service.py`. It must verify the master password against the stored hash, create session tokens on successful login, store sessions through `backend/app/repositories/session_repo.py`, validate tokens on every subsequent request, and enforce session expiry. Sessions should be stored in the SQLite database so they survive server restarts. See [Features.md § 2](Features.md) and [Architekture.md § 2.2](Architekture.md). +### Frontend Changes -### 2.5 Implement the auth router +**File:** `frontend/src/components/config/JailsTab.tsx` (or wherever the activate mutation result is handled) -Create `backend/app/routers/auth.py` with two endpoints: `POST /api/auth/login` (accepts a password, returns a session token or sets a cookie) and `POST /api/auth/logout` (invalidates the session). Define request and response models in `backend/app/models/auth.py`. +When the activation API call returns an error: +- If `recovered` is `true`, show a warning banner/toast: *"Activation of jail '{name}' failed. The server has been automatically recovered."* +- If `recovered` is `false`, show a critical error banner/toast: *"Activation of jail '{name}' failed and automatic recovery was unsuccessful. Manual intervention is required."* -### 2.6 Implement the auth dependency +### Tests -Create a FastAPI dependency in `backend/app/dependencies.py` that extracts the session token from the request (cookie or header), validates it through the auth service, and either returns the authenticated session or raises a `401 Unauthorized`. Every protected router must declare this dependency. See [Backend-Development.md § 4](Backend-Development.md) for the Depends pattern. +Add or extend tests in `backend/tests/test_services/test_config_file_service.py`: -### 2.7 Build the setup page (frontend) - -Create `frontend/src/pages/SetupPage.tsx`. The page should present a form with fields for the master password (with confirmation), database path, fail2ban socket path, and general preferences (timezone, date format, session duration). Use Fluent UI form components (`Input`, `Button`, `Field`, `Dropdown` for timezone). On submission, call `POST /api/setup` through the API client. Show validation errors inline. After successful setup, redirect to the login page. Create the corresponding API function in `frontend/src/api/setup.ts` and types in `frontend/src/types/setup.ts`. See [Features.md § 1](Features.md) and [Web-Design.md § 8](Web-Design.md) for component choices. - -### 2.8 Build the login page (frontend) - -Create `frontend/src/pages/LoginPage.tsx`. A single password input and a submit button — no username field. On submission, call `POST /api/auth/login`. On success, store the session (cookie or context) and redirect to the originally requested page or the dashboard. Show an error message on wrong password. Create `frontend/src/api/auth.ts` and `frontend/src/types/auth.ts`. See [Features.md § 2](Features.md). - -### 2.9 Implement the auth context and route guard - -Create `frontend/src/providers/AuthProvider.tsx` that manages authentication state (logged in / not logged in) and exposes login, logout, and session-check methods via React context. Create a route guard component that wraps all protected routes: if the user is not authenticated, redirect to the login page and remember the intended destination. After login, redirect back. See [Features.md § 2](Features.md) and [Web-Development.md § 7](Web-Development.md). - -### 2.10 Write tests for setup and auth - -Write backend tests covering: setup endpoint accepts valid data, setup endpoint rejects a second call, login succeeds with correct password, login fails with wrong password, protected endpoints reject unauthenticated requests, logout invalidates the session for both router and service. Use pytest-asyncio and httpx `AsyncClient` as described in [Backend-Development.md § 9](Backend-Development.md). +- **test_activate_jail_rollback_on_reload_failure**: Mock `jail_service.reload_all()` to raise on the first call (activation reload) and succeed on the second call (recovery reload). Assert the `.local` file is restored to its original content and the response indicates `recovered: true`. +- **test_activate_jail_rollback_on_health_check_failure**: Mock the health check to fail after reload. Assert rollback and recovery. +- **test_activate_jail_rollback_failure**: Mock both the activation reload and the recovery reload to fail. Assert the response indicates `recovered: false`. --- -## Stage 3 — Application Shell & Navigation +## Task 3 — Match Pie Chart Slice Colors to Country Label Font Colors ✅ DONE -With authentication working, this stage builds the persistent layout that every page shares: the navigation sidebar, the header, and the routing skeleton. +**Summary:** Updated `legendFormatter` in `TopCountriesPieChart.tsx` to return `React.ReactNode` instead of `string`, using `` to colour each legend label to match its pie slice. Imported `LegendPayload` from `recharts/types/component/DefaultLegendContent`. -### 3.1 Build the main layout component +**Context:** The dashboard's Top Countries pie chart (`frontend/src/components/TopCountriesPieChart.tsx`) uses a color palette from `frontend/src/utils/chartTheme.ts` for the pie slices. The country names displayed in the legend next to the chart currently use the default text color. They should instead use the **same color as their corresponding pie slice**. -Create `frontend/src/layouts/MainLayout.tsx`. This is the outer shell visible on every authenticated page. It contains a fixed-width sidebar navigation (240 px, collapsing to 48 px on small screens) and a main content area. Use the Fluent UI `Nav` component for the sidebar with groups for Dashboard, World Map, Jails, Configuration, History, Blocklists, and a Logout action at the bottom. The layout must be responsive following the breakpoints in [Web-Design.md § 4](Web-Design.md). The main content area is capped at 1440 px and centred on wide screens. +### Changes -### 3.2 Set up client-side routing +**File:** `frontend/src/components/TopCountriesPieChart.tsx` -Configure React Router in `frontend/src/App.tsx` (or a dedicated `AppRoutes.tsx`). Define routes for every page: `/` (dashboard), `/map`, `/jails`, `/jails/:name`, `/config`, `/history`, `/blocklists`, `/setup`, `/login`. Wrap all routes except setup and login inside the auth guard from Stage 2. Use the `MainLayout` for authenticated routes. Create placeholder page components for each route so navigation works end to end. +In the `` component (rendered by Recharts), the `formatter` prop already receives the legend entry value. Apply a custom renderer so each country name is rendered with its matching slice color as the **font color**. The Recharts `` accepts a `formatter` function whose second argument is the entry object containing the `color` property. Use that color to wrap the text in a `` with `style={{ color: entry.color }}`. Example: -### 3.3 Implement the logout flow +```tsx +formatter={(value: string, entry: LegendPayload) => { + const slice = slices.find((s) => s.name === value); + if (slice == null || total === 0) return value; + const pct = ((slice.value / total) * 100).toFixed(1); + return ( + + {value} ({pct}%) + + ); +}} +``` -Wire the Logout button in the sidebar to call `POST /api/auth/logout`, clear the client-side session state, and redirect to the login page. The logout option must be accessible from every page as specified in [Features.md § 2](Features.md). +Make sure the `formatter` return type is `ReactNode` (not `string`). Import the Recharts `Payload` type if needed: `import type { Payload } from "recharts/types/component/DefaultLegendContent"` . Adjust the import path to match the Recharts version in the project. + +Do **not** change the pie slice colors themselves — only the country label font color must match the slice it corresponds to. --- - -## Stage 4 — fail2ban Connection & Server Status - -This stage establishes the live connection to the fail2ban daemon and surfaces its health to the user. It is a prerequisite for every data-driven feature. - -### 4.1 Implement the health service - -Build `backend/app/services/health_service.py`. It connects to the fail2ban socket using the wrapper from Stage 1.8, sends a `status` command, and parses the response to extract: whether the server is reachable, the fail2ban version, the number of active jails, and aggregated ban/failure counts. Expose a method that returns a structured health status object. Log connectivity changes (online → offline and vice versa) via structlog. See [Features.md § 3 (Server Status Bar)](Features.md). - -### 4.2 Implement the health-check background task - -Create `backend/app/tasks/health_check.py` — an APScheduler job that runs the health service probe every 30 seconds and caches the result in memory (e.g. on `app.state`). This ensures the dashboard endpoint can return fresh status without blocking on a socket call. See [Architekture.md § 2.2 (Tasks)](Architekture.md). - -### 4.3 Implement the dashboard status endpoint - -Create `backend/app/routers/dashboard.py` with a `GET /api/dashboard/status` endpoint that returns the cached server status (online/offline, version, jail count, total bans, total failures). Define response models in `backend/app/models/server.py`. This endpoint is lightweight — it reads from the in-memory cache populated by the health-check task. - -### 4.4 Build the server status bar component (frontend) - -Create `frontend/src/components/ServerStatusBar.tsx`. This persistent bar appears at the top of the dashboard (and optionally on other pages). It displays the fail2ban connection status (green badge for online, red for offline), the server version, active jail count, and total bans/failures. Use Fluent UI `Badge` and `Text` components. Poll `GET /api/dashboard/status` at a reasonable interval or on page focus. Create `frontend/src/api/dashboard.ts`, `frontend/src/types/server.ts`, and a `useServerStatus` hook. - -### 4.5 Write tests for health service and dashboard - -Test that the health service correctly parses a mock fail2ban status response, handles socket errors gracefully, and that the dashboard endpoint returns the expected shape. Mock the fail2ban socket — tests must never touch a real daemon. - ---- - -## Stage 5 — Ban Overview (Dashboard) - -The main landing page. This stage delivers the ban list and access list tables that give users a quick picture of recent activity. - -### 5.1 Implement the ban service (list recent bans) - -Build `backend/app/services/ban_service.py` with a method that queries the fail2ban database for bans within a given time range. The fail2ban SQLite database stores ban records — read them using aiosqlite (open the fail2ban DB path from settings, read-only). Return structured ban objects including IP, jail, timestamp, and any additional metadata available. See [Features.md § 3 (Ban List)](Features.md). - -### 5.2 Implement the geo service - -Build `backend/app/services/geo_service.py`. Given an IP address, resolve its country of origin (and optionally ASN and RIR). Use an external API via aiohttp or a local GeoIP database. Cache results to avoid repeated lookups for the same IP. The geo service is used throughout the application wherever country information is displayed. See [Features.md § 5 (IP Lookup)](Features.md) and [Architekture.md § 2.2](Architekture.md). - -### 5.3 Implement the dashboard bans endpoint - -Add `GET /api/dashboard/bans` to `backend/app/routers/dashboard.py`. It accepts a time-range query parameter (hours or a preset like `24h`, `7d`, `30d`, `365d`). It calls the ban service to retrieve bans in that window, enriches each ban with country data from the geo service, and returns a paginated list. Define request/response models in `backend/app/models/ban.py`. - -### 5.4 Build the ban list table (frontend) - -Create `frontend/src/components/BanTable.tsx` using Fluent UI `DataGrid`. Columns: time of ban, IP address (monospace), requested URL/service, country, domain, subdomain. Rows are sorted newest-first. Above the table, place a time-range selector implemented as a `Toolbar` with `ToggleButton` for the four presets (24 h, 7 d, 30 d, 365 d). Create a `useBans` hook that calls `GET /api/dashboard/bans` with the selected range. See [Features.md § 3 (Ban List)](Features.md) and [Web-Design.md § 8 (Data Display)](Web-Design.md). - -### 5.5 Build the dashboard page - -Create `frontend/src/pages/DashboardPage.tsx`. Compose the server status bar at the top, then a `Pivot` (tab control) switching between "Ban List" and "Access List". The Ban List tab renders the `BanTable`. The Access List tab uses the same table component but fetches all recorded accesses, not just bans. If the access list requires a separate endpoint, add `GET /api/dashboard/accesses` to the backend with the same time-range support. See [Features.md § 3](Features.md). - -### 5.6 Write tests for ban service and dashboard endpoints - -Test ban queries for each time-range preset, test that geo enrichment works with mocked API responses, and test that the endpoint returns the correct response shape. Verify edge cases: no bans in the selected range, an IP that fails geo lookup. - ---- - -## Stage 6 — Jail Management - -This stage exposes fail2ban's jail system through the UI — listing jails, viewing details, and executing control commands. - -### 6.1 Implement the jail service - -Build `backend/app/services/jail_service.py`. Using the fail2ban socket client, implement methods to: list all jails with their status and key metrics, retrieve the full detail of a single jail (log paths, regex patterns, date pattern, encoding, actions, ban-time escalation settings), start a jail, stop a jail, toggle idle mode, reload a single jail, and reload all jails. Each method sends the appropriate command through the socket wrapper and parses the response. See [Features.md § 5 (Jail Overview, Jail Detail, Jail Controls)](Features.md). - -### 6.2 Implement the jails router - -Create `backend/app/routers/jails.py`: -- `GET /api/jails` — list all jails with status and metrics. -- `GET /api/jails/{name}` — full detail for a single jail. -- `POST /api/jails/{name}/start` — start a jail. -- `POST /api/jails/{name}/stop` — stop a jail. -- `POST /api/jails/{name}/idle` — toggle idle mode. -- `POST /api/jails/{name}/reload` — reload a single jail. -- `POST /api/jails/reload-all` — reload all jails. - -Define request/response models in `backend/app/models/jail.py`. Use appropriate HTTP status codes (404 if a jail name does not exist, 409 if a jail is already in the requested state). See [Architekture.md § 2.2 (Routers)](Architekture.md). - -### 6.3 Implement ban and unban endpoints - -Add to `backend/app/routers/bans.py`: -- `POST /api/bans` — ban an IP in a specified jail. Validate the IP with `ipaddress` before sending. -- `DELETE /api/bans` — unban an IP from a specific jail or all jails. Support an `unban_all` flag. -- `GET /api/bans/active` — list all currently banned IPs across all jails, with jail name, ban start time, expiry, and ban count. - -Delegate to the ban service. See [Features.md § 5 (Ban an IP, Unban an IP, Currently Banned IPs)](Features.md). - -### 6.4 Build the jail overview page (frontend) - -Create `frontend/src/pages/JailsPage.tsx`. Display a card or table for each jail showing name, status badge (running/stopped/idle), backend type, banned count, total bans, failure counts, find time, ban time, and max retries. Each jail links to a detail view. Use Fluent UI `Card` or `DataGrid`. Create `frontend/src/api/jails.ts`, `frontend/src/types/jail.ts`, and a `useJails` hook. See [Features.md § 5 (Jail Overview)](Features.md). - -### 6.5 Build the jail detail page (frontend) - -Create `frontend/src/pages/JailDetailPage.tsx` — reached via `/jails/:name`. Fetch the full jail detail and display: monitored log paths, fail regex and ignore regex lists (rendered in monospace), date pattern, log encoding, attached actions and their config, and ban-time escalation settings. Include control buttons (Start, Stop, Idle, Reload) that call the corresponding API endpoints with confirmation dialogs (Fluent UI `Dialog`). See [Features.md § 5 (Jail Detail, Jail Controls)](Features.md). - -### 6.6 Build the ban/unban UI (frontend) - -On the Jails page (or a dedicated sub-section), add a "Ban an IP" form with an IP input field and a jail selector dropdown. Add an "Unban an IP" form with an IP input (or selection from the currently-banned list), a jail selector (or "all jails"), and an "unban all" option. Show success/error feedback using Fluent UI `MessageBar` or `Toast`. Build a "Currently Banned IPs" table showing IP, jail, ban start, expiry, ban count, and a direct unban button per row. See [Features.md § 5 (Ban an IP, Unban an IP, Currently Banned IPs)](Features.md). - -### 6.7 Implement IP lookup endpoint and UI - -Add `GET /api/geo/lookup/{ip}` to `backend/app/routers/geo.py`. The endpoint checks whether the IP is currently banned (and in which jails), retrieves its ban history (count, timestamps, jails), and fetches enriched info (country, ASN, RIR) from the geo service. On the frontend, create an IP Lookup section in the Jails area where the user can enter any IP and see all this information. See [Features.md § 5 (IP Lookup)](Features.md). - -### 6.8 Implement the ignore list (whitelist) endpoints and UI - -Add endpoints to `backend/app/routers/jails.py` for managing ignore lists: -- `GET /api/jails/{name}/ignoreip` — get the ignore list for a jail. -- `POST /api/jails/{name}/ignoreip` — add an IP or network to a jail's ignore list. -- `DELETE /api/jails/{name}/ignoreip` — remove an IP from the ignore list. -- `POST /api/jails/{name}/ignoreself` — toggle the "ignore self" option. - -On the frontend, add an "IP Whitelist" section to the jail detail page showing the ignore list with add/remove controls. See [Features.md § 5 (IP Whitelist)](Features.md). - -### 6.9 Write tests for jail and ban features - -Test jail listing with mocked socket responses, jail detail parsing, start/stop/reload commands, ban and unban execution, currently-banned list retrieval, IP lookup with and without ban history, and ignore list operations. Ensure all socket interactions are mocked. - ---- - -## Stage 7 — Configuration View - -This stage lets users inspect and edit fail2ban configuration directly from the web interface. - -### 7.1 Implement the config service - -Build `backend/app/services/config_service.py`. It reads the active fail2ban configuration by querying the daemon for jail settings, filter regex patterns, and global parameters. It also writes configuration changes by sending the appropriate set commands through the socket (or by editing config files and triggering a reload, depending on what fail2ban supports for each setting). The service must validate regex patterns before applying them — attempting to compile each pattern and returning a clear error if it is invalid. See [Features.md § 6 (View Configuration, Edit Configuration)](Features.md). - -### 7.2 Implement the config router - -Create `backend/app/routers/config.py`: -- `GET /api/config/jails` — list all jails with their current configuration. -- `GET /api/config/jails/{name}` — full configuration for a single jail (filter, regex, dates, actions, escalation). -- `PUT /api/config/jails/{name}` — update a jail's configuration (ban time, max retries, enabled, regex patterns, date pattern, DNS mode, escalation settings). -- `GET /api/config/global` — global fail2ban settings. -- `PUT /api/config/global` — update global settings. -- `POST /api/config/reload` — reload fail2ban to apply changes. - -Define models in `backend/app/models/config.py`. Return validation errors before saving. See [Architekture.md § 2.2 (Routers)](Architekture.md). - -### 7.3 Implement log observation endpoints - -Add endpoints for registering new log files that fail2ban should monitor. The user needs to specify a log file path, one or more failure-detection regex patterns, a jail name, and basic jail settings. Include a preview endpoint that reads the specified log file and tests the provided regex against its contents, returning matching lines so the user can verify the pattern before saving. See [Features.md § 6 (Add Log Observation)](Features.md). - -### 7.4 Implement the regex tester endpoint - -Add `POST /api/config/regex-test` to the config router. It accepts a sample log line and a fail regex pattern, attempts to match them, and returns whether the pattern matched along with any captured groups highlighted by position. This is a stateless utility endpoint. See [Features.md § 6 (Regex Tester)](Features.md). - -### 7.5 Implement server settings endpoints - -Create `backend/app/routers/server.py`: -- `GET /api/server/settings` — current log level, log target, syslog socket, DB path, purge age, max matches. -- `PUT /api/server/settings` — update server-level settings. -- `POST /api/server/flush-logs` — flush and re-open log files. - -Delegate to `backend/app/services/server_service.py`. See [Features.md § 6 (Server Settings)](Features.md). - -### 7.6 Build the configuration page (frontend) - -Create `frontend/src/pages/ConfigPage.tsx`. The page should show all jails with their current settings in a readable format. Each jail section expands to show filter regex, ignore regex, date pattern, actions, and escalation settings. Provide inline editing: clicking a value turns it into an editable field. Add/remove buttons for regex patterns. A "Save" button persists changes and optionally triggers a reload. Show validation errors inline. Use Fluent UI `Accordion`, `Input`, `Textarea`, `Switch`, and `Button`. See [Features.md § 6](Features.md) and [Web-Design.md](Web-Design.md). - -### 7.7 Build the regex tester UI (frontend) - -Add a "Regex Tester" section to the configuration page (or as a dialog/panel). Two input fields: one for a sample log line, one for the regex pattern. On every change (debounced), call the regex-test endpoint and display the result — whether it matched, and highlight the matched groups. Use monospace font for both inputs. See [Features.md § 6 (Regex Tester)](Features.md). - -### 7.8 Build the server settings UI (frontend) - -Add a "Server Settings" section to the configuration page. Display current values for log level, log target, syslog socket, DB path, purge age, and max matches. Provide dropdowns for log level and log target, text inputs for paths and numeric values. Include a "Flush Logs" button. See [Features.md § 6 (Server Settings)](Features.md). - -### 7.9 Write tests for configuration features - -Test config read and write operations with mocked fail2ban responses, regex validation (valid and invalid patterns), the regex tester with matching and non-matching inputs, and server settings read/write. Verify that changes are only applied after validation passes. - ---- - -## Stage 8 — World Map View - -A geographical visualisation of ban activity. This stage depends on the geo service from Stage 5 and the ban data pipeline from Stage 5. - -### 8.1 Implement the map data endpoint - -Add `GET /api/dashboard/bans/by-country` to the dashboard router. It accepts the same time-range parameter as the ban list endpoint. It queries bans in the selected window, enriches them with geo data, and returns an aggregated count per country (ISO country code → ban count). Also return the full ban list so the frontend can display the companion table. See [Features.md § 4](Features.md). - -### 8.2 Build the world map component (frontend) - -Create `frontend/src/components/WorldMap.tsx`. Render a full world map with country outlines only — no fill colours, no satellite imagery. For each country with bans, display the ban count centred inside the country's borders. Countries with zero bans remain blank. Consider a lightweight SVG-based map library or a TopoJSON/GeoJSON world outline rendered with D3 or a comparable tool. The map must be interactive: clicking a country filters the companion access list. Include the same time-range selector as the dashboard. See [Features.md § 4](Features.md). - -### 8.3 Build the map page (frontend) - -Create `frontend/src/pages/MapPage.tsx`. Compose the time-range selector, the `WorldMap` component, and an access list table below. When a country is selected on the map, the table filters to show only entries from that country. Clicking the map background (or a "Clear filter" button) removes the country filter. Create `frontend/src/hooks/useMapData.ts` to fetch and manage the aggregated data. See [Features.md § 4](Features.md). - -### 8.4 Write tests for the map data endpoint - -Test aggregation correctness: multiple bans from the same country should be summed, unknown countries should be handled gracefully, and empty time ranges should return an empty map object. - ---- - -## Stage 9 — Ban History - -This stage exposes historical ban data from the fail2ban database for forensic exploration. - -### 9.1 Implement the history service - -Build `backend/app/services/history_service.py`. Query the fail2ban database for all past ban records (not just currently active ones). Support filtering by jail, IP address, and time range. Compute ban count per IP to identify repeat offenders. Provide a per-IP timeline method that returns every ban event for a given IP: which jail triggered it, when it started, how long it lasted, and any matched log lines stored in the database. See [Features.md § 7](Features.md). - -### 9.2 Implement the history router - -Create `backend/app/routers/history.py`: -- `GET /api/history` — paginated list of all historical bans with filters (jail, IP, time range). Returns time, IP, jail, duration, ban count, country. -- `GET /api/history/{ip}` — per-IP detail: full ban timeline, total failures, matched log lines. - -Define models in `backend/app/models/history.py`. Enrich results with geo data. See [Architekture.md § 2.2](Architekture.md). - -### 9.3 Build the history page (frontend) - -Create `frontend/src/pages/HistoryPage.tsx`. Display a `DataGrid` table of all past bans with columns for time, IP (monospace), jail, ban duration, ban count, and country. Add filter controls above the table: a jail dropdown, an IP search input, and the standard time-range selector. Highlight rows with high ban counts to flag repeat offenders. Clicking an IP row navigates to a per-IP detail view showing the full ban timeline and aggregated failures. See [Features.md § 7](Features.md). - -### 9.4 Write tests for history features - -Test history queries with various filters, per-IP timeline construction, ban count computation, and edge cases (IP with no history, jail that no longer exists). - ---- - -## Stage 10 — External Blocklist Importer - -This stage adds the ability to automatically download and apply external IP blocklists on a schedule. - -### 10.1 Implement the blocklist repository - -Build `backend/app/repositories/blocklist_repo.py` and `backend/app/repositories/import_log_repo.py`. The blocklist repo persists blocklist source definitions (name, URL, enabled flag) in the application database. The import log repo records every import run with timestamp, source URL, IPs imported, IPs skipped, and any errors encountered. See [Architekture.md § 2.2 (Repositories)](Architekture.md). - -### 10.2 Implement the blocklist service - -Build `backend/app/services/blocklist_service.py`. It manages blocklist source CRUD (add, edit, remove, toggle enabled). For the actual import: download each enabled source URL via aiohttp, validate every entry as a well-formed IP or CIDR range using the `ipaddress` module, skip malformed lines gracefully, and apply valid IPs as bans through fail2ban (in a dedicated blocklist jail) or via iptables. If using iptables, flush the chain before re-populating. Log every step with structlog. Record import results through the import log repository. Handle unreachable URLs by logging the error and continuing with remaining sources. See [Features.md § 8](Features.md). - -### 10.3 Implement the blocklist import scheduled task - -Create `backend/app/tasks/blocklist_import.py` — an APScheduler job that runs the blocklist service import at the configured schedule. The default is daily at 03:00. The schedule should be configurable through the blocklist service (saved in the app database). See [Features.md § 8 (Schedule)](Features.md). - -### 10.4 Implement the blocklist router - -Create `backend/app/routers/blocklist.py`: -- `GET /api/blocklists` — list all blocklist sources with their status. -- `POST /api/blocklists` — add a new source. -- `PUT /api/blocklists/{id}` — edit a source (name, URL, enabled). -- `DELETE /api/blocklists/{id}` — remove a source. -- `GET /api/blocklists/{id}/preview` — download and display a sample of the blocklist contents. -- `POST /api/blocklists/import` — trigger a manual import immediately ("Run Now"). -- `GET /api/blocklists/schedule` — get the current schedule and next run time. -- `PUT /api/blocklists/schedule` — update the schedule. -- `GET /api/blocklists/log` — paginated import log, filterable by source and date range. - -Define models in `backend/app/models/blocklist.py`. See [Architekture.md § 2.2](Architekture.md). - -### 10.5 Build the blocklist management page (frontend) - -Create `frontend/src/pages/BlocklistPage.tsx`. Display a list of blocklist sources as cards or rows showing name, URL, enabled toggle, and action buttons (edit, delete, preview). Add a form to create or edit a source. Show the schedule configuration with a simple time-and-frequency picker (no raw cron) — dropdowns for frequency preset and a time input. Include a "Run Now" button and a display of last import time and next scheduled run. Below, show the import log as a table (timestamp, source, IPs imported, IPs skipped, errors) with filters. If the most recent import had errors, show a warning badge in the navigation. See [Features.md § 8](Features.md). - -### 10.6 Write tests for blocklist features - -Test source CRUD, import with valid/invalid entries, schedule update, manual import trigger, import log persistence, and error handling when a URL is unreachable. Mock all HTTP calls. - ---- - -## Stage 11 — Polish, Cross-Cutting Concerns & Hardening - -This final stage covers everything that spans multiple features or improves the overall quality of the application. - -### 11.1 Implement connection health indicator - -Add a persistent connection-health indicator visible on every page (part of the `MainLayout`). When the fail2ban server becomes unreachable, show a clear warning bar at the top of the interface. When it recovers, dismiss the warning. The indicator reads from the cached health status maintained by the background task from Stage 4. See [Features.md § 9](Features.md). - -### 11.2 Add timezone awareness - -Ensure all timestamps displayed in the frontend respect the timezone configured during setup. Store all dates in UTC on the backend. Convert to the user's configured timezone on the frontend before display. Create a `formatDate` utility in `frontend/src/utils/` that applies the configured timezone and format. See [Features.md § 9](Features.md). - -### 11.3 Add responsive layout polish - -Review every page against the breakpoint table in [Web-Design.md § 4](Web-Design.md). Ensure the sidebar collapses correctly on small screens, tables scroll horizontally instead of breaking, cards stack vertically, and no content overflows. Test at 320 px, 640 px, 1024 px, and 1920 px widths. - -### 11.4 Add loading and error states - -Every page and data-fetching component must handle three states: loading (show Fluent UI `Spinner` or skeleton shimmer), error (show a `MessageBar` with details and a retry action), and empty (show an informational message). Remove bare spinners that persist longer than one second — replace them with skeleton screens as required by [Web-Design.md § 6](Web-Design.md). - -### 11.5 Implement reduced-motion support - -Honour the `prefers-reduced-motion` media query. When detected, disable all non-essential animations (tab transitions, row slide-outs, panel fly-ins) and replace them with instant state changes. See [Web-Design.md § 6 (Motion Rules)](Web-Design.md). - -### 11.6 Add accessibility audit - -Verify WCAG 2.1 AA compliance across the entire application. All interactive elements must be keyboard-accessible. All Fluent UI components include accessibility by default, but custom components (world map, regex tester highlight) need manual `aria-label` and role attributes. Ensure colour is never the sole indicator of status — combine with icons or text labels. See [Web-Design.md § 1](Web-Design.md). - -### 11.7 Add structured logging throughout - -Review every service and task to confirm that all significant operations are logged with structlog and contextual key-value pairs. Log ban/unban actions, config changes, blocklist imports, authentication events, and health transitions. Never log passwords, session tokens, or other secrets. See [Backend-Development.md § 7](Backend-Development.md). - -### 11.8 Add global error handling - -Register FastAPI exception handlers in `main.py` that map all custom domain exceptions to HTTP status codes with structured error bodies. Ensure no unhandled exception ever returns a raw 500 with a stack trace to the client. Log all errors with full context before returning the response. See [Backend-Development.md § 8](Backend-Development.md). - -### 11.9 Final test pass and coverage check - -Run the full test suite. Ensure all tests pass. Check coverage: aim for over 80 % line coverage overall, with 100 % on critical paths (auth, banning, scheduled imports). Add missing tests where coverage is below threshold. Ensure `ruff`, `mypy --strict`, and `tsc --noEmit` all pass with zero errors. See [Backend-Development.md § 9](Backend-Development.md) and [Web-Development.md § 1](Web-Development.md). diff --git a/Docs/Web-Design.md b/Docs/Web-Design.md index bfc1ac0..c04b3d1 100644 --- a/Docs/Web-Design.md +++ b/Docs/Web-Design.md @@ -204,7 +204,7 @@ Use Fluent UI React components as the building blocks. The following mapping sho |---|---|---| | Side navigation | `Nav` | Persistent on large screens, collapsible on small. Groups: Dashboard, Map, Jails, Config, History, Blocklists. | | Breadcrumbs | `Breadcrumb` | Show on detail pages (Jail > sshd, History > IP detail). | -| Page tabs | `Pivot` | Dashboard (Ban List / Access List), Map (Map / Access List). | +| Page tabs | `Pivot` | None currently (previous tabs removed). | ### Data Display @@ -271,12 +271,14 @@ The dashboard uses cards to display key figures (server status, total bans, acti ## 11. World Map View -- The map renders country outlines only — **no fill colours, no satellite imagery, no terrain shading**. +- The map renders country outlines only — **no fill colours, no satellite imagery, no terrain shading**. Countries are transparent with neutral strokes. +- **The map is fully interactive:** users can zoom in/out using mouse wheel or pinch gestures, and pan by dragging. Zoom range: 1× (full world) to 8× (regional detail). +- **Zoom controls:** Three small buttons overlaid in the top-right corner provide zoom in (+), zoom out (−), and reset view (⟲) functionality. Buttons use `appearance="secondary"` and `size="small"`. - Countries with banned IPs display a **count badge** centred inside the country polygon. Use `FontSizes.size14` semibold, `themePrimary` colour. - Countries with zero bans remain completely blank — no label, no tint. -- On hover: country region gets a subtle `neutralLighterAlt` fill. On click: fill shifts to `themeLighterAlt` and the companion table below filters to that country. -- The map must have a **light neutral border** (`neutralLight`) around its container, at **Depth 4**. -- Time-range selector above the map uses `Pivot` with quick presets (24 h, 7 d, 30 d, 365 d). +- On hover: country region gets a subtle `neutralBackground3` fill (only if the country has data). On click: fill shifts to `brandBackgroundHover` and the companion table below filters to that country. Default state remains transparent. +- The map must have a **light neutral border** (`neutralStroke1`) around its container, with `borderRadius.medium`. +- Time-range selector above the map uses `Select` dropdown with quick presets (24 h, 7 d, 30 d, 365 d). --- diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index dc8f1f6..1943ace 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -130,10 +130,15 @@ frontend/ ├── .eslintrc.cjs ├── .prettierrc ├── tsconfig.json -├── vite.config.ts +├── vite.config.ts # Dev proxy: /api → http://backend:8000 (service DNS) └── package.json ``` +> **Dev proxy target:** `vite.config.ts` proxies all `/api` requests to +> `http://backend:8000`. Use the compose **service name** (`backend`), not +> `localhost` — inside the container network `localhost` resolves to the +> frontend container itself and causes `ECONNREFUSED`. + ### Separation of Concerns - **Pages** handle routing and compose layout + components — they contain no business logic. diff --git a/Docs/test.md b/Docs/test.md new file mode 100644 index 0000000..dd35bcc --- /dev/null +++ b/Docs/test.md @@ -0,0 +1 @@ +https://lists.blocklist.de/lists/all.txt \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..69b0e80 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ +# ────────────────────────────────────────────────────────────── +# BanGUI — Project Makefile +# +# Compatible with both Docker Compose and Podman Compose. +# Auto-detects which compose binary is available. +# +# Usage: +# make up — start the debug stack +# make down — stop the debug stack +# make build — (re)build the backend image without starting +# make clean — stop, remove all containers, volumes, and local images +# make logs — tail logs for all debug services +# make restart — restart the debug stack +# make dev-ban-test — one-command smoke test of the ban pipeline +# ────────────────────────────────────────────────────────────── + +COMPOSE_FILE := Docker/compose.debug.yml + +# Compose project name (matches `name:` in compose.debug.yml). +PROJECT := bangui-dev + +# All named volumes declared in compose.debug.yml. +# Compose prefixes them with the project name. +DEV_VOLUMES := \ + $(PROJECT)_bangui-dev-data \ + $(PROJECT)_frontend-node-modules \ + $(PROJECT)_fail2ban-dev-config \ + $(PROJECT)_fail2ban-dev-run + +# Locally-built images (compose project name + service name). +# Public images (fail2ban, node) are intentionally excluded. +DEV_IMAGES := \ + $(PROJECT)_backend + +# Detect available compose binary. +COMPOSE := $(shell command -v podman-compose 2>/dev/null \ + || echo "podman compose") + +# Detect available container runtime (podman or docker). +RUNTIME := $(shell command -v podman 2>/dev/null || echo "docker") + +.PHONY: up down build restart logs clean dev-ban-test + +## Start the debug stack (detached). +## Ensures log stub files exist so fail2ban can open them on first start. +up: + @mkdir -p Docker/logs + @touch Docker/logs/auth.log + $(COMPOSE) -f $(COMPOSE_FILE) up -d + +## Stop the debug stack. +down: + $(COMPOSE) -f $(COMPOSE_FILE) down + +## (Re)build the backend image without starting containers. +build: + $(COMPOSE) -f $(COMPOSE_FILE) build + +## Restart the debug stack. +restart: down up + +## Tail logs for all debug services. +logs: + $(COMPOSE) -f $(COMPOSE_FILE) logs -f + +## Stop containers, remove ALL debug volumes and locally-built images. +## The next 'make up' will rebuild images from scratch and start fresh. +clean: + $(COMPOSE) -f $(COMPOSE_FILE) down --remove-orphans + $(RUNTIME) volume rm $(DEV_VOLUMES) 2>/dev/null || true + $(RUNTIME) rmi $(DEV_IMAGES) 2>/dev/null || true + @echo "All debug volumes and local images removed. Run 'make up' to rebuild and start fresh." + +## One-command smoke test for the ban pipeline: +## 1. Start fail2ban, 2. write failure lines, 3. check ban status. +dev-ban-test: + $(COMPOSE) -f $(COMPOSE_FILE) up -d fail2ban + sleep 5 + bash Docker/simulate_failed_logins.sh + sleep 3 + bash Docker/check_ban_status.sh diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9b663cc --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,22 @@ +# BanGUI Backend — Environment Variables +# Copy this file to .env and fill in the values. +# Never commit .env to version control. + +# Path to the BanGUI application SQLite database. +BANGUI_DATABASE_PATH=bangui.db + +# Path to the fail2ban Unix domain socket. +BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock + +# Secret key used to sign session tokens. Use a long, random string. +# Generate with: python -c "import secrets; print(secrets.token_hex(64))" +BANGUI_SESSION_SECRET=replace-this-with-a-long-random-secret + +# Session duration in minutes. Default: 60 minutes. +BANGUI_SESSION_DURATION_MINUTES=60 + +# Timezone for displaying timestamps in the UI (IANA tz name). +BANGUI_TIMEZONE=UTC + +# Application log level: debug | info | warning | error | critical +BANGUI_LOG_LEVEL=info diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f8dd91a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,49 @@ +# ───────────────────────────────────────────── +# backend/.gitignore (Python / FastAPI) +# ───────────────────────────────────────────── + +# Byte-compiled / optimised source files +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Virtual environment (local override) +.venv/ +venv/ +env/ + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Testing +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.tox/ + +# Type checkers & linters +.mypy_cache/ +.ruff_cache/ +.pytype/ + +# Local database files +*.sqlite3 +*.db +*.db-shm +*.db-wal + +# Alembic generated junk +alembic/versions/__pycache__/ + +# Secrets +.env +.env.* +!.env.example +secrets.json + +# Logs +*.log diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..a9d4aeb --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""BanGUI backend application package.""" diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..0f73ce5 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,88 @@ +"""Application configuration loaded from environment variables and .env file. + +Follows pydantic-settings patterns: all values are prefixed with BANGUI_ +and validated at startup via the Settings singleton. +""" + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """BanGUI runtime configuration. + + All fields are loaded from environment variables prefixed with ``BANGUI_`` + or from a ``.env`` file located next to the process working directory. + The application will raise a :class:`pydantic.ValidationError` on startup + if any required field is missing or has an invalid value. + """ + + database_path: str = Field( + default="bangui.db", + description="Filesystem path to the BanGUI SQLite application database.", + ) + fail2ban_socket: str = Field( + default="/var/run/fail2ban/fail2ban.sock", + description="Path to the fail2ban Unix domain socket.", + ) + session_secret: str = Field( + ..., + description=( + "Secret key used when generating session tokens. " + "Must be unique and never committed to source control." + ), + ) + session_duration_minutes: int = Field( + default=60, + ge=1, + description="Number of minutes a session token remains valid after creation.", + ) + timezone: str = Field( + default="UTC", + description="IANA timezone name used when displaying timestamps in the UI.", + ) + log_level: str = Field( + default="info", + description="Application log level: debug | info | warning | error | critical.", + ) + geoip_db_path: str | None = Field( + default=None, + description=( + "Optional path to a MaxMind GeoLite2-Country .mmdb file. " + "When set, failed ip-api.com lookups fall back to local resolution." + ), + ) + fail2ban_config_dir: str = Field( + default="/config/fail2ban", + description=( + "Path to the fail2ban configuration directory. " + "Must contain subdirectories jail.d/, filter.d/, and action.d/. " + "Used for listing, viewing, and editing configuration files through the web UI." + ), + ) + fail2ban_start_command: str = Field( + default="fail2ban-client start", + description=( + "Shell command used to start (not reload) the fail2ban daemon during " + "recovery rollback. Split by whitespace to build the argument list — " + "no shell interpretation is performed. " + "Example: 'systemctl start fail2ban' or 'fail2ban-client start'." + ), + ) + + model_config = SettingsConfigDict( + env_prefix="BANGUI_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + +def get_settings() -> Settings: + """Return a fresh :class:`Settings` instance loaded from the environment. + + Returns: + A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError` + if required keys are absent or values fail validation. + """ + return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..cac8843 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,112 @@ +"""Application database schema definition and initialisation. + +BanGUI maintains its own SQLite database that stores configuration, session +state, blocklist source definitions, and import run logs. This module is +the single source of truth for the schema — all ``CREATE TABLE`` statements +live here and are applied on first run via :func:`init_db`. + +The fail2ban database is separate and is accessed read-only by the history +and ban services. +""" + +import aiosqlite +import structlog + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# DDL statements +# --------------------------------------------------------------------------- + +_CREATE_SETTINGS: str = """ +CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +""" + +_CREATE_SESSIONS: str = """ +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + expires_at TEXT NOT NULL +); +""" + +_CREATE_SESSIONS_TOKEN_INDEX: str = """ +CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions (token); +""" + +_CREATE_BLOCKLIST_SOURCES: str = """ +CREATE TABLE IF NOT EXISTS blocklist_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +""" + +_CREATE_IMPORT_LOG: str = """ +CREATE TABLE IF NOT EXISTS import_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER REFERENCES blocklist_sources(id) ON DELETE SET NULL, + source_url TEXT NOT NULL, + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + ips_imported INTEGER NOT NULL DEFAULT 0, + ips_skipped INTEGER NOT NULL DEFAULT 0, + errors TEXT +); +""" + +_CREATE_GEO_CACHE: str = """ +CREATE TABLE IF NOT EXISTS geo_cache ( + ip TEXT PRIMARY KEY, + country_code TEXT, + country_name TEXT, + asn TEXT, + org TEXT, + cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +""" + +# Ordered list of DDL statements to execute on initialisation. +_SCHEMA_STATEMENTS: list[str] = [ + _CREATE_SETTINGS, + _CREATE_SESSIONS, + _CREATE_SESSIONS_TOKEN_INDEX, + _CREATE_BLOCKLIST_SOURCES, + _CREATE_IMPORT_LOG, + _CREATE_GEO_CACHE, +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def init_db(db: aiosqlite.Connection) -> None: + """Create all BanGUI application tables if they do not already exist. + + This function is idempotent — calling it on an already-initialised + database has no effect. It should be called once during application + startup inside the FastAPI lifespan handler. + + Args: + db: An open :class:`aiosqlite.Connection` to the application database. + """ + log.info("initialising_database_schema") + async with db.execute("PRAGMA journal_mode=WAL;"): + pass + async with db.execute("PRAGMA foreign_keys=ON;"): + pass + for statement in _SCHEMA_STATEMENTS: + await db.executescript(statement) + await db.commit() + log.info("database_schema_ready") diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..0afb7d4 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,156 @@ +"""FastAPI dependency providers. + +All ``Depends()`` callables that inject shared resources (database +connection, settings, services, auth guard) are defined here. +Routers import directly from this module — never from ``app.state`` +directly — to keep coupling explicit and testable. +""" + +import time +from typing import Annotated + +import aiosqlite +import structlog +from fastapi import Depends, HTTPException, Request, status + +from app.config import Settings +from app.models.auth import Session +from app.utils.time_utils import utc_now + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +_COOKIE_NAME = "bangui_session" + +# --------------------------------------------------------------------------- +# Session validation cache +# --------------------------------------------------------------------------- + +#: How long (seconds) a validated session token is served from the in-memory +#: cache without re-querying SQLite. Eliminates repeated DB lookups for the +#: same token arriving in near-simultaneous parallel requests. +_SESSION_CACHE_TTL: float = 10.0 + +#: ``token → (Session, cache_expiry_monotonic_time)`` +_session_cache: dict[str, tuple[Session, float]] = {} + + +def clear_session_cache() -> None: + """Flush the entire in-memory session validation cache. + + Useful in tests to prevent stale state from leaking between test cases. + """ + _session_cache.clear() + + +def invalidate_session_cache(token: str) -> None: + """Evict *token* from the in-memory session cache. + + Must be called during logout so the revoked token is no longer served + from cache without a DB round-trip. + + Args: + token: The session token to remove. + """ + _session_cache.pop(token, None) + + +async def get_db(request: Request) -> aiosqlite.Connection: + """Provide the shared :class:`aiosqlite.Connection` from ``app.state``. + + Args: + request: The current FastAPI request (injected automatically). + + Returns: + The application-wide aiosqlite connection opened during startup. + + Raises: + HTTPException: 503 if the database has not been initialised. + """ + db: aiosqlite.Connection | None = getattr(request.app.state, "db", None) + if db is None: + log.error("database_not_initialised") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Database is not available.", + ) + return db + + +async def get_settings(request: Request) -> Settings: + """Provide the :class:`~app.config.Settings` instance from ``app.state``. + + Args: + request: The current FastAPI request (injected automatically). + + Returns: + The application settings loaded at startup. + """ + return request.app.state.settings # type: ignore[no-any-return] + + +async def require_auth( + request: Request, + db: Annotated[aiosqlite.Connection, Depends(get_db)], +) -> Session: + """Validate the session token and return the active session. + + The token is read from the ``bangui_session`` cookie or the + ``Authorization: Bearer`` header. + + Validated tokens are cached in memory for :data:`_SESSION_CACHE_TTL` + seconds so that concurrent requests sharing the same token avoid repeated + SQLite round-trips. The cache is bypassed on expiry and explicitly + cleared by :func:`invalidate_session_cache` on logout. + + Args: + request: The incoming FastAPI request. + db: Injected aiosqlite connection. + + Returns: + The active :class:`~app.models.auth.Session`. + + Raises: + HTTPException: 401 if no valid session token is found. + """ + from app.services import auth_service # noqa: PLC0415 + + token: str | None = request.cookies.get(_COOKIE_NAME) + if not token: + auth_header: str = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[len("Bearer "):] + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Fast path: serve from in-memory cache when the entry is still fresh and + # the session itself has not yet exceeded its own expiry time. + cached = _session_cache.get(token) + if cached is not None: + session, cache_expires_at = cached + if time.monotonic() < cache_expires_at and session.expires_at > utc_now().isoformat(): + return session + # Stale cache entry — evict and fall through to DB. + _session_cache.pop(token, None) + + try: + session = await auth_service.validate_session(db, token) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(exc), + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + _session_cache[token] = (session, time.monotonic() + _SESSION_CACHE_TTL) + return session + + +# Convenience type aliases for route signatures. +DbDep = Annotated[aiosqlite.Connection, Depends(get_db)] +SettingsDep = Annotated[Settings, Depends(get_settings)] +AuthDep = Annotated[Session, Depends(require_auth)] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a02c1c1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,408 @@ +"""BanGUI FastAPI application factory. + +Call :func:`create_app` to obtain a configured :class:`fastapi.FastAPI` +instance suitable for direct use with an ASGI server (e.g. ``uvicorn``) or +in tests via ``httpx.AsyncClient``. + +The lifespan handler manages all shared resources — database connection, HTTP +session, and scheduler — so every component can rely on them being available +on ``app.state`` throughout the request lifecycle. +""" + +from __future__ import annotations + +import logging +import sys +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Awaitable, Callable + + from starlette.responses import Response as StarletteResponse + +import aiohttp +import aiosqlite +import structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped] +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, RedirectResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from app.config import Settings, get_settings +from app.db import init_db +from app.routers import ( + auth, + bans, + blocklist, + config, + dashboard, + file_config, + geo, + health, + history, + jails, + server, + setup, +) +from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check +from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError + +# --------------------------------------------------------------------------- +# Ensure the bundled fail2ban package is importable from fail2ban-master/ +# +# The directory layout differs between local dev and the Docker image: +# Local: /backend/app/main.py → fail2ban-master at parents[2] +# Docker: /app/app/main.py → fail2ban-master at parents[1] +# Walk up from this file until we find a "fail2ban-master" sibling directory +# so the path resolution is environment-agnostic. +# --------------------------------------------------------------------------- + + +def _find_fail2ban_master() -> Path | None: + """Return the first ``fail2ban-master`` directory found while walking up. + + Returns: + Absolute :class:`~pathlib.Path` to the ``fail2ban-master`` directory, + or ``None`` if no such directory exists among the ancestors. + """ + here = Path(__file__).resolve() + for ancestor in here.parents: + candidate = ancestor / "fail2ban-master" + if candidate.is_dir(): + return candidate + return None + + +_fail2ban_master: Path | None = _find_fail2ban_master() +if _fail2ban_master is not None and str(_fail2ban_master) not in sys.path: + sys.path.insert(0, str(_fail2ban_master)) + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + + +# --------------------------------------------------------------------------- +# Logging configuration +# --------------------------------------------------------------------------- + + +def _configure_logging(log_level: str) -> None: + """Configure structlog for production JSON output. + + Args: + log_level: One of ``debug``, ``info``, ``warning``, ``error``, ``critical``. + """ + level: int = logging.getLevelName(log_level.upper()) + logging.basicConfig(level=level, stream=sys.stdout, format="%(message)s") + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.stdlib.filter_by_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.processors.JSONRenderer(), + ], + wrapper_class=structlog.stdlib.BoundLogger, + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + +# --------------------------------------------------------------------------- +# Lifespan +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Manage the lifetime of all shared application resources. + + Resources are initialised in order on startup and released in reverse + order on shutdown. They are stored on ``app.state`` so they are + accessible to dependency providers and tests. + + Args: + app: The :class:`fastapi.FastAPI` instance being started. + """ + settings: Settings = app.state.settings + _configure_logging(settings.log_level) + + log.info("bangui_starting_up", database_path=settings.database_path) + + # --- Application database --- + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + + # --- Shared HTTP client session --- + http_session: aiohttp.ClientSession = aiohttp.ClientSession() + app.state.http_session = http_session + + # --- Pre-warm geo cache from the persistent store --- + from app.services import geo_service # noqa: PLC0415 + + geo_service.init_geoip(settings.geoip_db_path) + await geo_service.load_cache_from_db(db) + + # Log unresolved geo entries so the operator can see the scope of the issue. + async with db.execute( + "SELECT COUNT(*) FROM geo_cache WHERE country_code IS NULL" + ) as cur: + row = await cur.fetchone() + unresolved_count: int = int(row[0]) if row else 0 + if unresolved_count > 0: + log.warning("geo_cache_unresolved_ips", unresolved=unresolved_count) + + # --- Background task scheduler --- + scheduler: AsyncIOScheduler = AsyncIOScheduler(timezone="UTC") + scheduler.start() + app.state.scheduler = scheduler + + # --- Health-check background probe --- + health_check.register(app) + + # --- Blocklist import scheduled task --- + blocklist_import.register(app) + + # --- Periodic geo cache flush to SQLite --- + geo_cache_flush.register(app) + + # --- Periodic re-resolve of NULL-country geo entries --- + geo_re_resolve.register(app) + + log.info("bangui_started") + + try: + yield + finally: + log.info("bangui_shutting_down") + scheduler.shutdown(wait=False) + await http_session.close() + await db.close() + log.info("bangui_shut_down") + + +# --------------------------------------------------------------------------- +# Exception handlers +# --------------------------------------------------------------------------- + + +async def _unhandled_exception_handler( + request: Request, + exc: Exception, +) -> JSONResponse: + """Return a sanitised 500 JSON response for any unhandled exception. + + The exception is logged with full context before the response is sent. + No stack trace is leaked to the client. + + Args: + request: The incoming FastAPI request. + exc: The unhandled exception. + + Returns: + A :class:`fastapi.responses.JSONResponse` with status 500. + """ + log.error( + "unhandled_exception", + path=request.url.path, + method=request.method, + exc_info=exc, + ) + return JSONResponse( + status_code=500, + content={"detail": "An unexpected error occurred. Please try again later."}, + ) + + +async def _fail2ban_connection_handler( + request: Request, + exc: Fail2BanConnectionError, +) -> JSONResponse: + """Return a ``502 Bad Gateway`` response when fail2ban is unreachable. + + Args: + request: The incoming FastAPI request. + exc: The :class:`~app.utils.fail2ban_client.Fail2BanConnectionError`. + + Returns: + A :class:`fastapi.responses.JSONResponse` with status 502. + """ + log.warning( + "fail2ban_connection_error", + path=request.url.path, + method=request.method, + error=str(exc), + ) + return JSONResponse( + status_code=502, + content={"detail": f"Cannot reach fail2ban: {exc}"}, + ) + + +async def _fail2ban_protocol_handler( + request: Request, + exc: Fail2BanProtocolError, +) -> JSONResponse: + """Return a ``502 Bad Gateway`` response for fail2ban protocol errors. + + Args: + request: The incoming FastAPI request. + exc: The :class:`~app.utils.fail2ban_client.Fail2BanProtocolError`. + + Returns: + A :class:`fastapi.responses.JSONResponse` with status 502. + """ + log.warning( + "fail2ban_protocol_error", + path=request.url.path, + method=request.method, + error=str(exc), + ) + return JSONResponse( + status_code=502, + content={"detail": f"fail2ban protocol error: {exc}"}, + ) + + +# --------------------------------------------------------------------------- +# Setup-redirect middleware +# --------------------------------------------------------------------------- + +# Paths that are always reachable, even before setup is complete. +_ALWAYS_ALLOWED: frozenset[str] = frozenset( + {"/api/setup", "/api/health"}, +) + + +class SetupRedirectMiddleware(BaseHTTPMiddleware): + """Redirect all API requests to ``/api/setup`` until setup is done. + + Once setup is complete this middleware is a no-op. Paths listed in + :data:`_ALWAYS_ALLOWED` are exempt so the setup endpoint itself is + always reachable. + """ + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[StarletteResponse]], + ) -> StarletteResponse: + """Intercept requests before they reach the router. + + Args: + request: The incoming HTTP request. + call_next: The next middleware / router handler. + + Returns: + Either a ``307 Temporary Redirect`` to ``/api/setup`` or the + normal router response. + """ + path: str = request.url.path.rstrip("/") or "/" + + # Allow requests that don't need setup guard. + if any(path.startswith(allowed) for allowed in _ALWAYS_ALLOWED): + return await call_next(request) + + # If setup is not complete, block all other API requests. + # Fast path: setup completion is a one-way transition. Once it is + # True it is cached on app.state so all subsequent requests skip the + # DB query entirely. The flag is reset only when the app restarts. + if path.startswith("/api") and not getattr( + request.app.state, "_setup_complete_cached", False + ): + db: aiosqlite.Connection | None = getattr(request.app.state, "db", None) + if db is not None: + from app.services import setup_service # noqa: PLC0415 + + if await setup_service.is_setup_complete(db): + request.app.state._setup_complete_cached = True + else: + return RedirectResponse( + url="/api/setup", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + + return await call_next(request) + + +# --------------------------------------------------------------------------- +# Application factory +# --------------------------------------------------------------------------- + + +def create_app(settings: Settings | None = None) -> FastAPI: + """Create and configure the BanGUI FastAPI application. + + This factory is the single entry point for creating the application. + Tests can pass a custom ``settings`` object to override defaults + without touching environment variables. + + Args: + settings: Optional pre-built :class:`~app.config.Settings` instance. + If ``None``, settings are loaded from the environment via + :func:`~app.config.get_settings`. + + Returns: + A fully configured :class:`fastapi.FastAPI` application ready for use. + """ + resolved_settings: Settings = settings if settings is not None else get_settings() + + app: FastAPI = FastAPI( + title="BanGUI", + description="Web interface for monitoring, managing, and configuring fail2ban.", + version="0.1.0", + lifespan=_lifespan, + ) + + # Store settings on app.state so the lifespan handler can access them. + app.state.settings = resolved_settings + + # --- CORS --- + # In production the frontend is served by the same origin. + # CORS is intentionally permissive only in development. + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], # Vite dev server + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # --- Middleware --- + # Note: middleware is applied in reverse order of registration. + # The setup-redirect must run *after* CORS, so it is added last. + app.add_middleware(SetupRedirectMiddleware) + + # --- Exception handlers --- + # Ordered from most specific to least specific. FastAPI evaluates handlers + # in the order they were registered, so fail2ban network errors get a 502 + # rather than falling through to the generic 500 handler. + app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type] + app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type] + app.add_exception_handler(Exception, _unhandled_exception_handler) + + # --- Routers --- + app.include_router(health.router) + app.include_router(setup.router) + app.include_router(auth.router) + app.include_router(dashboard.router) + app.include_router(jails.router) + app.include_router(bans.router) + app.include_router(geo.router) + app.include_router(config.router) + app.include_router(file_config.router) + app.include_router(server.router) + app.include_router(history.router) + app.include_router(blocklist.router) + + return app diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..c210ba4 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +"""Pydantic request/response/domain models package.""" diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py new file mode 100644 index 0000000..8527de3 --- /dev/null +++ b/backend/app/models/auth.py @@ -0,0 +1,46 @@ +"""Authentication Pydantic models. + +Request, response, and domain models used by the auth router and service. +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class LoginRequest(BaseModel): + """Payload for ``POST /api/auth/login``.""" + + model_config = ConfigDict(strict=True) + + password: str = Field(..., description="Master password to authenticate with.") + + +class LoginResponse(BaseModel): + """Successful login response. + + The session token is also set as an ``HttpOnly`` cookie by the router. + This model documents the JSON body for API-first consumers. + """ + + model_config = ConfigDict(strict=True) + + token: str = Field(..., description="Session token for use in subsequent requests.") + expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.") + + +class LogoutResponse(BaseModel): + """Response body for ``POST /api/auth/logout``.""" + + model_config = ConfigDict(strict=True) + + message: str = Field(default="Logged out successfully.") + + +class Session(BaseModel): + """Internal domain model representing a persisted session record.""" + + model_config = ConfigDict(strict=True) + + id: int = Field(..., description="Auto-incremented row ID.") + token: str = Field(..., description="Opaque session token.") + created_at: str = Field(..., description="ISO 8601 UTC creation timestamp.") + expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.") diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py new file mode 100644 index 0000000..ceae79f --- /dev/null +++ b/backend/app/models/ban.py @@ -0,0 +1,335 @@ +"""Ban management Pydantic models. + +Request, response, and domain models used by the ban router and service. +""" + +import math +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# Time-range selector +# --------------------------------------------------------------------------- + +#: The four supported time-range presets for the dashboard views. +TimeRange = Literal["24h", "7d", "30d", "365d"] + +#: Number of seconds represented by each preset. +TIME_RANGE_SECONDS: dict[str, int] = { + "24h": 24 * 3600, + "7d": 7 * 24 * 3600, + "30d": 30 * 24 * 3600, + "365d": 365 * 24 * 3600, +} + + +class BanRequest(BaseModel): + """Payload for ``POST /api/bans`` (ban an IP).""" + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="IP address to ban.") + jail: str = Field(..., description="Jail in which to apply the ban.") + + +class UnbanRequest(BaseModel): + """Payload for ``DELETE /api/bans`` (unban an IP).""" + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="IP address to unban.") + jail: str | None = Field( + default=None, + description="Jail to remove the ban from. ``null`` means all jails.", + ) + unban_all: bool = Field( + default=False, + description="When ``true`` the IP is unbanned from every jail.", + ) + + +#: Discriminator literal for the origin of a ban. +BanOrigin = Literal["blocklist", "selfblock"] + +#: Jail name used by the blocklist import service. +BLOCKLIST_JAIL: str = "blocklist-import" + + +def _derive_origin(jail: str) -> BanOrigin: + """Derive the ban origin from the jail name. + + Args: + jail: The jail that issued the ban. + + Returns: + ``"blocklist"`` when the jail is the dedicated blocklist-import + jail, ``"selfblock"`` otherwise. + """ + return "blocklist" if jail == BLOCKLIST_JAIL else "selfblock" + + +class Ban(BaseModel): + """Domain model representing a single active or historical ban record.""" + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="Banned IP address.") + jail: str = Field(..., description="Jail that issued the ban.") + banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") + expires_at: str | None = Field( + default=None, + description="ISO 8601 UTC expiry timestamp, or ``null`` if permanent.", + ) + ban_count: int = Field(..., ge=1, description="Number of times this IP was banned.") + country: str | None = Field( + default=None, + description="ISO 3166-1 alpha-2 country code resolved from the IP.", + ) + origin: BanOrigin = Field( + ..., + description="Whether this ban came from a blocklist import or fail2ban itself.", + ) + + +class BanResponse(BaseModel): + """Response containing a single ban record.""" + + model_config = ConfigDict(strict=True) + + ban: Ban + + +class BanListResponse(BaseModel): + """Paginated list of ban records.""" + + model_config = ConfigDict(strict=True) + + bans: list[Ban] = Field(default_factory=list) + total: int = Field(..., ge=0, description="Total number of matching records.") + + +class ActiveBan(BaseModel): + """A currently active ban entry returned by ``GET /api/bans/active``.""" + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="Banned IP address.") + jail: str = Field(..., description="Jail holding the ban.") + banned_at: str | None = Field(default=None, description="ISO 8601 UTC start of the ban.") + expires_at: str | None = Field( + default=None, + description="ISO 8601 UTC expiry, or ``null`` if permanent.", + ) + ban_count: int = Field(default=1, ge=1, description="Running ban count for this IP.") + country: str | None = Field(default=None, description="ISO 3166-1 alpha-2 country code.") + + +class ActiveBanListResponse(BaseModel): + """List of all currently active bans across all jails.""" + + model_config = ConfigDict(strict=True) + + bans: list[ActiveBan] = Field(default_factory=list) + total: int = Field(..., ge=0) + + +class UnbanAllResponse(BaseModel): + """Response for ``DELETE /api/bans/all``.""" + + model_config = ConfigDict(strict=True) + + message: str = Field(..., description="Human-readable summary of the operation.") + count: int = Field(..., ge=0, description="Number of IPs that were unbanned.") + + +# --------------------------------------------------------------------------- +# Dashboard ban-list view models +# --------------------------------------------------------------------------- + + +class DashboardBanItem(BaseModel): + """A single row in the dashboard ban-list table. + + Populated from the fail2ban database and enriched with geo data. + """ + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="Banned IP address.") + jail: str = Field(..., description="Jail that issued the ban.") + banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") + service: str | None = Field( + default=None, + description="First matched log line — used as context for the ban.", + ) + country_code: str | None = Field( + default=None, + description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.", + ) + country_name: str | None = Field( + default=None, + description="Human-readable country name, or ``null`` if unknown.", + ) + asn: str | None = Field( + default=None, + description="Autonomous System Number string (e.g. ``'AS3320'``).", + ) + org: str | None = Field( + default=None, + description="Organisation name associated with the IP.", + ) + ban_count: int = Field(..., ge=1, description="How many times this IP was banned.") + origin: BanOrigin = Field( + ..., + description="Whether this ban came from a blocklist import or fail2ban itself.", + ) + + +class DashboardBanListResponse(BaseModel): + """Paginated dashboard ban-list response.""" + + model_config = ConfigDict(strict=True) + + items: list[DashboardBanItem] = Field(default_factory=list) + total: int = Field(..., ge=0, description="Total bans in the selected time window.") + page: int = Field(..., ge=1) + page_size: int = Field(..., ge=1) + + +class BansByCountryResponse(BaseModel): + """Response for the bans-by-country aggregation endpoint. + + Contains a per-country ban count, a human-readable country name map, and + the full (un-paginated) ban list for the selected time window so the + frontend can render both the world map and its companion table from a + single request. + """ + + model_config = ConfigDict(strict=True) + + countries: dict[str, int] = Field( + default_factory=dict, + description="ISO 3166-1 alpha-2 country code → ban count.", + ) + country_names: dict[str, str] = Field( + default_factory=dict, + description="ISO 3166-1 alpha-2 country code → human-readable country name.", + ) + bans: list[DashboardBanItem] = Field( + default_factory=list, + description="All bans in the selected time window (up to the server limit).", + ) + total: int = Field(..., ge=0, description="Total ban count in the window.") + + +# --------------------------------------------------------------------------- +# Trend endpoint models +# --------------------------------------------------------------------------- + +#: Bucket size in seconds for each time-range preset. +BUCKET_SECONDS: dict[str, int] = { + "24h": 3_600, # 1 hour → 24 buckets + "7d": 6 * 3_600, # 6 hours → 28 buckets + "30d": 86_400, # 1 day → 30 buckets + "365d": 7 * 86_400, # 7 days → ~53 buckets +} + +#: Human-readable bucket size label for each time-range preset. +BUCKET_SIZE_LABEL: dict[str, str] = { + "24h": "1h", + "7d": "6h", + "30d": "1d", + "365d": "7d", +} + + +def bucket_count(range_: TimeRange) -> int: + """Return the number of buckets needed to cover *range_* completely. + + Args: + range_: One of the supported time-range presets. + + Returns: + Ceiling division of the range duration by the bucket size so that + the last bucket is included even when the window is not an exact + multiple of the bucket size. + """ + return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_]) + + +class BanTrendBucket(BaseModel): + """A single time bucket in the ban trend series.""" + + model_config = ConfigDict(strict=True) + + timestamp: str = Field(..., description="ISO 8601 UTC start of the bucket.") + count: int = Field(..., ge=0, description="Number of bans that started in this bucket.") + + +class BanTrendResponse(BaseModel): + """Response for the ``GET /api/dashboard/bans/trend`` endpoint.""" + + model_config = ConfigDict(strict=True) + + buckets: list[BanTrendBucket] = Field( + default_factory=list, + description="Time-ordered list of ban-count buckets covering the full window.", + ) + bucket_size: str = Field( + ..., + description="Human-readable bucket size label (e.g. '1h', '6h', '1d', '7d').", + ) + + +# --------------------------------------------------------------------------- +# By-jail endpoint models +# --------------------------------------------------------------------------- + + +class JailBanCount(BaseModel): + """A single jail entry in the bans-by-jail aggregation.""" + + model_config = ConfigDict(strict=True) + + jail: str = Field(..., description="Jail name.") + count: int = Field(..., ge=0, description="Number of bans recorded in this jail.") + + +class BansByJailResponse(BaseModel): + """Response for the ``GET /api/dashboard/bans/by-jail`` endpoint.""" + + model_config = ConfigDict(strict=True) + + jails: list[JailBanCount] = Field( + default_factory=list, + description="Jails ordered by ban count descending.", + ) + total: int = Field(..., ge=0, description="Total ban count in the selected window.") + + +# --------------------------------------------------------------------------- +# Jail-specific paginated bans +# --------------------------------------------------------------------------- + + +class JailBannedIpsResponse(BaseModel): + """Paginated response for ``GET /api/jails/{name}/banned``. + + Contains only the current page of active ban entries for a single jail, + geo-enriched exclusively for the page slice to avoid rate-limit issues. + """ + + model_config = ConfigDict(strict=True) + + items: list[ActiveBan] = Field( + default_factory=list, + description="Active ban entries for the current page.", + ) + total: int = Field( + ..., + ge=0, + description="Total matching entries (after applying the search filter).", + ) + page: int = Field(..., ge=1, description="Current page number (1-based).") + page_size: int = Field(..., ge=1, description="Number of items per page.") diff --git a/backend/app/models/blocklist.py b/backend/app/models/blocklist.py new file mode 100644 index 0000000..946b340 --- /dev/null +++ b/backend/app/models/blocklist.py @@ -0,0 +1,181 @@ +"""Blocklist source and import log Pydantic models. + +Data shapes for blocklist source management, import operations, scheduling, +and import log retrieval. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# Blocklist source +# --------------------------------------------------------------------------- + + +class BlocklistSource(BaseModel): + """Domain model for a blocklist source definition.""" + + model_config = ConfigDict(strict=True) + + id: int + name: str + url: str + enabled: bool + created_at: str + updated_at: str + + +class BlocklistSourceCreate(BaseModel): + """Payload for ``POST /api/blocklists``.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., min_length=1, max_length=100, description="Human-readable source name.") + url: str = Field(..., min_length=1, description="URL of the blocklist file.") + enabled: bool = Field(default=True) + + +class BlocklistSourceUpdate(BaseModel): + """Payload for ``PUT /api/blocklists/{id}``. All fields are optional.""" + + model_config = ConfigDict(strict=True) + + name: str | None = Field(default=None, min_length=1, max_length=100) + url: str | None = Field(default=None) + enabled: bool | None = Field(default=None) + + +class BlocklistListResponse(BaseModel): + """Response for ``GET /api/blocklists``.""" + + model_config = ConfigDict(strict=True) + + sources: list[BlocklistSource] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Import log +# --------------------------------------------------------------------------- + + +class ImportLogEntry(BaseModel): + """A single blocklist import run record.""" + + model_config = ConfigDict(strict=True) + + id: int + source_id: int | None + source_url: str + timestamp: str + ips_imported: int + ips_skipped: int + errors: str | None + + +class ImportLogListResponse(BaseModel): + """Response for ``GET /api/blocklists/log``.""" + + model_config = ConfigDict(strict=True) + + items: list[ImportLogEntry] = Field(default_factory=list) + total: int = Field(..., ge=0) + page: int = Field(default=1, ge=1) + page_size: int = Field(default=50, ge=1) + total_pages: int = Field(default=1, ge=1) + + +# --------------------------------------------------------------------------- +# Schedule +# --------------------------------------------------------------------------- + + +class ScheduleFrequency(StrEnum): + """Available import schedule frequency presets.""" + + hourly = "hourly" + daily = "daily" + weekly = "weekly" + + +class ScheduleConfig(BaseModel): + """Import schedule configuration. + + The interpretation of fields depends on *frequency*: + + - ``hourly``: ``interval_hours`` controls how often (every N hours). + - ``daily``: ``hour`` and ``minute`` specify the daily run time (UTC). + - ``weekly``: additionally uses ``day_of_week`` (0=Monday … 6=Sunday). + """ + + # No strict=True here: FastAPI and json.loads() both supply enum values as + # plain strings; strict mode would reject string→enum coercion. + + frequency: ScheduleFrequency = ScheduleFrequency.daily + interval_hours: int = Field(default=24, ge=1, le=168, description="Used when frequency=hourly") + hour: int = Field(default=3, ge=0, le=23, description="UTC hour for daily/weekly runs") + minute: int = Field(default=0, ge=0, le=59, description="Minute for daily/weekly runs") + day_of_week: int = Field( + default=0, + ge=0, + le=6, + description="Day of week for weekly runs (0=Monday … 6=Sunday)", + ) + + +class ScheduleInfo(BaseModel): + """Current schedule configuration together with runtime metadata.""" + + model_config = ConfigDict(strict=True) + + config: ScheduleConfig + next_run_at: str | None + last_run_at: str | None + last_run_errors: bool | None = None + """``True`` if the most recent import had errors, ``False`` if clean, ``None`` if never run.""" + + +# --------------------------------------------------------------------------- +# Import results +# --------------------------------------------------------------------------- + + +class ImportSourceResult(BaseModel): + """Result of importing a single blocklist source.""" + + model_config = ConfigDict(strict=True) + + source_id: int | None + source_url: str + ips_imported: int + ips_skipped: int + error: str | None + + +class ImportRunResult(BaseModel): + """Aggregated result from a full import run across all enabled sources.""" + + model_config = ConfigDict(strict=True) + + results: list[ImportSourceResult] = Field(default_factory=list) + total_imported: int + total_skipped: int + errors_count: int + + +# --------------------------------------------------------------------------- +# Preview +# --------------------------------------------------------------------------- + + +class PreviewResponse(BaseModel): + """Response for ``GET /api/blocklists/{id}/preview``.""" + + model_config = ConfigDict(strict=True) + + entries: list[str] = Field(default_factory=list, description="Sample of valid IP entries") + total_lines: int + valid_count: int + skipped_count: int diff --git a/backend/app/models/config.py b/backend/app/models/config.py new file mode 100644 index 0000000..b336018 --- /dev/null +++ b/backend/app/models/config.py @@ -0,0 +1,1001 @@ +"""Configuration view/edit Pydantic models. + +Request, response, and domain models for the config router and service. +""" + +import datetime + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# Ban-time escalation +# --------------------------------------------------------------------------- + + +class BantimeEscalation(BaseModel): + """Incremental ban-time escalation configuration for a jail.""" + + model_config = ConfigDict(strict=True) + + increment: bool = Field( + default=False, + description="Whether incremental banning is enabled.", + ) + factor: float | None = Field( + default=None, + description="Multiplier applied to the base ban time on each repeat offence.", + ) + formula: str | None = Field( + default=None, + description="Python expression evaluated to compute the escalated ban time.", + ) + multipliers: str | None = Field( + default=None, + description="Space-separated integers used as per-offence multipliers.", + ) + max_time: int | None = Field( + default=None, + description="Maximum ban duration in seconds when escalation is active.", + ) + rnd_time: int | None = Field( + default=None, + description="Random jitter (seconds) added to each escalated ban time.", + ) + overall_jails: bool = Field( + default=False, + description="Count repeat offences across all jails, not just the current one.", + ) + + +class BantimeEscalationUpdate(BaseModel): + """Partial update payload for ban-time escalation settings.""" + + model_config = ConfigDict(strict=True) + + increment: bool | None = Field(default=None) + factor: float | None = Field(default=None) + formula: str | None = Field(default=None) + multipliers: str | None = Field(default=None) + max_time: int | None = Field(default=None) + rnd_time: int | None = Field(default=None) + overall_jails: bool | None = Field(default=None) + + +# --------------------------------------------------------------------------- +# Jail configuration models +# --------------------------------------------------------------------------- + + +class JailConfig(BaseModel): + """Configuration snapshot of a single jail (editable fields).""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Jail name as configured in fail2ban.") + ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.") + max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.") + find_time: int = Field(..., ge=1, description="Time window (seconds) for counting failures.") + fail_regex: list[str] = Field(default_factory=list, description="Failure detection regex patterns.") + ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.") + log_paths: list[str] = Field(default_factory=list, description="Monitored log files.") + date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.") + log_encoding: str = Field(default="UTF-8", description="Log file encoding.") + backend: str = Field(default="polling", description="Log monitoring backend.") + use_dns: str = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.") + prefregex: str = Field(default="", description="Prefix regex prepended to every failregex; empty means disabled.") + actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.") + bantime_escalation: BantimeEscalation | None = Field( + default=None, + description="Incremental ban-time escalation settings, or None if not configured.", + ) + + +class JailConfigResponse(BaseModel): + """Response for ``GET /api/config/jails/{name}``.""" + + model_config = ConfigDict(strict=True) + + jail: JailConfig + + +class JailConfigListResponse(BaseModel): + """Response for ``GET /api/config/jails``.""" + + model_config = ConfigDict(strict=True) + + jails: list[JailConfig] = Field(default_factory=list) + total: int = Field(..., ge=0) + + +class JailConfigUpdate(BaseModel): + """Payload for ``PUT /api/config/jails/{name}``.""" + + model_config = ConfigDict(strict=True) + + ban_time: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.") + max_retry: int | None = Field(default=None, ge=1) + find_time: int | None = Field(default=None, ge=1) + fail_regex: list[str] | None = Field(default=None, description="Failure detection regex patterns.") + ignore_regex: list[str] | None = Field(default=None) + prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.") + date_pattern: str | None = Field(default=None) + dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.") + backend: str | None = Field(default=None, description="Log monitoring backend.") + log_encoding: str | None = Field(default=None, description="Log file encoding.") + enabled: bool | None = Field(default=None) + bantime_escalation: BantimeEscalationUpdate | None = Field( + default=None, + description="Incremental ban-time escalation settings to update.", + ) + + +# --------------------------------------------------------------------------- +# Regex tester models +# --------------------------------------------------------------------------- + + +class RegexTestRequest(BaseModel): + """Payload for ``POST /api/config/regex-test``.""" + + model_config = ConfigDict(strict=True) + + log_line: str = Field(..., description="Sample log line to test against.") + fail_regex: str = Field(..., description="Regex pattern to match.") + + +class RegexTestResponse(BaseModel): + """Result of a regex test.""" + + model_config = ConfigDict(strict=True) + + matched: bool = Field(..., description="Whether the pattern matched the log line.") + groups: list[str] = Field( + default_factory=list, + description="Named groups captured by a successful match.", + ) + error: str | None = Field( + default=None, + description="Compilation error message if the regex is invalid.", + ) + + +# --------------------------------------------------------------------------- +# Global config models +# --------------------------------------------------------------------------- + + +class GlobalConfigResponse(BaseModel): + """Response for ``GET /api/config/global``.""" + + model_config = ConfigDict(strict=True) + + log_level: str + log_target: str + db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.") + db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.") + + +class GlobalConfigUpdate(BaseModel): + """Payload for ``PUT /api/config/global``.""" + + model_config = ConfigDict(strict=True) + + log_level: str | None = Field( + default=None, + description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG.", + ) + log_target: str | None = Field( + default=None, + description="Log target: STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL, or a file path.", + ) + db_purge_age: int | None = Field(default=None, ge=0) + db_max_matches: int | None = Field(default=None, ge=0) + + +# --------------------------------------------------------------------------- +# Log observation / preview models +# --------------------------------------------------------------------------- + + +class AddLogPathRequest(BaseModel): + """Payload for ``POST /api/config/jails/{name}/logpath``.""" + + model_config = ConfigDict(strict=True) + + log_path: str = Field(..., description="Absolute path to the log file to monitor.") + tail: bool = Field( + default=True, + description="If true, monitor from current end of file (tail). If false, read from the beginning.", + ) + + +class LogPreviewRequest(BaseModel): + """Payload for ``POST /api/config/preview-log``.""" + + model_config = ConfigDict(strict=True) + + log_path: str = Field(..., description="Absolute path to the log file to preview.") + fail_regex: str = Field(..., description="Regex pattern to test against log lines.") + num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.") + + +class LogPreviewLine(BaseModel): + """A single log line with match information.""" + + model_config = ConfigDict(strict=True) + + line: str + matched: bool + groups: list[str] = Field(default_factory=list) + + +class LogPreviewResponse(BaseModel): + """Response for ``POST /api/config/preview-log``.""" + + model_config = ConfigDict(strict=True) + + lines: list[LogPreviewLine] = Field(default_factory=list) + total_lines: int = Field(..., ge=0) + matched_count: int = Field(..., ge=0) + regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.") + + +# --------------------------------------------------------------------------- +# Map color threshold models +# --------------------------------------------------------------------------- + + +class MapColorThresholdsResponse(BaseModel): + """Response for ``GET /api/config/map-thresholds``.""" + + model_config = ConfigDict(strict=True) + + threshold_high: int = Field( + ..., description="Ban count for red coloring." + ) + threshold_medium: int = Field( + ..., description="Ban count for yellow coloring." + ) + threshold_low: int = Field( + ..., description="Ban count for green coloring." + ) + + +class MapColorThresholdsUpdate(BaseModel): + """Payload for ``PUT /api/config/map-thresholds``.""" + + model_config = ConfigDict(strict=True) + + threshold_high: int = Field(..., gt=0, description="Ban count for red.") + threshold_medium: int = Field( + ..., gt=0, description="Ban count for yellow." + ) + threshold_low: int = Field(..., gt=0, description="Ban count for green.") + + +# --------------------------------------------------------------------------- +# Parsed filter file models +# --------------------------------------------------------------------------- + + +class FilterConfig(BaseModel): + """Structured representation of a ``filter.d/*.conf`` file. + + The ``active``, ``used_by_jails``, ``source_file``, and + ``has_local_override`` fields are populated by + :func:`~app.services.config_file_service.list_filters` and + :func:`~app.services.config_file_service.get_filter`. When the model is + returned from the raw file-based endpoints (``/filters/{name}/parsed``), + these fields carry their default values. + """ + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Filter base name, e.g. ``sshd``.") + filename: str = Field(..., description="Actual filename, e.g. ``sshd.conf``.") + # [INCLUDES] + before: str | None = Field(default=None, description="Included file read before this one.") + after: str | None = Field(default=None, description="Included file read after this one.") + # [DEFAULT] — free-form key=value pairs + variables: dict[str, str] = Field( + default_factory=dict, + description="Free-form ``[DEFAULT]`` section variables.", + ) + # [Definition] + prefregex: str | None = Field( + default=None, + description="Prefix regex prepended to every failregex.", + ) + failregex: list[str] = Field( + default_factory=list, + description="Failure detection regex patterns (one per list entry).", + ) + ignoreregex: list[str] = Field( + default_factory=list, + description="Regex patterns that bypass ban logic.", + ) + maxlines: int | None = Field( + default=None, + description="Maximum number of log lines accumulated for a single match attempt.", + ) + datepattern: str | None = Field( + default=None, + description="Custom date-parsing pattern, or ``None`` for auto-detect.", + ) + journalmatch: str | None = Field( + default=None, + description="Systemd journal match expression.", + ) + # Active-status fields — populated by config_file_service.list_filters / + # get_filter; default to safe "inactive" values when not computed. + active: bool = Field( + default=False, + description=( + "``True`` when this filter is referenced by at least one currently " + "enabled (running) jail." + ), + ) + used_by_jails: list[str] = Field( + default_factory=list, + description=( + "Names of currently enabled jails that reference this filter. " + "Empty when ``active`` is ``False``." + ), + ) + source_file: str = Field( + default="", + description="Absolute path to the ``.conf`` source file for this filter.", + ) + has_local_override: bool = Field( + default=False, + description=( + "``True`` when a ``.local`` override file exists alongside the " + "base ``.conf`` file." + ), + ) + + +class FilterConfigUpdate(BaseModel): + """Partial update payload for a parsed filter file. + + Only explicitly set (non-``None``) fields are written back. + """ + + model_config = ConfigDict(strict=True) + + before: str | None = Field(default=None) + after: str | None = Field(default=None) + variables: dict[str, str] | None = Field(default=None) + prefregex: str | None = Field(default=None) + failregex: list[str] | None = Field(default=None) + ignoreregex: list[str] | None = Field(default=None) + maxlines: int | None = Field(default=None) + datepattern: str | None = Field(default=None) + journalmatch: str | None = Field(default=None) + + +class FilterUpdateRequest(BaseModel): + """Payload for ``PUT /api/config/filters/{name}``. + + Accepts only the user-editable ``[Definition]`` fields. Fields left as + ``None`` are not changed; the existing value from the merged conf/local is + preserved. + """ + + model_config = ConfigDict(strict=True) + + failregex: list[str] | None = Field( + default=None, + description="Updated failure-detection regex patterns. ``None`` = keep existing.", + ) + ignoreregex: list[str] | None = Field( + default=None, + description="Updated bypass-ban regex patterns. ``None`` = keep existing.", + ) + datepattern: str | None = Field( + default=None, + description="Custom date-parsing pattern. ``None`` = keep existing.", + ) + journalmatch: str | None = Field( + default=None, + description="Systemd journal match expression. ``None`` = keep existing.", + ) + + +class FilterCreateRequest(BaseModel): + """Payload for ``POST /api/config/filters``. + + Creates a new user-defined filter at ``filter.d/{name}.local``. + """ + + model_config = ConfigDict(strict=True) + + name: str = Field( + ..., + description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.", + ) + failregex: list[str] = Field( + default_factory=list, + description="Failure-detection regex patterns.", + ) + ignoreregex: list[str] = Field( + default_factory=list, + description="Regex patterns that bypass ban logic.", + ) + prefregex: str | None = Field( + default=None, + description="Prefix regex prepended to every failregex.", + ) + datepattern: str | None = Field( + default=None, + description="Custom date-parsing pattern.", + ) + journalmatch: str | None = Field( + default=None, + description="Systemd journal match expression.", + ) + + +class AssignFilterRequest(BaseModel): + """Payload for ``POST /api/config/jails/{jail_name}/filter``.""" + + model_config = ConfigDict(strict=True) + + filter_name: str = Field( + ..., + description="Filter base name to assign to the jail (e.g. ``sshd``).", + ) + + +class FilterListResponse(BaseModel): + """Response for ``GET /api/config/filters``.""" + + model_config = ConfigDict(strict=True) + + filters: list[FilterConfig] = Field( + default_factory=list, + description=( + "All discovered filters, each annotated with active/inactive status " + "and the jails that reference them." + ), + ) + total: int = Field(..., ge=0, description="Total number of filters found.") + + +# --------------------------------------------------------------------------- +# Parsed action file models +# --------------------------------------------------------------------------- + + +class ActionConfig(BaseModel): + """Structured representation of an ``action.d/*.conf`` file.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Action base name, e.g. ``iptables``.") + filename: str = Field(..., description="Actual filename, e.g. ``iptables.conf``.") + # [INCLUDES] + before: str | None = Field(default=None) + after: str | None = Field(default=None) + # [Definition] — well-known lifecycle commands + actionstart: str | None = Field( + default=None, + description="Executed at jail start or first ban.", + ) + actionstop: str | None = Field( + default=None, + description="Executed at jail stop.", + ) + actioncheck: str | None = Field( + default=None, + description="Executed before each ban.", + ) + actionban: str | None = Field( + default=None, + description="Executed to ban an IP. Tags: ````, ````, ````.", + ) + actionunban: str | None = Field( + default=None, + description="Executed to unban an IP.", + ) + actionflush: str | None = Field( + default=None, + description="Executed to flush all bans on shutdown.", + ) + # [Definition] — extra variables not covered by the well-known keys + definition_vars: dict[str, str] = Field( + default_factory=dict, + description="Additional ``[Definition]`` variables.", + ) + # [Init] — runtime-configurable parameters + init_vars: dict[str, str] = Field( + default_factory=dict, + description="Runtime parameters that can be overridden per jail.", + ) + # Active-status fields — populated by config_file_service.list_actions / + # get_action; default to safe "inactive" values when not computed. + active: bool = Field( + default=False, + description=( + "``True`` when this action is referenced by at least one currently " + "enabled (running) jail." + ), + ) + used_by_jails: list[str] = Field( + default_factory=list, + description=( + "Names of currently enabled jails that reference this action. " + "Empty when ``active`` is ``False``." + ), + ) + source_file: str = Field( + default="", + description="Absolute path to the ``.conf`` source file for this action.", + ) + has_local_override: bool = Field( + default=False, + description=( + "``True`` when a ``.local`` override file exists alongside the " + "base ``.conf`` file." + ), + ) + + +class ActionConfigUpdate(BaseModel): + """Partial update payload for a parsed action file.""" + + model_config = ConfigDict(strict=True) + + before: str | None = Field(default=None) + after: str | None = Field(default=None) + actionstart: str | None = Field(default=None) + actionstop: str | None = Field(default=None) + actioncheck: str | None = Field(default=None) + actionban: str | None = Field(default=None) + actionunban: str | None = Field(default=None) + actionflush: str | None = Field(default=None) + definition_vars: dict[str, str] | None = Field(default=None) + init_vars: dict[str, str] | None = Field(default=None) + + +class ActionListResponse(BaseModel): + """Response for ``GET /api/config/actions``.""" + + model_config = ConfigDict(strict=True) + + actions: list[ActionConfig] = Field( + default_factory=list, + description=( + "All discovered actions, each annotated with active/inactive status " + "and the jails that reference them." + ), + ) + total: int = Field(..., ge=0, description="Total number of actions found.") + + +class ActionUpdateRequest(BaseModel): + """Payload for ``PUT /api/config/actions/{name}``. + + Accepts only the user-editable ``[Definition]`` lifecycle fields and + ``[Init]`` parameters. Fields left as ``None`` are not changed. + """ + + model_config = ConfigDict(strict=True) + + actionstart: str | None = Field( + default=None, + description="Updated ``actionstart`` command. ``None`` = keep existing.", + ) + actionstop: str | None = Field( + default=None, + description="Updated ``actionstop`` command. ``None`` = keep existing.", + ) + actioncheck: str | None = Field( + default=None, + description="Updated ``actioncheck`` command. ``None`` = keep existing.", + ) + actionban: str | None = Field( + default=None, + description="Updated ``actionban`` command. ``None`` = keep existing.", + ) + actionunban: str | None = Field( + default=None, + description="Updated ``actionunban`` command. ``None`` = keep existing.", + ) + actionflush: str | None = Field( + default=None, + description="Updated ``actionflush`` command. ``None`` = keep existing.", + ) + definition_vars: dict[str, str] | None = Field( + default=None, + description="Additional ``[Definition]`` variables to set. ``None`` = keep existing.", + ) + init_vars: dict[str, str] | None = Field( + default=None, + description="``[Init]`` parameters to set. ``None`` = keep existing.", + ) + + +class ActionCreateRequest(BaseModel): + """Payload for ``POST /api/config/actions``. + + Creates a new user-defined action at ``action.d/{name}.local``. + """ + + model_config = ConfigDict(strict=True) + + name: str = Field( + ..., + description="Action base name (e.g. ``my-custom-action``). Must not already exist.", + ) + actionstart: str | None = Field(default=None, description="Command to execute at jail start.") + actionstop: str | None = Field(default=None, description="Command to execute at jail stop.") + actioncheck: str | None = Field(default=None, description="Command to execute before each ban.") + actionban: str | None = Field(default=None, description="Command to execute to ban an IP.") + actionunban: str | None = Field(default=None, description="Command to execute to unban an IP.") + actionflush: str | None = Field(default=None, description="Command to flush all bans on shutdown.") + definition_vars: dict[str, str] = Field( + default_factory=dict, + description="Additional ``[Definition]`` variables.", + ) + init_vars: dict[str, str] = Field( + default_factory=dict, + description="``[Init]`` runtime parameters.", + ) + + +class AssignActionRequest(BaseModel): + """Payload for ``POST /api/config/jails/{jail_name}/action``.""" + + model_config = ConfigDict(strict=True) + + action_name: str = Field( + ..., + description="Action base name to add to the jail (e.g. ``iptables-multiport``).", + ) + params: dict[str, str] = Field( + default_factory=dict, + description=( + "Optional per-jail action parameters written as " + "``action_name[key=value, ...]`` in the jail config." + ), + ) + + +# --------------------------------------------------------------------------- +# Jail file config models (Task 6.1) +# --------------------------------------------------------------------------- + + +class JailSectionConfig(BaseModel): + """Settings within a single [jailname] section of a jail.d file.""" + + model_config = ConfigDict(strict=True) + + enabled: bool | None = Field(default=None, description="Whether this jail is enabled.") + port: str | None = Field(default=None, description="Port(s) to monitor (e.g. 'ssh' or '22,2222').") + filter: str | None = Field(default=None, description="Filter name to use (e.g. 'sshd').") + logpath: list[str] = Field(default_factory=list, description="Log file paths to monitor.") + maxretry: int | None = Field(default=None, ge=1, description="Failures before banning.") + findtime: int | None = Field(default=None, ge=1, description="Time window in seconds for counting failures.") + bantime: int | None = Field(default=None, description="Ban duration in seconds. -1 for permanent.") + action: list[str] = Field(default_factory=list, description="Action references.") + backend: str | None = Field(default=None, description="Log monitoring backend.") + extra: dict[str, str] = Field(default_factory=dict, description="Additional settings not captured by named fields.") + + +class JailFileConfig(BaseModel): + """Structured representation of a jail.d/*.conf file.""" + + model_config = ConfigDict(strict=True) + + filename: str = Field(..., description="Filename including extension (e.g. 'sshd.conf').") + jails: dict[str, JailSectionConfig] = Field( + default_factory=dict, + description="Mapping of jail name → settings for each [section] in the file.", + ) + + +class JailFileConfigUpdate(BaseModel): + """Partial update payload for a jail.d file.""" + + model_config = ConfigDict(strict=True) + + jails: dict[str, JailSectionConfig] | None = Field( + default=None, + description="Jail section updates. Only jails present in this dict are updated.", + ) + + +# --------------------------------------------------------------------------- +# Inactive jail models (Stage 1) +# --------------------------------------------------------------------------- + + +class InactiveJail(BaseModel): + """A jail defined in fail2ban config files that is not currently active. + + A jail is considered inactive when its ``enabled`` key is ``false`` (or + absent from the config, since fail2ban defaults to disabled) **or** when it + is explicitly enabled in config but fail2ban is not reporting it as + running. + """ + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Jail name from the config section header.") + filter: str = Field( + ..., + description=( + "Filter name used by this jail. May include fail2ban mode suffix, " + "e.g. ``sshd[mode=normal]``." + ), + ) + actions: list[str] = Field( + default_factory=list, + description="Action references listed in the config (raw strings).", + ) + port: str | None = Field( + default=None, + description="Port(s) to monitor, e.g. ``ssh`` or ``22,2222``.", + ) + logpath: list[str] = Field( + default_factory=list, + description="Log file paths to monitor.", + ) + bantime: str | None = Field( + default=None, + description="Ban duration as a raw config string, e.g. ``10m`` or ``-1``.", + ) + findtime: str | None = Field( + default=None, + description="Failure-counting window as a raw config string, e.g. ``10m``.", + ) + maxretry: int | None = Field( + default=None, + description="Number of failures before a ban is issued.", + ) + # ---- Extended fields for full GUI display ---- + ban_time_seconds: int = Field( + default=600, + description="Ban duration in seconds, parsed from bantime string.", + ) + find_time_seconds: int = Field( + default=600, + description="Failure-counting window in seconds, parsed from findtime string.", + ) + log_encoding: str = Field( + default="auto", + description="Log encoding, e.g. ``utf-8`` or ``auto``.", + ) + backend: str = Field( + default="auto", + description="Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.", + ) + date_pattern: str | None = Field( + default=None, + description="Date pattern for log parsing, or None for auto-detect.", + ) + use_dns: str = Field( + default="warn", + description="DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.", + ) + prefregex: str = Field( + default="", + description="Prefix regex prepended to every failregex.", + ) + fail_regex: list[str] = Field( + default_factory=list, + description="List of failure regex patterns.", + ) + ignore_regex: list[str] = Field( + default_factory=list, + description="List of ignore regex patterns.", + ) + bantime_escalation: BantimeEscalation | None = Field( + default=None, + description="Ban-time escalation configuration, if enabled.", + ) + source_file: str = Field( + ..., + description="Absolute path to the config file where this jail is defined.", + ) + enabled: bool = Field( + ..., + description=( + "Effective ``enabled`` value from the merged config. ``False`` for " + "inactive jails that appear in this list." + ), + ) + + +class InactiveJailListResponse(BaseModel): + """Response for ``GET /api/config/jails/inactive``.""" + + model_config = ConfigDict(strict=True) + + jails: list[InactiveJail] = Field(default_factory=list) + total: int = Field(..., ge=0) + + +class ActivateJailRequest(BaseModel): + """Optional override values when activating an inactive jail. + + All fields are optional. Omitted fields are not written to the + ``.local`` override file so that fail2ban falls back to its default + values. + """ + + model_config = ConfigDict(strict=True) + + bantime: str | None = Field( + default=None, + description="Override ban duration, e.g. ``1h`` or ``3600``.", + ) + findtime: str | None = Field( + default=None, + description="Override failure-counting window, e.g. ``10m``.", + ) + maxretry: int | None = Field( + default=None, + ge=1, + description="Override maximum failures before a ban.", + ) + port: str | None = Field( + default=None, + description="Override port(s) to monitor.", + ) + logpath: list[str] | None = Field( + default=None, + description="Override log file paths.", + ) + + +class JailActivationResponse(BaseModel): + """Response for jail activation and deactivation endpoints.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Name of the affected jail.") + active: bool = Field( + ..., + description="New activation state: ``True`` after activate, ``False`` after deactivate.", + ) + message: str = Field(..., description="Human-readable result message.") + fail2ban_running: bool = Field( + default=True, + description=( + "Whether the fail2ban daemon is still running after the activation " + "and reload. ``False`` signals that the daemon may have crashed." + ), + ) + validation_warnings: list[str] = Field( + default_factory=list, + description="Non-fatal warnings from the pre-activation validation step.", + ) + recovered: bool | None = Field( + default=None, + description=( + "Set when activation failed after writing the config file. " + "``True`` means the system automatically rolled back the change and " + "restarted fail2ban. ``False`` means the rollback itself also " + "failed and manual intervention is required. ``None`` when " + "activation succeeded or failed before the file was written." + ), + ) + + +# --------------------------------------------------------------------------- +# Jail validation models (Task 3) +# --------------------------------------------------------------------------- + + +class JailValidationIssue(BaseModel): + """A single issue found during pre-activation validation of a jail config.""" + + model_config = ConfigDict(strict=True) + + field: str = Field( + ..., + description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.", + ) + message: str = Field(..., description="Human-readable description of the issue.") + + +class JailValidationResult(BaseModel): + """Result of pre-activation validation of a single jail configuration.""" + + model_config = ConfigDict(strict=True) + + jail_name: str = Field(..., description="Name of the validated jail.") + valid: bool = Field(..., description="True when no issues were found.") + issues: list[JailValidationIssue] = Field( + default_factory=list, + description="Validation issues found. Empty when valid=True.", + ) + + +# --------------------------------------------------------------------------- +# Rollback response model (Task 3) +# --------------------------------------------------------------------------- + + +class RollbackResponse(BaseModel): + """Response for ``POST /api/config/jails/{name}/rollback``.""" + + model_config = ConfigDict(strict=True) + + jail_name: str = Field(..., description="Name of the jail that was disabled.") + disabled: bool = Field( + ..., + description="Whether the jail's .local override was successfully written with enabled=false.", + ) + fail2ban_running: bool = Field( + ..., + description="Whether fail2ban is online after the rollback attempt.", + ) + active_jails: int = Field( + default=0, + ge=0, + description="Number of currently active jails after a successful restart.", + ) + message: str = Field(..., description="Human-readable result message.") + + +# --------------------------------------------------------------------------- +# Pending recovery model (Task 3) +# --------------------------------------------------------------------------- + + +class PendingRecovery(BaseModel): + """Records a probable activation-caused fail2ban crash pending user action.""" + + model_config = ConfigDict(strict=True) + + jail_name: str = Field( + ..., + description="Name of the jail whose activation likely caused the crash.", + ) + activated_at: datetime.datetime = Field( + ..., + description="ISO-8601 UTC timestamp of when the jail was activated.", + ) + detected_at: datetime.datetime = Field( + ..., + description="ISO-8601 UTC timestamp of when the crash was detected.", + ) + recovered: bool = Field( + default=False, + description="Whether fail2ban has been successfully restarted.", + ) + + +# --------------------------------------------------------------------------- +# fail2ban log viewer models +# --------------------------------------------------------------------------- + + +class Fail2BanLogResponse(BaseModel): + """Response for ``GET /api/config/fail2ban-log``.""" + + model_config = ConfigDict(strict=True) + + log_path: str = Field(..., description="Resolved absolute path of the log file being read.") + lines: list[str] = Field(default_factory=list, description="Log lines returned (tail, optionally filtered).") + total_lines: int = Field(..., ge=0, description="Total number of lines in the file before filtering.") + log_level: str = Field(..., description="Current fail2ban log level.") + log_target: str = Field(..., description="Current fail2ban log target (file path or special value).") + + +class ServiceStatusResponse(BaseModel): + """Response for ``GET /api/config/service-status``.""" + + model_config = ConfigDict(strict=True) + + 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.") + 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_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.") + log_level: str = Field(default="UNKNOWN", description="Current fail2ban log level.") + log_target: str = Field(default="UNKNOWN", description="Current fail2ban log target.") diff --git a/backend/app/models/file_config.py b/backend/app/models/file_config.py new file mode 100644 index 0000000..f77dbe3 --- /dev/null +++ b/backend/app/models/file_config.py @@ -0,0 +1,109 @@ +"""Pydantic models for file-based fail2ban configuration management. + +Covers jail config files (``jail.d/``), filter definitions (``filter.d/``), +and action definitions (``action.d/``). +""" + +from pydantic import BaseModel, ConfigDict, Field + +# --------------------------------------------------------------------------- +# Jail config file models (Task 4a) +# --------------------------------------------------------------------------- + + +class JailConfigFile(BaseModel): + """Metadata for a single jail configuration file in ``jail.d/``.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Jail name (file stem, e.g. ``sshd``).") + filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).") + enabled: bool = Field( + ..., + description=( + "Whether the jail is enabled. Derived from the ``enabled`` key " + "inside the file; defaults to ``true`` when the key is absent." + ), + ) + + +class JailConfigFilesResponse(BaseModel): + """Response for ``GET /api/config/jail-files``.""" + + model_config = ConfigDict(strict=True) + + files: list[JailConfigFile] = Field(default_factory=list) + total: int = Field(..., ge=0) + + +class JailConfigFileContent(BaseModel): + """Single jail config file with its raw content.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Jail name (file stem).") + filename: str = Field(..., description="Actual filename.") + enabled: bool = Field(..., description="Whether the jail is enabled.") + content: str = Field(..., description="Raw file content.") + + +class JailConfigFileEnabledUpdate(BaseModel): + """Payload for ``PUT /api/config/jail-files/{filename}/enabled``.""" + + model_config = ConfigDict(strict=True) + + enabled: bool = Field(..., description="New enabled state for this jail.") + + +# --------------------------------------------------------------------------- +# Generic conf-file entry (shared by filter.d and action.d) +# --------------------------------------------------------------------------- + + +class ConfFileEntry(BaseModel): + """Metadata for a single ``.conf`` or ``.local`` file.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Base name without extension (e.g. ``sshd``).") + filename: str = Field(..., description="Actual filename (e.g. ``sshd.conf``).") + + +class ConfFilesResponse(BaseModel): + """Response for list endpoints (``GET /api/config/filters`` and ``GET /api/config/actions``).""" + + model_config = ConfigDict(strict=True) + + files: list[ConfFileEntry] = Field(default_factory=list) + total: int = Field(..., ge=0) + + +class ConfFileContent(BaseModel): + """A conf file with its raw text content.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Base name without extension.") + filename: str = Field(..., description="Actual filename.") + content: str = Field(..., description="Raw file content.") + + +class ConfFileUpdateRequest(BaseModel): + """Payload for ``PUT /api/config/filters/{name}`` and ``PUT /api/config/actions/{name}``.""" + + model_config = ConfigDict(strict=True) + + content: str = Field(..., description="New raw file content (must not exceed 512 KB).") + + +class ConfFileCreateRequest(BaseModel): + """Payload for ``POST /api/config/filters`` and ``POST /api/config/actions``.""" + + model_config = ConfigDict(strict=True) + + name: str = Field( + ..., + description="New file base name (without extension). Must contain only " + "alphanumeric characters, hyphens, underscores, and dots.", + ) + content: str = Field(..., description="Initial raw file content (must not exceed 512 KB).") diff --git a/backend/app/models/geo.py b/backend/app/models/geo.py new file mode 100644 index 0000000..6b06508 --- /dev/null +++ b/backend/app/models/geo.py @@ -0,0 +1,66 @@ +"""Geo and IP lookup Pydantic models. + +Response models for the ``GET /api/geo/lookup/{ip}`` endpoint. +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class GeoDetail(BaseModel): + """Enriched geolocation data for an IP address. + + Populated from the ip-api.com free API. + """ + + model_config = ConfigDict(strict=True) + + country_code: str | None = Field( + default=None, + description="ISO 3166-1 alpha-2 country code.", + ) + country_name: str | None = Field( + default=None, + description="Human-readable country name.", + ) + asn: str | None = Field( + default=None, + description="Autonomous System Number (e.g. ``'AS3320'``).", + ) + org: str | None = Field( + default=None, + description="Organisation associated with the ASN.", + ) + + +class GeoCacheStatsResponse(BaseModel): + """Response for ``GET /api/geo/stats``. + + Exposes diagnostic counters of the geo cache subsystem so operators + can assess resolution health from the UI or CLI. + """ + + model_config = ConfigDict(strict=True) + + cache_size: int = Field(..., description="Number of positive entries in the in-memory cache.") + unresolved: int = Field(..., description="Number of geo_cache rows with country_code IS NULL.") + neg_cache_size: int = Field(..., description="Number of entries in the in-memory negative cache.") + dirty_size: int = Field(..., description="Number of newly resolved entries not yet flushed to disk.") + + +class IpLookupResponse(BaseModel): + """Response for ``GET /api/geo/lookup/{ip}``. + + Aggregates current ban status and geographical information for an IP. + """ + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="The queried IP address.") + currently_banned_in: list[str] = Field( + default_factory=list, + description="Names of jails where this IP is currently banned.", + ) + geo: GeoDetail | None = Field( + default=None, + description="Enriched geographical and network information.", + ) diff --git a/backend/app/models/history.py b/backend/app/models/history.py new file mode 100644 index 0000000..8fbac56 --- /dev/null +++ b/backend/app/models/history.py @@ -0,0 +1,142 @@ +"""Ban history Pydantic models. + +Request, response, and domain models used by the history router and service. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.ban import TimeRange + +__all__ = [ + "HistoryBanItem", + "HistoryListResponse", + "IpDetailResponse", + "IpTimelineEvent", + "TimeRange", +] + + +class HistoryBanItem(BaseModel): + """A single row in the history ban-list table. + + Populated from the fail2ban database and optionally enriched with + geolocation data. + """ + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="Banned IP address.") + jail: str = Field(..., description="Jail that issued the ban.") + banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") + ban_count: int = Field(..., ge=1, description="How many times this IP was banned.") + failures: int = Field( + default=0, + ge=0, + description="Total failure count extracted from the ``data`` column.", + ) + matches: list[str] = Field( + default_factory=list, + description="Matched log lines stored in the ``data`` column.", + ) + country_code: str | None = Field( + default=None, + description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.", + ) + country_name: str | None = Field( + default=None, + description="Human-readable country name, or ``null`` if unknown.", + ) + asn: str | None = Field( + default=None, + description="Autonomous System Number string (e.g. ``'AS3320'``).", + ) + org: str | None = Field( + default=None, + description="Organisation name associated with the IP.", + ) + + +class HistoryListResponse(BaseModel): + """Paginated history ban-list response.""" + + model_config = ConfigDict(strict=True) + + items: list[HistoryBanItem] = Field(default_factory=list) + total: int = Field(..., ge=0, description="Total matching records.") + page: int = Field(..., ge=1) + page_size: int = Field(..., ge=1) + + +# --------------------------------------------------------------------------- +# Per-IP timeline +# --------------------------------------------------------------------------- + + +class IpTimelineEvent(BaseModel): + """A single ban event in a per-IP timeline. + + Represents one row from the fail2ban ``bans`` table for a specific IP. + """ + + model_config = ConfigDict(strict=True) + + jail: str = Field(..., description="Jail that triggered this ban.") + banned_at: str = Field(..., description="ISO 8601 UTC timestamp of the ban.") + ban_count: int = Field( + ..., + ge=1, + description="Running ban counter for this IP at the time of this event.", + ) + failures: int = Field( + default=0, + ge=0, + description="Failure count at the time of the ban.", + ) + matches: list[str] = Field( + default_factory=list, + description="Matched log lines that triggered the ban.", + ) + + +class IpDetailResponse(BaseModel): + """Full historical record for a single IP address. + + Contains aggregated totals and a chronological timeline of all ban events + recorded in the fail2ban database for the given IP. + """ + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="The IP address.") + total_bans: int = Field(..., ge=0, description="Total number of ban records.") + total_failures: int = Field( + ..., + ge=0, + description="Sum of all failure counts across all ban events.", + ) + last_ban_at: str | None = Field( + default=None, + description="ISO 8601 UTC timestamp of the most recent ban, or ``null``.", + ) + country_code: str | None = Field( + default=None, + description="ISO 3166-1 alpha-2 country code, or ``null`` if unknown.", + ) + country_name: str | None = Field( + default=None, + description="Human-readable country name, or ``null`` if unknown.", + ) + asn: str | None = Field( + default=None, + description="Autonomous System Number string.", + ) + org: str | None = Field( + default=None, + description="Organisation name associated with the IP.", + ) + timeline: list[IpTimelineEvent] = Field( + default_factory=list, + description="All ban events for this IP, ordered newest-first.", + ) diff --git a/backend/app/models/jail.py b/backend/app/models/jail.py new file mode 100644 index 0000000..b0ee149 --- /dev/null +++ b/backend/app/models/jail.py @@ -0,0 +1,96 @@ +"""Jail management Pydantic models. + +Request, response, and domain models used by the jails router and service. +""" + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.config import BantimeEscalation + + +class JailStatus(BaseModel): + """Runtime metrics for a single jail.""" + + model_config = ConfigDict(strict=True) + + currently_banned: int = Field(..., ge=0) + total_banned: int = Field(..., ge=0) + currently_failed: int = Field(..., ge=0) + total_failed: int = Field(..., ge=0) + + +class Jail(BaseModel): + """Domain model for a single fail2ban jail with its full configuration.""" + + model_config = ConfigDict(strict=True) + + name: str = Field(..., description="Jail name as configured in fail2ban.") + enabled: bool = Field(..., description="Whether the jail is currently active.") + running: bool = Field(..., description="Whether the jail backend is running.") + idle: bool = Field(default=False, description="Whether the jail is in idle mode.") + backend: str = Field(..., description="Log monitoring backend (e.g. polling, systemd).") + log_paths: list[str] = Field(default_factory=list, description="Monitored log files.") + fail_regex: list[str] = Field(default_factory=list, description="Failure detection regex patterns.") + ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.") + ignore_ips: list[str] = Field(default_factory=list, description="IP addresses or CIDRs on the ignore list.") + date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.") + log_encoding: str = Field(default="UTF-8", description="Log file encoding.") + find_time: int = Field(..., description="Time window (seconds) for counting failures.") + ban_time: int = Field(..., description="Duration (seconds) of a ban. -1 means permanent.") + max_retry: int = Field(..., description="Number of failures before a ban is issued.") + actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.") + bantime_escalation: BantimeEscalation | None = Field( + default=None, + description="Incremental ban-time escalation settings, or None if not configured.", + ) + status: JailStatus | None = Field(default=None, description="Runtime counters.") + + +class JailSummary(BaseModel): + """Lightweight jail entry for the overview list.""" + + model_config = ConfigDict(strict=True) + + name: str + enabled: bool + running: bool + idle: bool + backend: str + find_time: int + ban_time: int + max_retry: int + status: JailStatus | None = None + + +class JailListResponse(BaseModel): + """Response for ``GET /api/jails``.""" + + model_config = ConfigDict(strict=True) + + jails: list[JailSummary] = Field(default_factory=list) + total: int = Field(..., ge=0) + + +class JailDetailResponse(BaseModel): + """Response for ``GET /api/jails/{name}``.""" + + model_config = ConfigDict(strict=True) + + jail: Jail + + +class JailCommandResponse(BaseModel): + """Generic response for jail control commands (start, stop, reload, idle).""" + + model_config = ConfigDict(strict=True) + + message: str + jail: str + + +class IgnoreIpRequest(BaseModel): + """Payload for adding an IP or network to a jail's ignore list.""" + + model_config = ConfigDict(strict=True) + + ip: str = Field(..., description="IP address or CIDR network to ignore.") diff --git a/backend/app/models/server.py b/backend/app/models/server.py new file mode 100644 index 0000000..0e572a0 --- /dev/null +++ b/backend/app/models/server.py @@ -0,0 +1,58 @@ +"""Server status and health-check Pydantic models. + +Used by the dashboard router, health service, and server settings router. +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class ServerStatus(BaseModel): + """Cached fail2ban server health snapshot.""" + + model_config = ConfigDict(strict=True) + + online: bool = Field(..., description="Whether fail2ban is reachable via its socket.") + version: str | None = Field(default=None, description="fail2ban version string.") + active_jails: 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_failures: int = Field(default=0, ge=0, description="Aggregated current failure count across all jails.") + + +class ServerStatusResponse(BaseModel): + """Response for ``GET /api/dashboard/status``.""" + + model_config = ConfigDict(strict=True) + + status: ServerStatus + + +class ServerSettings(BaseModel): + """Domain model for fail2ban server-level settings.""" + + model_config = ConfigDict(strict=True) + + log_level: str = Field(..., description="fail2ban daemon log level.") + log_target: str = Field(..., description="Log destination: STDOUT, STDERR, SYSLOG, or a file path.") + syslog_socket: str | None = Field(default=None) + db_path: str = Field(..., description="Path to the fail2ban ban history database.") + db_purge_age: int = Field(..., description="Seconds before old records are purged.") + db_max_matches: int = Field(..., description="Maximum stored matches per ban record.") + + +class ServerSettingsUpdate(BaseModel): + """Payload for ``PUT /api/server/settings``.""" + + model_config = ConfigDict(strict=True) + + log_level: str | None = Field(default=None) + log_target: str | None = Field(default=None) + db_purge_age: int | None = Field(default=None, ge=0) + db_max_matches: int | None = Field(default=None, ge=0) + + +class ServerSettingsResponse(BaseModel): + """Response for ``GET /api/server/settings``.""" + + model_config = ConfigDict(strict=True) + + settings: ServerSettings diff --git a/backend/app/models/setup.py b/backend/app/models/setup.py new file mode 100644 index 0000000..4e6a55e --- /dev/null +++ b/backend/app/models/setup.py @@ -0,0 +1,64 @@ +"""Setup wizard Pydantic models. + +Request, response, and domain models for the first-run configuration wizard. +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class SetupRequest(BaseModel): + """Payload for ``POST /api/setup``.""" + + model_config = ConfigDict(strict=True) + + master_password: str = Field( + ..., + min_length=8, + description="Master password that protects the BanGUI interface.", + ) + database_path: str = Field( + default="bangui.db", + description="Filesystem path to the BanGUI SQLite application database.", + ) + fail2ban_socket: str = Field( + default="/var/run/fail2ban/fail2ban.sock", + description="Path to the fail2ban Unix domain socket.", + ) + timezone: str = Field( + default="UTC", + description="IANA timezone name used when displaying timestamps.", + ) + session_duration_minutes: int = Field( + default=60, + ge=1, + description="Number of minutes a user session remains valid.", + ) + + +class SetupResponse(BaseModel): + """Response returned after a successful initial setup.""" + + model_config = ConfigDict(strict=True) + + message: str = Field( + default="Setup completed successfully. Please log in.", + ) + + +class SetupTimezoneResponse(BaseModel): + """Response for ``GET /api/setup/timezone``.""" + + model_config = ConfigDict(strict=True) + + timezone: str = Field(..., description="Configured IANA timezone identifier.") + + +class SetupStatusResponse(BaseModel): + """Response indicating whether setup has been completed.""" + + model_config = ConfigDict(strict=True) + + completed: bool = Field( + ..., + description="``True`` if the initial setup has already been performed.", + ) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..3fe590d --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1 @@ +"""Database access layer (repositories) package.""" diff --git a/backend/app/repositories/blocklist_repo.py b/backend/app/repositories/blocklist_repo.py new file mode 100644 index 0000000..dd6b49d --- /dev/null +++ b/backend/app/repositories/blocklist_repo.py @@ -0,0 +1,187 @@ +"""Blocklist sources repository. + +CRUD operations for the ``blocklist_sources`` table in the application +SQLite database. All methods accept a :class:`aiosqlite.Connection` — no +ORM, no HTTP exceptions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import aiosqlite + + +async def create_source( + db: aiosqlite.Connection, + name: str, + url: str, + *, + enabled: bool = True, +) -> int: + """Insert a new blocklist source and return its generated id. + + Args: + db: Active aiosqlite connection. + name: Human-readable display name. + url: URL of the blocklist text file. + enabled: Whether the source is active. Defaults to ``True``. + + Returns: + The ``ROWID`` / primary key of the new row. + """ + cursor = await db.execute( + """ + INSERT INTO blocklist_sources (name, url, enabled) + VALUES (?, ?, ?) + """, + (name, url, int(enabled)), + ) + await db.commit() + return int(cursor.lastrowid) # type: ignore[arg-type] + + +async def get_source( + db: aiosqlite.Connection, + source_id: int, +) -> dict[str, Any] | None: + """Return a single blocklist source row as a plain dict, or ``None``. + + Args: + db: Active aiosqlite connection. + source_id: Primary key of the source to retrieve. + + Returns: + A dict with keys matching the ``blocklist_sources`` columns, or + ``None`` if no row with that id exists. + """ + async with db.execute( + "SELECT id, name, url, enabled, created_at, updated_at FROM blocklist_sources WHERE id = ?", + (source_id,), + ) as cursor: + row = await cursor.fetchone() + if row is None: + return None + return _row_to_dict(row) + + +async def list_sources(db: aiosqlite.Connection) -> list[dict[str, Any]]: + """Return all blocklist sources ordered by id ascending. + + Args: + db: Active aiosqlite connection. + + Returns: + List of dicts, one per row in ``blocklist_sources``. + """ + async with db.execute( + "SELECT id, name, url, enabled, created_at, updated_at FROM blocklist_sources ORDER BY id" + ) as cursor: + rows = await cursor.fetchall() + return [_row_to_dict(r) for r in rows] + + +async def list_enabled_sources(db: aiosqlite.Connection) -> list[dict[str, Any]]: + """Return only enabled blocklist sources ordered by id. + + Args: + db: Active aiosqlite connection. + + Returns: + List of dicts for rows where ``enabled = 1``. + """ + async with db.execute( + "SELECT id, name, url, enabled, created_at, updated_at FROM blocklist_sources WHERE enabled = 1 ORDER BY id" + ) as cursor: + rows = await cursor.fetchall() + return [_row_to_dict(r) for r in rows] + + +async def update_source( + db: aiosqlite.Connection, + source_id: int, + *, + name: str | None = None, + url: str | None = None, + enabled: bool | None = None, +) -> bool: + """Update one or more fields on a blocklist source. + + Only the keyword arguments that are not ``None`` are included in the + ``UPDATE`` statement. + + Args: + db: Active aiosqlite connection. + source_id: Primary key of the source to update. + name: New display name, or ``None`` to leave unchanged. + url: New URL, or ``None`` to leave unchanged. + enabled: New enabled flag, or ``None`` to leave unchanged. + + Returns: + ``True`` if a row was updated, ``False`` if the id does not exist. + """ + fields: list[str] = [] + params: list[Any] = [] + + if name is not None: + fields.append("name = ?") + params.append(name) + if url is not None: + fields.append("url = ?") + params.append(url) + if enabled is not None: + fields.append("enabled = ?") + params.append(int(enabled)) + + if not fields: + # Nothing to update — treat as success only if the row exists. + return await get_source(db, source_id) is not None + + fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')") + params.append(source_id) + + cursor = await db.execute( + f"UPDATE blocklist_sources SET {', '.join(fields)} WHERE id = ?", # noqa: S608 + params, + ) + await db.commit() + return cursor.rowcount > 0 + + +async def delete_source(db: aiosqlite.Connection, source_id: int) -> bool: + """Delete a blocklist source by id. + + Args: + db: Active aiosqlite connection. + source_id: Primary key of the source to remove. + + Returns: + ``True`` if a row was deleted, ``False`` if the id did not exist. + """ + cursor = await db.execute( + "DELETE FROM blocklist_sources WHERE id = ?", + (source_id,), + ) + await db.commit() + return cursor.rowcount > 0 + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _row_to_dict(row: Any) -> dict[str, Any]: + """Convert an aiosqlite row to a plain Python dict. + + Args: + row: An :class:`aiosqlite.Row` or sequence returned by a cursor. + + Returns: + ``dict`` mapping column names to values with ``enabled`` cast to + ``bool``. + """ + d: dict[str, Any] = dict(row) + d["enabled"] = bool(d["enabled"]) + return d diff --git a/backend/app/repositories/import_log_repo.py b/backend/app/repositories/import_log_repo.py new file mode 100644 index 0000000..6ec284e --- /dev/null +++ b/backend/app/repositories/import_log_repo.py @@ -0,0 +1,155 @@ +"""Import log repository. + +Persists and queries blocklist import run records in the ``import_log`` +table. All methods are plain async functions that accept a +:class:`aiosqlite.Connection`. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import aiosqlite + + +async def add_log( + db: aiosqlite.Connection, + *, + source_id: int | None, + source_url: str, + ips_imported: int, + ips_skipped: int, + errors: str | None, +) -> int: + """Insert a new import log entry and return its id. + + Args: + db: Active aiosqlite connection. + source_id: FK to ``blocklist_sources.id``, or ``None`` if the source + has been deleted since the import ran. + source_url: URL that was downloaded. + ips_imported: Number of IPs successfully applied as bans. + ips_skipped: Number of lines that were skipped (invalid or CIDR). + errors: Error message string, or ``None`` if the import succeeded. + + Returns: + Primary key of the inserted row. + """ + cursor = await db.execute( + """ + INSERT INTO import_log (source_id, source_url, ips_imported, ips_skipped, errors) + VALUES (?, ?, ?, ?, ?) + """, + (source_id, source_url, ips_imported, ips_skipped, errors), + ) + await db.commit() + return int(cursor.lastrowid) # type: ignore[arg-type] + + +async def list_logs( + db: aiosqlite.Connection, + *, + source_id: int | None = None, + page: int = 1, + page_size: int = 50, +) -> tuple[list[dict[str, Any]], int]: + """Return a paginated list of import log entries. + + Args: + db: Active aiosqlite connection. + source_id: If given, filter to logs for this source only. + page: 1-based page index. + page_size: Number of items per page. + + Returns: + A 2-tuple ``(items, total)`` where *items* is a list of dicts and + *total* is the count of all matching rows (ignoring pagination). + """ + where = "" + params_count: list[Any] = [] + params_rows: list[Any] = [] + + if source_id is not None: + where = " WHERE source_id = ?" + params_count.append(source_id) + params_rows.append(source_id) + + # Total count + async with db.execute( + f"SELECT COUNT(*) FROM import_log{where}", # noqa: S608 + params_count, + ) as cursor: + count_row = await cursor.fetchone() + total: int = int(count_row[0]) if count_row else 0 + + offset = (page - 1) * page_size + params_rows.extend([page_size, offset]) + + async with db.execute( + f""" + SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors + FROM import_log{where} + ORDER BY id DESC + LIMIT ? OFFSET ? + """, # noqa: S608 + params_rows, + ) as cursor: + rows = await cursor.fetchall() + items = [_row_to_dict(r) for r in rows] + + return items, total + + +async def get_last_log(db: aiosqlite.Connection) -> dict[str, Any] | None: + """Return the most recent import log entry across all sources. + + Args: + db: Active aiosqlite connection. + + Returns: + The latest log entry as a dict, or ``None`` if no logs exist. + """ + async with db.execute( + """ + SELECT id, source_id, source_url, timestamp, ips_imported, ips_skipped, errors + FROM import_log + ORDER BY id DESC + LIMIT 1 + """ + ) as cursor: + row = await cursor.fetchone() + return _row_to_dict(row) if row is not None else None + + +def compute_total_pages(total: int, page_size: int) -> int: + """Return the total number of pages for a given total and page size. + + Args: + total: Total number of items. + page_size: Items per page. + + Returns: + Number of pages (minimum 1). + """ + if total == 0: + return 1 + return math.ceil(total / page_size) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _row_to_dict(row: Any) -> dict[str, Any]: + """Convert an aiosqlite row to a plain Python dict. + + Args: + row: An :class:`aiosqlite.Row` or sequence returned by a cursor. + + Returns: + Dict mapping column names to Python values. + """ + return dict(row) diff --git a/backend/app/repositories/session_repo.py b/backend/app/repositories/session_repo.py new file mode 100644 index 0000000..94cb7f0 --- /dev/null +++ b/backend/app/repositories/session_repo.py @@ -0,0 +1,100 @@ +"""Session repository. + +Provides storage, retrieval, and deletion of session records in the +``sessions`` table of the application SQLite database. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import aiosqlite + +from app.models.auth import Session + + +async def create_session( + db: aiosqlite.Connection, + token: str, + created_at: str, + expires_at: str, +) -> Session: + """Insert a new session row and return the domain model. + + Args: + db: Active aiosqlite connection. + token: Opaque random session token (hex string). + created_at: ISO 8601 UTC creation timestamp. + expires_at: ISO 8601 UTC expiry timestamp. + + Returns: + The newly created :class:`~app.models.auth.Session`. + """ + cursor = await db.execute( + "INSERT INTO sessions (token, created_at, expires_at) VALUES (?, ?, ?)", + (token, created_at, expires_at), + ) + await db.commit() + return Session( + id=int(cursor.lastrowid) if cursor.lastrowid else 0, + token=token, + created_at=created_at, + expires_at=expires_at, + ) + + +async def get_session(db: aiosqlite.Connection, token: str) -> Session | None: + """Look up a session by its token. + + Args: + db: Active aiosqlite connection. + token: The session token to retrieve. + + Returns: + The :class:`~app.models.auth.Session` if found, else ``None``. + """ + async with db.execute( + "SELECT id, token, created_at, expires_at FROM sessions WHERE token = ?", + (token,), + ) as cursor: + row = await cursor.fetchone() + + if row is None: + return None + + return Session( + id=int(row[0]), + token=str(row[1]), + created_at=str(row[2]), + expires_at=str(row[3]), + ) + + +async def delete_session(db: aiosqlite.Connection, token: str) -> None: + """Delete a session by token (logout / expiry clean-up). + + Args: + db: Active aiosqlite connection. + token: The session token to remove. + """ + await db.execute("DELETE FROM sessions WHERE token = ?", (token,)) + await db.commit() + + +async def delete_expired_sessions(db: aiosqlite.Connection, now_iso: str) -> int: + """Remove all sessions whose ``expires_at`` timestamp is in the past. + + Args: + db: Active aiosqlite connection. + now_iso: Current UTC time as ISO 8601 string used as the cutoff. + + Returns: + Number of rows deleted. + """ + cursor = await db.execute( + "DELETE FROM sessions WHERE expires_at <= ?", + (now_iso,), + ) + await db.commit() + return int(cursor.rowcount) diff --git a/backend/app/repositories/settings_repo.py b/backend/app/repositories/settings_repo.py new file mode 100644 index 0000000..e813013 --- /dev/null +++ b/backend/app/repositories/settings_repo.py @@ -0,0 +1,71 @@ +"""Settings repository. + +Provides CRUD operations for the ``settings`` key-value table in the +application SQLite database. All methods are plain async functions that +accept a :class:`aiosqlite.Connection` — no ORM, no HTTP exceptions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import aiosqlite + + +async def get_setting(db: aiosqlite.Connection, key: str) -> str | None: + """Return the value for *key*, or ``None`` if it does not exist. + + Args: + db: Active aiosqlite connection. + key: The setting key to look up. + + Returns: + The stored value string, or ``None`` if the key is absent. + """ + async with db.execute( + "SELECT value FROM settings WHERE key = ?", + (key,), + ) as cursor: + row = await cursor.fetchone() + return str(row[0]) if row is not None else None + + +async def set_setting(db: aiosqlite.Connection, key: str, value: str) -> None: + """Insert or replace the setting identified by *key*. + + Args: + db: Active aiosqlite connection. + key: The setting key. + value: The value to store. + """ + await db.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, value), + ) + await db.commit() + + +async def delete_setting(db: aiosqlite.Connection, key: str) -> None: + """Delete the setting identified by *key* if it exists. + + Args: + db: Active aiosqlite connection. + key: The setting key to remove. + """ + await db.execute("DELETE FROM settings WHERE key = ?", (key,)) + await db.commit() + + +async def get_all_settings(db: aiosqlite.Connection) -> dict[str, str]: + """Return all settings as a plain ``dict``. + + Args: + db: Active aiosqlite connection. + + Returns: + A dictionary mapping every stored key to its value. + """ + async with db.execute("SELECT key, value FROM settings") as cursor: + rows = await cursor.fetchall() + return {str(row[0]): str(row[1]) for row in rows} diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..552f91f --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers package.""" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..28922ed --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,129 @@ +"""Authentication router. + +``POST /api/auth/login`` — verify master password and issue a session. +``POST /api/auth/logout`` — revoke the current session. + +The session token is returned both in the JSON body (for API-first +consumers) and as an ``HttpOnly`` cookie (for the browser SPA). +""" + +from __future__ import annotations + +import structlog +from fastapi import APIRouter, HTTPException, Request, Response, status + +from app.dependencies import DbDep, SettingsDep, invalidate_session_cache +from app.models.auth import LoginRequest, LoginResponse, LogoutResponse +from app.services import auth_service + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +_COOKIE_NAME = "bangui_session" + + +@router.post( + "/login", + response_model=LoginResponse, + summary="Authenticate with the master password", +) +async def login( + body: LoginRequest, + response: Response, + db: DbDep, + settings: SettingsDep, +) -> LoginResponse: + """Verify the master password and return a session token. + + On success the token is also set as an ``HttpOnly`` ``SameSite=Lax`` + cookie so the browser SPA benefits from automatic credential handling. + + Args: + body: Login request validated by Pydantic. + response: FastAPI response object used to set the cookie. + db: Injected aiosqlite connection. + settings: Application settings (used for session duration). + + Returns: + :class:`~app.models.auth.LoginResponse` containing the token. + + Raises: + HTTPException: 401 if the password is incorrect. + """ + try: + session = await auth_service.login( + db, + password=body.password, + session_duration_minutes=settings.session_duration_minutes, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(exc), + ) from exc + + response.set_cookie( + key=_COOKIE_NAME, + value=session.token, + httponly=True, + samesite="lax", + secure=False, # Set to True in production behind HTTPS + max_age=settings.session_duration_minutes * 60, + ) + return LoginResponse(token=session.token, expires_at=session.expires_at) + + +@router.post( + "/logout", + response_model=LogoutResponse, + summary="Revoke the current session", +) +async def logout( + request: Request, + response: Response, + db: DbDep, +) -> LogoutResponse: + """Invalidate the active session. + + The session token is read from the ``bangui_session`` cookie or the + ``Authorization: Bearer`` header. If no token is present the request + is silently treated as a successful logout (idempotent). + + Args: + request: FastAPI request (used to extract the token). + response: FastAPI response (used to clear the cookie). + db: Injected aiosqlite connection. + + Returns: + :class:`~app.models.auth.LogoutResponse`. + """ + token = _extract_token(request) + if token: + await auth_service.logout(db, token) + invalidate_session_cache(token) + response.delete_cookie(key=_COOKIE_NAME) + return LogoutResponse() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _extract_token(request: Request) -> str | None: + """Extract the session token from cookie or Authorization header. + + Args: + request: The incoming FastAPI request. + + Returns: + The token string, or ``None`` if absent. + """ + token: str | None = request.cookies.get(_COOKIE_NAME) + if token: + return token + auth_header: str = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[len("Bearer "):] + return None diff --git a/backend/app/routers/bans.py b/backend/app/routers/bans.py new file mode 100644 index 0000000..dbdee38 --- /dev/null +++ b/backend/app/routers/bans.py @@ -0,0 +1,234 @@ +"""Bans router. + +Manual ban and unban operations and the active-bans overview: + +* ``GET /api/bans/active`` — list all currently banned IPs +* ``POST /api/bans`` — ban an IP in a specific jail +* ``DELETE /api/bans`` — unban an IP from one or all jails +* ``DELETE /api/bans/all`` — unban every currently banned IP across all jails +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import aiohttp + +from fastapi import APIRouter, HTTPException, Request, status + +from app.dependencies import AuthDep +from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest +from app.models.jail import JailCommandResponse +from app.services import jail_service +from app.services.jail_service import JailNotFoundError, JailOperationError +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"]) + + +def _bad_gateway(exc: Exception) -> HTTPException: + """Return a 502 response when fail2ban is unreachable. + + Args: + exc: The underlying connection error. + + Returns: + :class:`fastapi.HTTPException` with status 502. + """ + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Cannot reach fail2ban: {exc}", + ) + + +@router.get( + "/active", + response_model=ActiveBanListResponse, + summary="List all currently banned IPs across all jails", +) +async def get_active_bans( + request: Request, + _auth: AuthDep, +) -> ActiveBanListResponse: + """Return every IP that is currently banned across all fail2ban jails. + + Each entry includes the jail name, ban start time, expiry time, and + enriched geolocation data (country code). + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.ban.ActiveBanListResponse` with all active bans. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + app_db = request.app.state.db + + try: + return await jail_service.get_active_bans( + socket_path, + http_session=http_session, + app_db=app_db, + ) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=JailCommandResponse, + summary="Ban an IP address in a specific jail", +) +async def ban_ip( + request: Request, + _auth: AuthDep, + body: BanRequest, +) -> JailCommandResponse: + """Ban an IP address in the specified fail2ban jail. + + The IP address is validated before the command is sent. IPv4 and + IPv6 addresses are both accepted. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + body: Payload containing the IP address and target jail. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the ban. + + Raises: + HTTPException: 400 when the IP address is invalid. + HTTPException: 404 when the specified jail does not exist. + HTTPException: 409 when fail2ban reports the ban failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.ban_ip(socket_path, body.jail, body.ip) + return JailCommandResponse( + message=f"IP {body.ip!r} banned in jail {body.jail!r}.", + jail=body.jail, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except JailNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Jail not found: {body.jail!r}", + ) from None + except JailOperationError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.delete( + "", + response_model=JailCommandResponse, + summary="Unban an IP address from one or all jails", +) +async def unban_ip( + request: Request, + _auth: AuthDep, + body: UnbanRequest, +) -> JailCommandResponse: + """Unban an IP address from a specific jail or all jails. + + When ``unban_all`` is ``true`` the IP is removed from every jail using + fail2ban's global unban command. When ``jail`` is specified only that + jail is targeted. If neither ``unban_all`` nor ``jail`` is provided the + IP is unbanned from all jails (equivalent to ``unban_all=true``). + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + body: Payload with the IP address, optional jail, and unban_all flag. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the unban. + + Raises: + HTTPException: 400 when the IP address is invalid. + HTTPException: 404 when the specified jail does not exist. + HTTPException: 409 when fail2ban reports the unban failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + + # Determine target jail (None means all jails). + target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail + + try: + await jail_service.unban_ip(socket_path, body.ip, jail=target_jail) + scope = f"jail {target_jail!r}" if target_jail else "all jails" + return JailCommandResponse( + message=f"IP {body.ip!r} unbanned from {scope}.", + jail=target_jail or "*", + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except JailNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Jail not found: {target_jail!r}", + ) from None + except JailOperationError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.delete( + "/all", + response_model=UnbanAllResponse, + summary="Unban every currently banned IP across all jails", +) +async def unban_all( + request: Request, + _auth: AuthDep, +) -> UnbanAllResponse: + """Remove all active bans from every fail2ban jail in a single operation. + + Uses fail2ban's ``unban --all`` command to atomically clear every active + ban across all jails. Returns the number of IPs that were unbanned. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.ban.UnbanAllResponse` with the count of + unbanned IPs. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + count: int = await jail_service.unban_all_ips(socket_path) + return UnbanAllResponse( + message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.", + count=count, + ) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc diff --git a/backend/app/routers/blocklist.py b/backend/app/routers/blocklist.py new file mode 100644 index 0000000..58cf951 --- /dev/null +++ b/backend/app/routers/blocklist.py @@ -0,0 +1,370 @@ +"""Blocklist router. + +Manages external IP blocklist sources, triggers manual imports, and exposes +the import schedule and log: + +* ``GET /api/blocklists`` — list all sources +* ``POST /api/blocklists`` — add a source +* ``GET /api/blocklists/import`` — (reserved; use POST) +* ``POST /api/blocklists/import`` — trigger a manual import now +* ``GET /api/blocklists/schedule`` — get current schedule + next run +* ``PUT /api/blocklists/schedule`` — update schedule +* ``GET /api/blocklists/log`` — paginated import log +* ``GET /api/blocklists/{id}`` — get a single source +* ``PUT /api/blocklists/{id}`` — edit a source +* ``DELETE /api/blocklists/{id}`` — remove a source +* ``GET /api/blocklists/{id}/preview`` — preview the blocklist contents + +Note: static path segments (``/import``, ``/schedule``, ``/log``) are +registered *before* the ``/{id}`` routes so FastAPI resolves them correctly. + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +import aiosqlite + +if TYPE_CHECKING: + import aiohttp +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status + +from app.dependencies import AuthDep, get_db +from app.models.blocklist import ( + BlocklistListResponse, + BlocklistSource, + BlocklistSourceCreate, + BlocklistSourceUpdate, + ImportLogListResponse, + ImportRunResult, + PreviewResponse, + ScheduleConfig, + ScheduleInfo, +) +from app.repositories import import_log_repo +from app.services import blocklist_service +from app.tasks import blocklist_import as blocklist_import_task + +router: APIRouter = APIRouter(prefix="/api/blocklists", tags=["Blocklists"]) + +DbDep = Annotated[aiosqlite.Connection, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Source list + create +# --------------------------------------------------------------------------- + + +@router.get( + "", + response_model=BlocklistListResponse, + summary="List all blocklist sources", +) +async def list_blocklists( + db: DbDep, + _auth: AuthDep, +) -> BlocklistListResponse: + """Return all configured blocklist source definitions. + + Args: + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.blocklist.BlocklistListResponse` with all sources. + """ + sources = await blocklist_service.list_sources(db) + return BlocklistListResponse(sources=sources) + + +@router.post( + "", + response_model=BlocklistSource, + status_code=status.HTTP_201_CREATED, + summary="Add a new blocklist source", +) +async def create_blocklist( + payload: BlocklistSourceCreate, + db: DbDep, + _auth: AuthDep, +) -> BlocklistSource: + """Create a new blocklist source definition. + + Args: + payload: New source data (name, url, enabled). + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Returns: + The newly created :class:`~app.models.blocklist.BlocklistSource`. + """ + return await blocklist_service.create_source( + db, payload.name, payload.url, enabled=payload.enabled + ) + + +# --------------------------------------------------------------------------- +# Static sub-paths — must be declared BEFORE /{id} +# --------------------------------------------------------------------------- + + +@router.post( + "/import", + response_model=ImportRunResult, + summary="Trigger a manual blocklist import", +) +async def run_import_now( + request: Request, + db: DbDep, + _auth: AuthDep, +) -> ImportRunResult: + """Download and apply all enabled blocklist sources immediately. + + Args: + request: Incoming request (used to access shared HTTP session). + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.blocklist.ImportRunResult` with per-source + results and aggregated counters. + """ + http_session: aiohttp.ClientSession = request.app.state.http_session + socket_path: str = request.app.state.settings.fail2ban_socket + return await blocklist_service.import_all(db, http_session, socket_path) + + +@router.get( + "/schedule", + response_model=ScheduleInfo, + summary="Get the current import schedule", +) +async def get_schedule( + request: Request, + db: DbDep, + _auth: AuthDep, +) -> ScheduleInfo: + """Return the current schedule configuration and runtime metadata. + + The ``next_run_at`` field is read from APScheduler if the job is active. + + Args: + request: Incoming request (used to query the scheduler). + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.blocklist.ScheduleInfo` with config and run + times. + """ + scheduler = request.app.state.scheduler + job = scheduler.get_job(blocklist_import_task.JOB_ID) + next_run_at: str | None = None + if job is not None and job.next_run_time is not None: + next_run_at = job.next_run_time.isoformat() + + return await blocklist_service.get_schedule_info(db, next_run_at) + + +@router.put( + "/schedule", + response_model=ScheduleInfo, + summary="Update the import schedule", +) +async def update_schedule( + payload: ScheduleConfig, + request: Request, + db: DbDep, + _auth: AuthDep, +) -> ScheduleInfo: + """Persist a new schedule configuration and reschedule the import job. + + Args: + payload: New :class:`~app.models.blocklist.ScheduleConfig`. + request: Incoming request (used to access the scheduler). + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Returns: + Updated :class:`~app.models.blocklist.ScheduleInfo`. + """ + await blocklist_service.set_schedule(db, payload) + # Reschedule the background job immediately. + blocklist_import_task.reschedule(request.app) + + job = request.app.state.scheduler.get_job(blocklist_import_task.JOB_ID) + next_run_at: str | None = None + if job is not None and job.next_run_time is not None: + next_run_at = job.next_run_time.isoformat() + + return await blocklist_service.get_schedule_info(db, next_run_at) + + +@router.get( + "/log", + response_model=ImportLogListResponse, + summary="Get the paginated import log", +) +async def get_import_log( + db: DbDep, + _auth: AuthDep, + source_id: int | None = Query(default=None, description="Filter by source id"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), +) -> ImportLogListResponse: + """Return a paginated log of all import runs. + + Args: + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + source_id: Optional filter — only show logs for this source. + page: 1-based page number. + page_size: Items per page. + + Returns: + :class:`~app.models.blocklist.ImportLogListResponse`. + """ + items, total = await import_log_repo.list_logs( + db, source_id=source_id, page=page, page_size=page_size + ) + total_pages = import_log_repo.compute_total_pages(total, page_size) + from app.models.blocklist import ImportLogEntry # noqa: PLC0415 + + return ImportLogListResponse( + items=[ImportLogEntry.model_validate(i) for i in items], + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + +# --------------------------------------------------------------------------- +# Single source CRUD — parameterised routes AFTER static sub-paths +# --------------------------------------------------------------------------- + + +@router.get( + "/{source_id}", + response_model=BlocklistSource, + summary="Get a single blocklist source", +) +async def get_blocklist( + source_id: int, + db: DbDep, + _auth: AuthDep, +) -> BlocklistSource: + """Return a single blocklist source by id. + + Args: + source_id: Primary key of the source. + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Raises: + HTTPException: 404 if the source does not exist. + """ + source = await blocklist_service.get_source(db, source_id) + if source is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") + return source + + +@router.put( + "/{source_id}", + response_model=BlocklistSource, + summary="Update a blocklist source", +) +async def update_blocklist( + source_id: int, + payload: BlocklistSourceUpdate, + db: DbDep, + _auth: AuthDep, +) -> BlocklistSource: + """Update one or more fields on a blocklist source. + + Args: + source_id: Primary key of the source to update. + payload: Fields to update (all optional). + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Raises: + HTTPException: 404 if the source does not exist. + """ + updated = await blocklist_service.update_source( + db, + source_id, + name=payload.name, + url=payload.url, + enabled=payload.enabled, + ) + if updated is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") + return updated + + +@router.delete( + "/{source_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a blocklist source", +) +async def delete_blocklist( + source_id: int, + db: DbDep, + _auth: AuthDep, +) -> None: + """Delete a blocklist source by id. + + Args: + source_id: Primary key of the source to remove. + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Raises: + HTTPException: 404 if the source does not exist. + """ + deleted = await blocklist_service.delete_source(db, source_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") + + +@router.get( + "/{source_id}/preview", + response_model=PreviewResponse, + summary="Preview the contents of a blocklist source", +) +async def preview_blocklist( + source_id: int, + request: Request, + db: DbDep, + _auth: AuthDep, +) -> PreviewResponse: + """Download and preview a sample of a blocklist source. + + Returns the first :data:`~app.services.blocklist_service._PREVIEW_LINES` + valid IP entries together with validation statistics. + + Args: + source_id: Primary key of the source to preview. + request: Incoming request (used to access the HTTP session). + db: Application database connection (injected). + _auth: Validated session — enforces authentication. + + Raises: + HTTPException: 404 if the source does not exist. + HTTPException: 502 if the URL cannot be reached. + """ + source = await blocklist_service.get_source(db, source_id) + if source is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Blocklist source not found.") + + http_session: aiohttp.ClientSession = request.app.state.http_session + try: + return await blocklist_service.preview_source(source.url, http_session) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Could not fetch blocklist: {exc}", + ) from exc diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py new file mode 100644 index 0000000..e56e2f8 --- /dev/null +++ b/backend/app/routers/config.py @@ -0,0 +1,1561 @@ +"""Configuration router. + +Provides endpoints to inspect and edit fail2ban jail configuration and +global settings, test regex patterns, add log paths, and preview log files. + +* ``GET /api/config/jails`` — list all jail configs +* ``GET /api/config/jails/{name}`` — full config for one jail +* ``PUT /api/config/jails/{name}`` — update a jail's config +* ``GET /api/config/jails/inactive`` — list all inactive jails +* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail +* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail +* ``POST /api/config/jails/{name}/validate`` — validate jail config pre-activation (Task 3) +* ``POST /api/config/jails/{name}/rollback`` — disable bad jail and restart fail2ban (Task 3) +* ``GET /api/config/pending-recovery`` — active crash-recovery record (Task 3) +* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail +* ``POST /api/config/jails/{name}/action`` — add an action to a jail +* ``DELETE /api/config/jails/{name}/action/{action_name}`` — remove an action from a jail +* ``GET /api/config/global`` — global fail2ban settings +* ``PUT /api/config/global`` — update global settings +* ``POST /api/config/reload`` — reload fail2ban +* ``POST /api/config/regex-test`` — test a regex pattern +* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail +* ``POST /api/config/preview-log`` — preview log matches +* ``GET /api/config/filters`` — list all filters with active/inactive status +* ``GET /api/config/filters/{name}`` — full parsed detail for one filter +* ``PUT /api/config/filters/{name}`` — update a filter's .local override +* ``POST /api/config/filters`` — create a new user-defined filter +* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file +* ``GET /api/config/actions`` — list all actions with active/inactive status +* ``GET /api/config/actions/{name}`` — full parsed detail for one action +* ``PUT /api/config/actions/{name}`` — update an action's .local override +* ``POST /api/config/actions`` — create a new user-defined action +* ``DELETE /api/config/actions/{name}`` — delete an action's .local file +* ``GET /api/config/fail2ban-log`` — read the tail of the fail2ban log file +* ``GET /api/config/service-status`` — fail2ban health + log configuration +""" + +from __future__ import annotations + +import datetime +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Path, Query, Request, status + +from app.dependencies import AuthDep +from app.models.config import ( + ActionConfig, + ActionCreateRequest, + ActionListResponse, + ActionUpdateRequest, + ActivateJailRequest, + AddLogPathRequest, + AssignActionRequest, + AssignFilterRequest, + Fail2BanLogResponse, + FilterConfig, + FilterCreateRequest, + FilterListResponse, + FilterUpdateRequest, + GlobalConfigResponse, + GlobalConfigUpdate, + InactiveJailListResponse, + JailActivationResponse, + JailConfigListResponse, + JailConfigResponse, + JailConfigUpdate, + JailValidationResult, + LogPreviewRequest, + LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, + PendingRecovery, + RegexTestRequest, + RegexTestResponse, + RollbackResponse, + ServiceStatusResponse, +) +from app.services import config_file_service, config_service, jail_service +from app.services.config_file_service import ( + ActionAlreadyExistsError, + ActionNameError, + ActionNotFoundError, + ActionReadonlyError, + ConfigWriteError, + FilterAlreadyExistsError, + FilterInvalidRegexError, + FilterNameError, + FilterNotFoundError, + FilterReadonlyError, + JailAlreadyActiveError, + JailAlreadyInactiveError, + JailNameError, + JailNotFoundInConfigError, +) +from app.services.config_service import ( + ConfigOperationError, + ConfigValidationError, + JailNotFoundError, +) +from app.tasks.health_check import _run_probe +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"]) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")] + + +def _not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Jail not found: {name!r}", + ) + + +def _bad_gateway(exc: Exception) -> HTTPException: + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Cannot reach fail2ban: {exc}", + ) + + +def _unprocessable(message: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=message, + ) + + +def _bad_request(message: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=message, + ) + + +# --------------------------------------------------------------------------- +# Jail configuration endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/jails", + response_model=JailConfigListResponse, + summary="List configuration for all active jails", +) +async def get_jail_configs( + request: Request, + _auth: AuthDep, +) -> JailConfigListResponse: + """Return editable configuration for every active fail2ban jail. + + Fetches ban time, find time, max retries, regex patterns, log paths, + date pattern, encoding, backend, and attached actions for all jails. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.JailConfigListResponse`. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_service.list_jail_configs(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.get( + "/jails/inactive", + response_model=InactiveJailListResponse, + summary="List all inactive jails discovered in config files", +) +async def get_inactive_jails( + request: Request, + _auth: AuthDep, +) -> InactiveJailListResponse: + """Return all jails defined in fail2ban config files that are not running. + + Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the + fail2ban merge order. Jails that fail2ban currently reports as running + are excluded; only truly inactive entries are returned. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.InactiveJailListResponse`. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + return await config_file_service.list_inactive_jails(config_dir, socket_path) + + +@router.get( + "/jails/{name}", + response_model=JailConfigResponse, + summary="Return configuration for a single jail", +) +async def get_jail_config( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailConfigResponse: + """Return the full editable configuration for one fail2ban jail. + + Args: + request: Incoming request. + _auth: Validated session. + name: Jail name. + + Returns: + :class:`~app.models.config.JailConfigResponse`. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_service.get_jail_config(socket_path, name) + except JailNotFoundError: + raise _not_found(name) from None + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.put( + "/jails/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update jail configuration", +) +async def update_jail_config( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: JailConfigUpdate, +) -> None: + """Update one or more configuration fields for an active fail2ban jail. + + Regex patterns are validated before being sent to fail2ban. An invalid + pattern returns 422 with the regex error message. + + Args: + request: Incoming request. + _auth: Validated session. + name: Jail name. + body: Partial update — only non-None fields are written. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 422 when a regex pattern fails to compile. + HTTPException: 400 when a set command is rejected. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_service.update_jail_config(socket_path, name, body) + except JailNotFoundError: + raise _not_found(name) from None + except ConfigValidationError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Global configuration endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/global", + response_model=GlobalConfigResponse, + summary="Return global fail2ban settings", +) +async def get_global_config( + request: Request, + _auth: AuthDep, +) -> GlobalConfigResponse: + """Return global fail2ban settings (log level, log target, database config). + + Args: + request: Incoming request. + _auth: Validated session. + + Returns: + :class:`~app.models.config.GlobalConfigResponse`. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_service.get_global_config(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.put( + "/global", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update global fail2ban settings", +) +async def update_global_config( + request: Request, + _auth: AuthDep, + body: GlobalConfigUpdate, +) -> None: + """Update global fail2ban settings. + + Args: + request: Incoming request. + _auth: Validated session. + body: Partial update — only non-None fields are written. + + Raises: + HTTPException: 400 when a set command is rejected. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_service.update_global_config(socket_path, body) + except ConfigOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Reload endpoint +# --------------------------------------------------------------------------- + + +@router.post( + "/reload", + status_code=status.HTTP_204_NO_CONTENT, + summary="Reload fail2ban to apply configuration changes", +) +async def reload_fail2ban( + request: Request, + _auth: AuthDep, +) -> None: + """Trigger a full fail2ban reload. + + All jails are stopped and restarted with the current configuration. + + Args: + request: Incoming request. + _auth: Validated session. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.reload_all(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Regex tester (stateless) +# --------------------------------------------------------------------------- + + +@router.post( + "/regex-test", + response_model=RegexTestResponse, + summary="Test a fail regex pattern against a sample log line", +) +async def regex_test( + _auth: AuthDep, + body: RegexTestRequest, +) -> RegexTestResponse: + """Test whether a regex pattern matches a given log line. + + This endpoint is entirely in-process — no fail2ban socket call is made. + Returns the match result and any captured groups. + + Args: + _auth: Validated session. + body: Sample log line and regex pattern. + + Returns: + :class:`~app.models.config.RegexTestResponse` with match result and groups. + """ + return config_service.test_regex(body) + + +# --------------------------------------------------------------------------- +# Log path management +# --------------------------------------------------------------------------- + + +@router.post( + "/jails/{name}/logpath", + status_code=status.HTTP_204_NO_CONTENT, + summary="Add a log file path to an existing jail", +) +async def add_log_path( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: AddLogPathRequest, +) -> None: + """Register an additional log file for an existing jail to monitor. + + Uses ``set addlogpath `` to add the path + without requiring a daemon restart. + + Args: + request: Incoming request. + _auth: Validated session. + name: Jail name. + body: Log path and tail/head preference. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 400 when the command is rejected. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_service.add_log_path(socket_path, name, body) + except JailNotFoundError: + raise _not_found(name) from None + except ConfigOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.delete( + "/jails/{name}/logpath", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove a monitored log path from a jail", +) +async def delete_log_path( + request: Request, + _auth: AuthDep, + name: _NamePath, + log_path: str = Query(..., description="Absolute path of the log file to stop monitoring."), +) -> None: + """Stop a jail from monitoring the specified log file. + + Uses ``set dellogpath `` to remove the log path at runtime + without requiring a daemon restart. + + Args: + request: Incoming request. + _auth: Validated session. + name: Jail name. + log_path: Absolute path to the log file to remove (query parameter). + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 400 when the command is rejected. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_service.delete_log_path(socket_path, name, log_path) + except JailNotFoundError: + raise _not_found(name) from None + except ConfigOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/preview-log", + response_model=LogPreviewResponse, + summary="Preview log file lines against a regex pattern", +) +async def preview_log( + _auth: AuthDep, + body: LogPreviewRequest, +) -> LogPreviewResponse: + """Read the last N lines of a log file and test a regex against each one. + + Returns each line with a flag indicating whether the regex matched, and + the captured groups for matching lines. The log file is read from the + server's local filesystem. + + Args: + _auth: Validated session. + body: Log file path, regex pattern, and number of lines to read. + + Returns: + :class:`~app.models.config.LogPreviewResponse` with per-line results. + """ + return await config_service.preview_log(body) + + +# --------------------------------------------------------------------------- +# Map color thresholds +# --------------------------------------------------------------------------- + + +@router.get( + "/map-color-thresholds", + response_model=MapColorThresholdsResponse, + summary="Get map color threshold configuration", +) +async def get_map_color_thresholds( + request: Request, + _auth: AuthDep, +) -> MapColorThresholdsResponse: + """Return the configured map color thresholds. + + Args: + request: FastAPI request object. + _auth: Validated session. + + Returns: + :class:`~app.models.config.MapColorThresholdsResponse` with + current thresholds. + """ + from app.services import setup_service + + high, medium, low = await setup_service.get_map_color_thresholds( + request.app.state.db + ) + return MapColorThresholdsResponse( + threshold_high=high, + threshold_medium=medium, + threshold_low=low, + ) + + +@router.put( + "/map-color-thresholds", + response_model=MapColorThresholdsResponse, + summary="Update map color threshold configuration", +) +async def update_map_color_thresholds( + request: Request, + _auth: AuthDep, + body: MapColorThresholdsUpdate, +) -> MapColorThresholdsResponse: + """Update the map color threshold configuration. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: New threshold values. + + Returns: + :class:`~app.models.config.MapColorThresholdsResponse` with + updated thresholds. + + Raises: + HTTPException: 400 if validation fails (thresholds not + properly ordered). + """ + from app.services import setup_service + + try: + await setup_service.set_map_color_thresholds( + request.app.state.db, + threshold_high=body.threshold_high, + threshold_medium=body.threshold_medium, + threshold_low=body.threshold_low, + ) + except ValueError as exc: + raise _bad_request(str(exc)) from exc + + return MapColorThresholdsResponse( + threshold_high=body.threshold_high, + threshold_medium=body.threshold_medium, + threshold_low=body.threshold_low, + ) + + +@router.post( + "/jails/{name}/activate", + response_model=JailActivationResponse, + summary="Activate an inactive jail", +) +async def activate_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: ActivateJailRequest | None = None, +) -> JailActivationResponse: + """Enable an inactive jail and reload fail2ban. + + Writes ``enabled = true`` (plus any override values from the request + body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so + the jail starts immediately. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Name of the jail to activate. + body: Optional override values (bantime, findtime, maxretry, port, + logpath). + + Returns: + :class:`~app.models.config.JailActivationResponse`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + HTTPException: 409 if the jail is already active. + HTTPException: 502 if fail2ban is unreachable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + req = body if body is not None else ActivateJailRequest() + + try: + result = await config_file_service.activate_jail( + config_dir, socket_path, name, req + ) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except JailAlreadyActiveError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jail {name!r} is already active.", + ) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write config override: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + # Record this activation so the health-check task can attribute a + # subsequent fail2ban crash to it. + request.app.state.last_activation = { + "jail_name": name, + "at": datetime.datetime.now(tz=datetime.UTC), + } + + # If fail2ban stopped responding after the reload, create a pending-recovery + # record immediately (before the background health task notices). + if not result.fail2ban_running: + request.app.state.pending_recovery = PendingRecovery( + jail_name=name, + activated_at=request.app.state.last_activation["at"], + detected_at=datetime.datetime.now(tz=datetime.UTC), + ) + + # Force an immediate health probe so the cached status reflects the current + # fail2ban state without waiting for the next scheduled check. + await _run_probe(request.app) + + return result + + +@router.post( + "/jails/{name}/deactivate", + response_model=JailActivationResponse, + summary="Deactivate an active jail", +) +async def deactivate_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailActivationResponse: + """Disable an active jail and reload fail2ban. + + Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a + full fail2ban reload so the jail stops immediately. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Name of the jail to deactivate. + + Returns: + :class:`~app.models.config.JailActivationResponse`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + HTTPException: 409 if the jail is already inactive. + HTTPException: 502 if fail2ban is unreachable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + + try: + result = await config_file_service.deactivate_jail(config_dir, socket_path, name) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except JailAlreadyInactiveError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jail {name!r} is already inactive.", + ) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write config override: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + # Force an immediate health probe so the cached status reflects the current + # fail2ban state (reload changes the active-jail count) without waiting for + # the next scheduled background check (up to 30 seconds). + await _run_probe(request.app) + + return result + + +# --------------------------------------------------------------------------- +# Jail validation & rollback endpoints (Task 3) +# --------------------------------------------------------------------------- + + +@router.post( + "/jails/{name}/validate", + response_model=JailValidationResult, + summary="Validate jail configuration before activation", +) +async def validate_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailValidationResult: + """Run pre-activation validation checks on a jail configuration. + + Validates filter and action file existence, regex pattern compilation, and + log path existence without modifying any files or reloading fail2ban. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name to validate. + + Returns: + :class:`~app.models.config.JailValidationResult` with any issues found. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await config_file_service.validate_jail_config(config_dir, name) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + + +@router.get( + "/pending-recovery", + response_model=PendingRecovery | None, + summary="Return active crash-recovery record if one exists", +) +async def get_pending_recovery( + request: Request, + _auth: AuthDep, +) -> PendingRecovery | None: + """Return the current :class:`~app.models.config.PendingRecovery` record. + + A non-null response means fail2ban crashed shortly after a jail activation + and the user should be offered a rollback option. Returns ``null`` (HTTP + 200 with ``null`` body) when no recovery is pending. + + Args: + request: FastAPI request object. + _auth: Validated session. + + Returns: + :class:`~app.models.config.PendingRecovery` or ``None``. + """ + return getattr(request.app.state, "pending_recovery", None) + + +@router.post( + "/jails/{name}/rollback", + response_model=RollbackResponse, + summary="Disable a bad jail config and restart fail2ban", +) +async def rollback_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> RollbackResponse: + """Disable the specified jail and attempt to restart fail2ban. + + Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when + fail2ban is down — no socket is needed), then runs the configured start + command and waits up to ten seconds for the daemon to come back online. + + On success, clears the :class:`~app.models.config.PendingRecovery` record. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name to disable and roll back. + + Returns: + :class:`~app.models.config.RollbackResponse`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 500 if writing the .local override file fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + start_cmd: str = request.app.state.settings.fail2ban_start_command + start_cmd_parts: list[str] = start_cmd.split() + + try: + result = await config_file_service.rollback_jail( + config_dir, socket_path, name, start_cmd_parts + ) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write config override: {exc}", + ) from exc + + # Clear pending recovery if fail2ban came back online. + if result.fail2ban_running: + request.app.state.pending_recovery = None + request.app.state.last_activation = None + + return result + + +# --------------------------------------------------------------------------- +# Filter discovery endpoints (Task 2.1) +# --------------------------------------------------------------------------- + + +@router.get( + "/filters", + response_model=FilterListResponse, + summary="List all available filters with active/inactive status", +) +async def list_filters( + request: Request, + _auth: AuthDep, +) -> FilterListResponse: + """Return all filters discovered in ``filter.d/`` with active/inactive status. + + Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, and cross-references each filter's + name against the ``filter`` fields of currently running jails to determine + whether it is active. + + Active filters (those used by at least one running jail) are sorted to the + top of the list; inactive filters follow. Both groups are sorted + alphabetically within themselves. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.FilterListResponse` with all discovered + filters. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + result = await config_file_service.list_filters(config_dir, socket_path) + # Sort: active first (by name), then inactive (by name). + result.filters.sort(key=lambda f: (not f.active, f.name.lower())) + return result + + +@router.get( + "/filters/{name}", + response_model=FilterConfig, + summary="Return full parsed detail for a single filter", +) +async def get_filter( + request: Request, + _auth: AuthDep, + name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")], +) -> FilterConfig: + """Return the full parsed configuration and active/inactive status for one filter. + + Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding + ``.local`` override, and annotates the result with ``active``, + ``used_by_jails``, ``source_file``, and ``has_local_override``. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + name: Filter base name (with or without ``.conf`` extension). + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 404 if the filter is not found in ``filter.d/``. + HTTPException: 502 if fail2ban is unreachable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.get_filter(config_dir, socket_path, name) + except FilterNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) from None + + +# --------------------------------------------------------------------------- +# Filter write endpoints (Task 2.2) +# --------------------------------------------------------------------------- + + +_FilterNamePath = Annotated[ + str, + Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."), +] + + +def _filter_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) + + +@router.put( + "/filters/{name}", + response_model=FilterConfig, + summary="Update a filter's .local override with new regex/pattern values", +) +async def update_filter( + request: Request, + _auth: AuthDep, + name: _FilterNamePath, + body: FilterUpdateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after writing."), +) -> FilterConfig: + """Update a filter's ``[Definition]`` fields by writing a ``.local`` override. + + All regex patterns are validated before writing. The original ``.conf`` + file is never modified. Fields left as ``null`` in the request body are + kept at their current values. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Filter base name (with or without ``.conf`` extension). + body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``, + ``journalmatch``. + reload: When ``true``, trigger a fail2ban reload after writing. + + Returns: + Updated :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the filter does not exist. + HTTPException: 422 if any regex pattern fails to compile. + HTTPException: 500 if writing the ``.local`` file fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.update_filter( + config_dir, socket_path, name, body, do_reload=reload + ) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterNotFoundError: + raise _filter_not_found(name) from None + except FilterInvalidRegexError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write filter override: {exc}", + ) from exc + + +@router.post( + "/filters", + response_model=FilterConfig, + status_code=status.HTTP_201_CREATED, + summary="Create a new user-defined filter", +) +async def create_filter( + request: Request, + _auth: AuthDep, + body: FilterCreateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after creating."), +) -> FilterConfig: + """Create a new user-defined filter at ``filter.d/{name}.local``. + + The filter is created as a ``.local`` file so it can coexist safely with + shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for + the requested name already exists. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: Filter name and ``[Definition]`` fields. + reload: When ``true``, trigger a fail2ban reload after creating. + + Returns: + :class:`~app.models.config.FilterConfig` for the new filter. + + Raises: + HTTPException: 400 if the name contains invalid characters. + HTTPException: 409 if the filter already exists. + HTTPException: 422 if any regex pattern is invalid. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.create_filter( + config_dir, socket_path, body, do_reload=reload + ) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterAlreadyExistsError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Filter {exc.name!r} already exists.", + ) from exc + except FilterInvalidRegexError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write filter: {exc}", + ) from exc + + +@router.delete( + "/filters/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a user-created filter's .local file", +) +async def delete_filter( + request: Request, + _auth: AuthDep, + name: _FilterNamePath, +) -> None: + """Delete a user-created filter's ``.local`` override file. + + Shipped ``.conf``-only filters cannot be deleted (returns 409). When + both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. + When only a ``.local`` exists (user-created filter), the file is deleted + entirely. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Filter base name. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the filter does not exist. + HTTPException: 409 if the filter is a shipped default (conf-only). + HTTPException: 500 if deletion fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await config_file_service.delete_filter(config_dir, name) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterNotFoundError: + raise _filter_not_found(name) from None + except FilterReadonlyError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete filter: {exc}", + ) from exc + + +@router.post( + "/jails/{name}/filter", + status_code=status.HTTP_204_NO_CONTENT, + summary="Assign a filter to a jail", +) +async def assign_filter_to_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: AssignFilterRequest, + reload: bool = Query(default=False, description="Reload fail2ban after assigning."), +) -> None: + """Write ``filter = {filter_name}`` to the jail's ``.local`` config. + + Existing keys in the jail's ``.local`` file are preserved. If the file + does not exist it is created. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + body: Filter to assign. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *filter_name* contain invalid characters. + HTTPException: 404 if the jail or filter does not exist. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_file_service.assign_filter_to_jail( + config_dir, socket_path, name, body, do_reload=reload + ) + except (JailNameError, FilterNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except FilterNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {exc.name!r}", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + + +# --------------------------------------------------------------------------- +# Action discovery endpoints (Task 3.1) +# --------------------------------------------------------------------------- + +_ActionNamePath = Annotated[ + str, + Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."), +] + + +def _action_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Action not found: {name!r}", + ) + + +@router.get( + "/actions", + response_model=ActionListResponse, + summary="List all available actions with active/inactive status", +) +async def list_actions( + request: Request, + _auth: AuthDep, +) -> ActionListResponse: + """Return all actions discovered in ``action.d/`` with active/inactive status. + + Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, and cross-references each action's + name against the ``action`` fields of currently running jails to determine + whether it is active. + + Active actions (those used by at least one running jail) are sorted to the + top of the list; inactive actions follow. Both groups are sorted + alphabetically within themselves. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.ActionListResponse` with all discovered + actions. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + result = await config_file_service.list_actions(config_dir, socket_path) + result.actions.sort(key=lambda a: (not a.active, a.name.lower())) + return result + + +@router.get( + "/actions/{name}", + response_model=ActionConfig, + summary="Return full parsed detail for a single action", +) +async def get_action( + request: Request, + _auth: AuthDep, + name: _ActionNamePath, +) -> ActionConfig: + """Return the full parsed configuration and active/inactive status for one action. + + Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding + ``.local`` override, and annotates the result with ``active``, + ``used_by_jails``, ``source_file``, and ``has_local_override``. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + name: Action base name (with or without ``.conf`` extension). + + Returns: + :class:`~app.models.config.ActionConfig`. + + Raises: + HTTPException: 404 if the action is not found in ``action.d/``. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.get_action(config_dir, socket_path, name) + except ActionNotFoundError: + raise _action_not_found(name) from None + + +# --------------------------------------------------------------------------- +# Action write endpoints (Task 3.2) +# --------------------------------------------------------------------------- + + +@router.put( + "/actions/{name}", + response_model=ActionConfig, + summary="Update an action's .local override with new lifecycle command values", +) +async def update_action( + request: Request, + _auth: AuthDep, + name: _ActionNamePath, + body: ActionUpdateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after writing."), +) -> ActionConfig: + """Update an action's ``[Definition]`` fields by writing a ``.local`` override. + + Only non-``null`` fields in the request body are written. The original + ``.conf`` file is never modified. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Action base name (with or without ``.conf`` extension). + body: Partial update — lifecycle commands and ``[Init]`` parameters. + reload: When ``true``, trigger a fail2ban reload after writing. + + Returns: + Updated :class:`~app.models.config.ActionConfig`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the action does not exist. + HTTPException: 500 if writing the ``.local`` file fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.update_action( + config_dir, socket_path, name, body, do_reload=reload + ) + except ActionNameError as exc: + raise _bad_request(str(exc)) from exc + except ActionNotFoundError: + raise _action_not_found(name) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write action override: {exc}", + ) from exc + + +@router.post( + "/actions", + response_model=ActionConfig, + status_code=status.HTTP_201_CREATED, + summary="Create a new user-defined action", +) +async def create_action( + request: Request, + _auth: AuthDep, + body: ActionCreateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after creating."), +) -> ActionConfig: + """Create a new user-defined action at ``action.d/{name}.local``. + + Returns 409 if a ``.conf`` or ``.local`` for the requested name already + exists. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: Action name and ``[Definition]`` lifecycle fields. + reload: When ``true``, trigger a fail2ban reload after creating. + + Returns: + :class:`~app.models.config.ActionConfig` for the new action. + + Raises: + HTTPException: 400 if the name contains invalid characters. + HTTPException: 409 if the action already exists. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.create_action( + config_dir, socket_path, body, do_reload=reload + ) + except ActionNameError as exc: + raise _bad_request(str(exc)) from exc + except ActionAlreadyExistsError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Action {exc.name!r} already exists.", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write action: {exc}", + ) from exc + + +@router.delete( + "/actions/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a user-created action's .local file", +) +async def delete_action( + request: Request, + _auth: AuthDep, + name: _ActionNamePath, +) -> None: + """Delete a user-created action's ``.local`` override file. + + Shipped ``.conf``-only actions cannot be deleted (returns 409). When + both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Action base name. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the action does not exist. + HTTPException: 409 if the action is a shipped default (conf-only). + HTTPException: 500 if deletion fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await config_file_service.delete_action(config_dir, name) + except ActionNameError as exc: + raise _bad_request(str(exc)) from exc + except ActionNotFoundError: + raise _action_not_found(name) from None + except ActionReadonlyError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete action: {exc}", + ) from exc + + +@router.post( + "/jails/{name}/action", + status_code=status.HTTP_204_NO_CONTENT, + summary="Add an action to a jail", +) +async def assign_action_to_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: AssignActionRequest, + reload: bool = Query(default=False, description="Reload fail2ban after assigning."), +) -> None: + """Append an action entry to the jail's ``.local`` config. + + Existing keys in the jail's ``.local`` file are preserved. If the file + does not exist it is created. The action is not duplicated if it is + already present. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + body: Action to add plus optional per-jail parameters. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *action_name* contain invalid characters. + HTTPException: 404 if the jail or action does not exist. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_file_service.assign_action_to_jail( + config_dir, socket_path, name, body, do_reload=reload + ) + except (JailNameError, ActionNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except ActionNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Action not found: {exc.name!r}", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + + +@router.delete( + "/jails/{name}/action/{action_name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove an action from a jail", +) +async def remove_action_from_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, + action_name: Annotated[str, Path(description="Action base name to remove.")], + reload: bool = Query(default=False, description="Reload fail2ban after removing."), +) -> None: + """Remove an action from the jail's ``.local`` config. + + If the jail has no ``.local`` file or the action is not listed there, + the call is silently idempotent. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + action_name: Base name of the action to remove. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *action_name* contain invalid characters. + HTTPException: 404 if the jail is not found in config files. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_file_service.remove_action_from_jail( + config_dir, socket_path, name, action_name, do_reload=reload + ) + except (JailNameError, ActionNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + + +# --------------------------------------------------------------------------- +# fail2ban log viewer endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/fail2ban-log", + response_model=Fail2BanLogResponse, + summary="Read the tail of the fail2ban daemon log file", +) +async def get_fail2ban_log( + request: Request, + _auth: AuthDep, + lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200, + filter: Annotated[ # noqa: A002 + str | None, + Query(description="Plain-text substring filter; only matching lines are returned."), + ] = None, +) -> Fail2BanLogResponse: + """Return the tail of the fail2ban daemon log file. + + Queries the fail2ban socket for the current log target and log level, + reads the last *lines* entries from the file, and optionally filters + them by *filter*. Only file-based log targets are supported. + + Args: + request: Incoming request. + _auth: Validated session — enforces authentication. + lines: Number of tail lines to return (1–2000, default 200). + filter: Optional plain-text substring — only matching lines returned. + + Returns: + :class:`~app.models.config.Fail2BanLogResponse`. + + Raises: + HTTPException: 400 when the log target is not a file or path is outside + the allowed directory. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_service.read_fail2ban_log(socket_path, lines, filter) + except config_service.ConfigOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.get( + "/service-status", + response_model=ServiceStatusResponse, + summary="Return fail2ban service health status with log configuration", +) +async def get_service_status( + request: Request, + _auth: AuthDep, +) -> ServiceStatusResponse: + """Return fail2ban service health and current log configuration. + + Probes the fail2ban daemon to determine online/offline state, then + augments the result with the current log level and log target values. + + Args: + request: Incoming request. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.ServiceStatusResponse`. + + Raises: + HTTPException: 502 when fail2ban is unreachable (the service itself + handles this gracefully and returns ``online=False``). + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_service.get_service_status(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..5d77858 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,246 @@ +"""Dashboard router. + +Provides the ``GET /api/dashboard/status`` endpoint that returns the cached +fail2ban server health snapshot. The snapshot is maintained by the +background health-check task and refreshed every 30 seconds. + +Also provides ``GET /api/dashboard/bans`` for the dashboard ban-list table, +``GET /api/dashboard/bans/by-country`` for country aggregation, +``GET /api/dashboard/bans/trend`` for time-bucketed ban counts, and +``GET /api/dashboard/bans/by-jail`` for per-jail ban counts. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import aiohttp + +from fastapi import APIRouter, Query, Request + +from app.dependencies import AuthDep +from app.models.ban import ( + BanOrigin, + BansByCountryResponse, + BansByJailResponse, + BanTrendResponse, + DashboardBanListResponse, + TimeRange, +) +from app.models.server import ServerStatus, ServerStatusResponse +from app.services import ban_service + +router: APIRouter = APIRouter(prefix="/api/dashboard", tags=["Dashboard"]) + +# --------------------------------------------------------------------------- +# Default pagination constants +# --------------------------------------------------------------------------- + +_DEFAULT_PAGE_SIZE: int = 100 +_DEFAULT_RANGE: TimeRange = "24h" + + +@router.get( + "/status", + response_model=ServerStatusResponse, + summary="Return the cached fail2ban server status", +) +async def get_server_status( + request: Request, + _auth: AuthDep, +) -> ServerStatusResponse: + """Return the most recent fail2ban health snapshot. + + The snapshot is populated by a background task that runs every 30 seconds. + If the task has not yet executed a placeholder ``online=False`` status is + returned so the response is always well-formed. + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication on this endpoint. + + Returns: + :class:`~app.models.server.ServerStatusResponse` containing the + current health snapshot. + """ + cached: ServerStatus = getattr( + request.app.state, + "server_status", + ServerStatus(online=False), + ) + return ServerStatusResponse(status=cached) + + +@router.get( + "/bans", + response_model=DashboardBanListResponse, + summary="Return a paginated list of recent bans", +) +async def get_dashboard_bans( + request: Request, + _auth: AuthDep, + range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), + page: int = Query(default=1, ge=1, description="1-based page number."), + page_size: int = Query(default=_DEFAULT_PAGE_SIZE, ge=1, le=500, description="Items per page."), + origin: BanOrigin | None = Query( + default=None, + description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + ), +) -> DashboardBanListResponse: + """Return a paginated list of bans within the selected time window. + + Reads from the fail2ban database and enriches each entry with + geolocation data (country, ASN, organisation) from the ip-api.com + free API. Results are sorted newest-first. Geo lookups are served + from the in-memory cache only; no database writes occur during this + GET request. + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session dependency. + range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``. + page: 1-based page number. + page_size: Maximum items per page (1–500). + origin: Optional filter by ban origin. + + Returns: + :class:`~app.models.ban.DashboardBanListResponse` with paginated + ban items and the total count for the selected window. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + return await ban_service.list_bans( + socket_path, + range, + page=page, + page_size=page_size, + http_session=http_session, + app_db=None, + origin=origin, + ) + + +@router.get( + "/bans/by-country", + response_model=BansByCountryResponse, + summary="Return ban counts aggregated by country", +) +async def get_bans_by_country( + request: Request, + _auth: AuthDep, + range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), + origin: BanOrigin | None = Query( + default=None, + description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + ), +) -> BansByCountryResponse: + """Return ban counts aggregated by ISO country code. + + Uses SQL aggregation (``GROUP BY ip``) and batch geo-resolution to handle + 10 000+ banned IPs efficiently. Returns a ``{country_code: count}`` map + and the 200 most recent raw ban rows for the companion access table. Geo + lookups are served from the in-memory cache only; no database writes occur + during this GET request. + + Args: + request: The incoming request. + _auth: Validated session dependency. + range: Time-range preset. + origin: Optional filter by ban origin. + + Returns: + :class:`~app.models.ban.BansByCountryResponse` with per-country + aggregation and the companion ban list. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + return await ban_service.bans_by_country( + socket_path, + range, + http_session=http_session, + app_db=None, + origin=origin, + ) + + +@router.get( + "/bans/trend", + response_model=BanTrendResponse, + summary="Return ban counts aggregated into time buckets", +) +async def get_ban_trend( + request: Request, + _auth: AuthDep, + range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), + origin: BanOrigin | None = Query( + default=None, + description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + ), +) -> BanTrendResponse: + """Return ban counts grouped into equal-width time buckets. + + Each bucket represents a contiguous time interval within the selected + window. All buckets are returned — empty buckets (zero bans) are + included so the frontend always receives a complete, gap-free series + suitable for rendering a continuous area or line chart. + + Bucket sizes: + + * ``24h`` → 1-hour buckets (24 total) + * ``7d`` → 6-hour buckets (28 total) + * ``30d`` → 1-day buckets (30 total) + * ``365d`` → 7-day buckets (~53 total) + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session dependency. + range: Time-range preset. + origin: Optional filter by ban origin. + + Returns: + :class:`~app.models.ban.BanTrendResponse` with the ordered bucket + list and the bucket-size label. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + + return await ban_service.ban_trend(socket_path, range, origin=origin) + + +@router.get( + "/bans/by-jail", + response_model=BansByJailResponse, + summary="Return ban counts aggregated by jail", +) +async def get_bans_by_jail( + request: Request, + _auth: AuthDep, + range: TimeRange = Query(default=_DEFAULT_RANGE, description="Time-range preset."), + origin: BanOrigin | None = Query( + default=None, + description="Filter by ban origin: 'blocklist' or 'selfblock'. Omit for all.", + ), +) -> BansByJailResponse: + """Return ban counts grouped by jail name for the selected time window. + + Queries the fail2ban database and returns a list of jails sorted by + ban count descending. This endpoint is intended for the dashboard jail + distribution bar chart. + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session dependency. + range: Time-range preset — ``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``. + origin: Optional filter by ban origin. + + Returns: + :class:`~app.models.ban.BansByJailResponse` with per-jail counts + sorted descending and the total for the selected window. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + + return await ban_service.bans_by_jail(socket_path, range, origin=origin) diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py new file mode 100644 index 0000000..54aee28 --- /dev/null +++ b/backend/app/routers/file_config.py @@ -0,0 +1,832 @@ +"""File-based fail2ban configuration router. + +Provides endpoints to list, view, edit, and create fail2ban configuration +files directly on the filesystem (``jail.d/``, ``filter.d/``, ``action.d/``). + +Endpoints: +* ``GET /api/config/jail-files`` — list all jail config files +* ``GET /api/config/jail-files/{filename}`` — get one jail config file (with content) +* ``PUT /api/config/jail-files/{filename}`` — overwrite a jail config file +* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config +* ``GET /api/config/filters/{name}/raw`` — get one filter file raw content +* ``PUT /api/config/filters/{name}/raw`` — update a filter file (raw content) +* ``POST /api/config/filters/raw`` — create a new filter file (raw content) +* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model +* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model +* ``GET /api/config/actions`` — list all action files +* ``GET /api/config/actions/{name}`` — get one action file (with content) +* ``PUT /api/config/actions/{name}`` — update an action file +* ``POST /api/config/actions`` — create a new action file +* ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model +* ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model + +Note: ``GET /api/config/filters`` (enriched list) and +``GET /api/config/filters/{name}`` (full parsed detail) are handled by the +config router (``config.py``), which is registered first and therefore takes +precedence. Raw-content read/write variants are at ``/filters/{name}/raw`` +and ``POST /filters/raw``. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Path, Request, status + +from app.dependencies import AuthDep +from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + FilterConfig, + FilterConfigUpdate, + JailFileConfig, + JailFileConfigUpdate, +) +from app.models.file_config import ( + ConfFileContent, + ConfFileCreateRequest, + ConfFilesResponse, + ConfFileUpdateRequest, + JailConfigFileContent, + JailConfigFileEnabledUpdate, + JailConfigFilesResponse, +) +from app.services import file_config_service +from app.services.file_config_service import ( + ConfigDirError, + ConfigFileExistsError, + ConfigFileNameError, + ConfigFileNotFoundError, + ConfigFileWriteError, +) + +router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"]) + +# --------------------------------------------------------------------------- +# Path type aliases +# --------------------------------------------------------------------------- + +_FilenamePath = Annotated[ + str, Path(description="Config filename including extension (e.g. ``sshd.conf``).") +] +_NamePath = Annotated[ + str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).") +] + +# --------------------------------------------------------------------------- +# Error helpers +# --------------------------------------------------------------------------- + + +def _not_found(filename: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Config file not found: {filename!r}", + ) + + +def _bad_request(message: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=message, + ) + + +def _conflict(filename: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Config file already exists: {filename!r}", + ) + + +def _service_unavailable(message: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=message, + ) + + +# --------------------------------------------------------------------------- +# Jail config file endpoints (Task 4a) +# --------------------------------------------------------------------------- + + +@router.get( + "/jail-files", + response_model=JailConfigFilesResponse, + summary="List all jail config files", +) +async def list_jail_config_files( + request: Request, + _auth: AuthDep, +) -> JailConfigFilesResponse: + """Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``. + + The ``enabled`` field reflects the value of the ``enabled`` key inside the + file (defaulting to ``true`` when the key is absent). + + Args: + request: Incoming request (used for ``app.state.settings``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.file_config.JailConfigFilesResponse`. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.list_jail_config_files(config_dir) + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.get( + "/jail-files/{filename}", + response_model=JailConfigFileContent, + summary="Return a single jail config file with its content", +) +async def get_jail_config_file( + request: Request, + _auth: AuthDep, + filename: _FilenamePath, +) -> JailConfigFileContent: + """Return the metadata and raw content of one jail config file. + + Args: + request: Incoming request. + _auth: Validated session. + filename: Filename including extension (e.g. ``sshd.conf``). + + Returns: + :class:`~app.models.file_config.JailConfigFileContent`. + + Raises: + HTTPException: 400 if *filename* is unsafe. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_jail_config_file(config_dir, filename) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(filename) from None + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/jail-files/{filename}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Overwrite a jail.d config file with new raw content", +) +async def write_jail_config_file( + request: Request, + _auth: AuthDep, + filename: _FilenamePath, + body: ConfFileUpdateRequest, +) -> None: + """Overwrite the raw content of an existing jail.d config file. + + The change is written directly to disk. You must reload fail2ban + (``POST /api/config/reload``) separately for the change to take effect. + + Args: + request: Incoming request. + _auth: Validated session. + filename: Filename of the jail config file (e.g. ``sshd.conf``). + body: New raw file content. + + Raises: + HTTPException: 400 if *filename* is unsafe or content is invalid. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.write_jail_config_file(config_dir, filename, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(filename) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/jail-files/{filename}/enabled", + status_code=status.HTTP_204_NO_CONTENT, + summary="Enable or disable a jail configuration file", +) +async def set_jail_config_file_enabled( + request: Request, + _auth: AuthDep, + filename: _FilenamePath, + body: JailConfigFileEnabledUpdate, +) -> None: + """Set the ``enabled = true/false`` key inside a jail config file. + + The change modifies the file on disk. You must reload fail2ban + (``POST /api/config/reload``) separately for the change to take effect. + + Args: + request: Incoming request. + _auth: Validated session. + filename: Filename of the jail config file (e.g. ``sshd.conf``). + body: New enabled state. + + Raises: + HTTPException: 400 if *filename* is unsafe or the operation fails. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.set_jail_config_enabled( + config_dir, filename, body.enabled + ) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(filename) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.post( + "/jail-files", + response_model=ConfFileContent, + status_code=status.HTTP_201_CREATED, + summary="Create a new jail.d config file", +) +async def create_jail_config_file( + request: Request, + _auth: AuthDep, + body: ConfFileCreateRequest, +) -> ConfFileContent: + """Create a new ``.conf`` file in ``jail.d/``. + + Args: + request: Incoming request. + _auth: Validated session. + body: :class:`~app.models.file_config.ConfFileCreateRequest` with name and content. + + Returns: + :class:`~app.models.file_config.ConfFileContent` with the created file metadata. + + Raises: + HTTPException: 400 if the name is unsafe or the content exceeds the size limit. + HTTPException: 409 if a file with that name already exists. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + filename = await file_config_service.create_jail_config_file(config_dir, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileExistsError: + raise _conflict(body.name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + return ConfFileContent( + name=body.name, + filename=filename, + content=body.content, + ) + + +# --------------------------------------------------------------------------- +# Filter file endpoints (Task 4d) +# --------------------------------------------------------------------------- + + +@router.get( + "/filters/{name}/raw", + response_model=ConfFileContent, + summary="Return a filter definition file's raw content", +) +async def get_filter_file_raw( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> ConfFileContent: + """Return the raw content of a filter definition file. + + This endpoint provides direct access to the file bytes for the raw + config editor. For structured parsing with active/inactive status use + ``GET /api/config/filters/{name}`` (served by the config router). + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``). + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + HTTPException: 400 if *name* is unsafe. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_filter_file(config_dir, name) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/filters/{name}/raw", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update a filter definition file (raw content)", +) +async def write_filter_file( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: ConfFileUpdateRequest, +) -> None: + """Overwrite the content of an existing filter definition file. + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name with or without extension. + body: New file content. + + Raises: + HTTPException: 400 if *name* is unsafe or content exceeds the size limit. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.write_filter_file(config_dir, name, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.post( + "/filters/raw", + status_code=status.HTTP_201_CREATED, + response_model=ConfFileContent, + summary="Create a new filter definition file (raw content)", +) +async def create_filter_file( + request: Request, + _auth: AuthDep, + body: ConfFileCreateRequest, +) -> ConfFileContent: + """Create a new ``.conf`` file in ``filter.d/``. + + Args: + request: Incoming request. + _auth: Validated session. + body: Name and initial content for the new file. + + Returns: + The created :class:`~app.models.file_config.ConfFileContent`. + + Raises: + HTTPException: 400 if *name* is invalid or content exceeds limit. + HTTPException: 409 if a file with that name already exists. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + filename = await file_config_service.create_filter_file(config_dir, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileExistsError: + raise _conflict(body.name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + return ConfFileContent( + name=body.name, + filename=filename, + content=body.content, + ) + + +# --------------------------------------------------------------------------- +# Action file endpoints (Task 4e) +# --------------------------------------------------------------------------- + + +@router.get( + "/actions", + response_model=ConfFilesResponse, + summary="List all action definition files", +) +async def list_action_files( + request: Request, + _auth: AuthDep, +) -> ConfFilesResponse: + """Return a list of every ``.conf`` and ``.local`` file in ``action.d/``. + + Args: + request: Incoming request. + _auth: Validated session. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.list_action_files(config_dir) + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.get( + "/actions/{name}", + response_model=ConfFileContent, + summary="Return an action definition file with its content", +) +async def get_action_file( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> ConfFileContent: + """Return the content of an action definition file. + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name with or without extension. + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + HTTPException: 400 if *name* is unsafe. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_action_file(config_dir, name) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/actions/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update an action definition file", +) +async def write_action_file( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: ConfFileUpdateRequest, +) -> None: + """Overwrite the content of an existing action definition file. + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name with or without extension. + body: New file content. + + Raises: + HTTPException: 400 if *name* is unsafe or content exceeds the size limit. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.write_action_file(config_dir, name, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.post( + "/actions", + status_code=status.HTTP_201_CREATED, + response_model=ConfFileContent, + summary="Create a new action definition file", +) +async def create_action_file( + request: Request, + _auth: AuthDep, + body: ConfFileCreateRequest, +) -> ConfFileContent: + """Create a new ``.conf`` file in ``action.d/``. + + Args: + request: Incoming request. + _auth: Validated session. + body: Name and initial content for the new file. + + Returns: + The created :class:`~app.models.file_config.ConfFileContent`. + + Raises: + HTTPException: 400 if *name* is invalid or content exceeds limit. + HTTPException: 409 if a file with that name already exists. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + filename = await file_config_service.create_action_file(config_dir, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileExistsError: + raise _conflict(body.name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + return ConfFileContent( + name=body.name, + filename=filename, + content=body.content, + ) + + +# --------------------------------------------------------------------------- +# Parsed filter endpoints (Task 2.1) +# --------------------------------------------------------------------------- + + +@router.get( + "/filters/{name}/parsed", + response_model=FilterConfig, + summary="Return a filter file parsed into a structured model", +) +async def get_parsed_filter( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> FilterConfig: + """Parse a filter definition file and return its structured fields. + + The file is read from ``filter.d/``, parsed as fail2ban INI format, and + returned as a :class:`~app.models.config.FilterConfig` JSON object. This + is the input model for the form-based filter editor (Task 2.3). + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name (e.g. ``sshd`` or ``sshd.conf``). + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 400 if *name* is unsafe. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_parsed_filter_file(config_dir, name) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/filters/{name}/parsed", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update a filter file from a structured model", +) +async def update_parsed_filter( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: FilterConfigUpdate, +) -> None: + """Apply a partial structured update to a filter definition file. + + Fields set to ``null`` in the request body are left unchanged. The file is + re-serialized to fail2ban INI format after merging. + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name of the filter to update. + body: Partial :class:`~app.models.config.FilterConfigUpdate`. + + Raises: + HTTPException: 400 if *name* is unsafe or content exceeds the size limit. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.update_parsed_filter_file(config_dir, name, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +# --------------------------------------------------------------------------- +# Parsed action endpoints (Task 3.1) +# --------------------------------------------------------------------------- + + +@router.get( + "/actions/{name}/parsed", + response_model=ActionConfig, + summary="Return an action file parsed into a structured model", +) +async def get_parsed_action( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> ActionConfig: + """Parse an action definition file and return its structured fields. + + The file is read from ``action.d/``, parsed as fail2ban INI format, and + returned as a :class:`~app.models.config.ActionConfig` JSON object. This + is the input model for the form-based action editor (Task 3.3). + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name (e.g. ``iptables`` or ``iptables.conf``). + + Returns: + :class:`~app.models.config.ActionConfig`. + + Raises: + HTTPException: 400 if *name* is unsafe. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_parsed_action_file(config_dir, name) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/actions/{name}/parsed", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update an action file from a structured model", +) +async def update_parsed_action( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: ActionConfigUpdate, +) -> None: + """Apply a partial structured update to an action definition file. + + Fields set to ``null`` in the request body are left unchanged. The file is + re-serialized to fail2ban INI format after merging. + + Args: + request: Incoming request. + _auth: Validated session. + name: Base name of the action to update. + body: Partial :class:`~app.models.config.ActionConfigUpdate`. + + Raises: + HTTPException: 400 if *name* is unsafe or content exceeds the size limit. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.update_parsed_action_file(config_dir, name, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(name) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +# --------------------------------------------------------------------------- +# Parsed jail file endpoints (Task 6.1) +# --------------------------------------------------------------------------- + + +@router.get( + "/jail-files/{filename}/parsed", + response_model=JailFileConfig, + summary="Return a jail.d file parsed into a structured model", +) +async def get_parsed_jail_file( + request: Request, + _auth: AuthDep, + filename: _NamePath, +) -> JailFileConfig: + """Parse a jail.d config file and return its structured fields. + + The file is read from ``jail.d/``, parsed as fail2ban INI format, and + returned as a :class:`~app.models.config.JailFileConfig` JSON object. This + is the input model for the form-based jail file editor (Task 6.2). + + Args: + request: Incoming request. + _auth: Validated session. + filename: Filename including extension (e.g. ``sshd.conf``). + + Returns: + :class:`~app.models.config.JailFileConfig`. + + Raises: + HTTPException: 400 if *filename* is unsafe. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + return await file_config_service.get_parsed_jail_file(config_dir, filename) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(filename) from None + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc + + +@router.put( + "/jail-files/{filename}/parsed", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update a jail.d file from a structured model", +) +async def update_parsed_jail_file( + request: Request, + _auth: AuthDep, + filename: _NamePath, + body: JailFileConfigUpdate, +) -> None: + """Apply a partial structured update to a jail.d config file. + + Fields set to ``null`` in the request body are left unchanged. The file is + re-serialized to fail2ban INI format after merging. + + Args: + request: Incoming request. + _auth: Validated session. + filename: Filename including extension (e.g. ``sshd.conf``). + body: Partial :class:`~app.models.config.JailFileConfigUpdate`. + + Raises: + HTTPException: 400 if *filename* is unsafe or content exceeds size limit. + HTTPException: 404 if the file does not exist. + HTTPException: 503 if the config directory is unavailable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await file_config_service.update_parsed_jail_file(config_dir, filename, body) + except ConfigFileNameError as exc: + raise _bad_request(str(exc)) from exc + except ConfigFileNotFoundError: + raise _not_found(filename) from None + except ConfigFileWriteError as exc: + raise _bad_request(str(exc)) from exc + except ConfigDirError as exc: + raise _service_unavailable(str(exc)) from exc diff --git a/backend/app/routers/geo.py b/backend/app/routers/geo.py new file mode 100644 index 0000000..0200496 --- /dev/null +++ b/backend/app/routers/geo.py @@ -0,0 +1,175 @@ +"""Geo / IP lookup router. + +Provides the IP enrichment endpoints: + +* ``GET /api/geo/lookup/{ip}`` — ban status, ban history, and geo info for an IP +* ``POST /api/geo/re-resolve`` — retry all previously failed geo lookups +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +if TYPE_CHECKING: + import aiohttp + +import aiosqlite +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status + +from app.dependencies import AuthDep, get_db +from app.models.geo import GeoCacheStatsResponse, GeoDetail, IpLookupResponse +from app.services import geo_service, jail_service +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter(prefix="/api/geo", tags=["Geo"]) + +_IpPath = Annotated[str, Path(description="IPv4 or IPv6 address to look up.")] + + +@router.get( + "/lookup/{ip}", + response_model=IpLookupResponse, + summary="Look up ban status and geo information for an IP", +) +async def lookup_ip( + request: Request, + _auth: AuthDep, + ip: _IpPath, +) -> IpLookupResponse: + """Return current ban status, geo data, and network information for an IP. + + Checks every running fail2ban jail to determine whether the IP is + currently banned, and enriches the result with country, ASN, and + organisation data from ip-api.com. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + ip: The IP address to look up. + + Returns: + :class:`~app.models.geo.IpLookupResponse` with ban status and geo data. + + Raises: + HTTPException: 400 when *ip* is not a valid IP address. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + async def _enricher(addr: str) -> geo_service.GeoInfo | None: + return await geo_service.lookup(addr, http_session) + + try: + result = await jail_service.lookup_ip( + socket_path, + ip, + geo_enricher=_enricher, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except Fail2BanConnectionError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Cannot reach fail2ban: {exc}", + ) from exc + + raw_geo = result.get("geo") + geo_detail: GeoDetail | None = None + if raw_geo is not None: + geo_detail = GeoDetail( + country_code=raw_geo.country_code, + country_name=raw_geo.country_name, + asn=raw_geo.asn, + org=raw_geo.org, + ) + + return IpLookupResponse( + ip=result["ip"], + currently_banned_in=result["currently_banned_in"], + geo=geo_detail, + ) + + +# --------------------------------------------------------------------------- +# POST /api/geo/re-resolve +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# GET /api/geo/stats +# --------------------------------------------------------------------------- + + +@router.get( + "/stats", + response_model=GeoCacheStatsResponse, + summary="Geo cache diagnostic counters", +) +async def geo_stats( + _auth: AuthDep, + db: Annotated[aiosqlite.Connection, Depends(get_db)], +) -> GeoCacheStatsResponse: + """Return diagnostic counters for the geo cache subsystem. + + Useful for operators and the UI to gauge geo-resolution health. + + Args: + _auth: Validated session — enforces authentication. + db: BanGUI application database connection. + + Returns: + :class:`~app.models.geo.GeoCacheStatsResponse` with current counters. + """ + stats: dict[str, int] = await geo_service.cache_stats(db) + return GeoCacheStatsResponse(**stats) + + +@router.post( + "/re-resolve", + summary="Re-resolve all IPs whose country could not be determined", +) +async def re_resolve_geo( + request: Request, + _auth: AuthDep, + db: Annotated[aiosqlite.Connection, Depends(get_db)], +) -> dict[str, int]: + """Retry geo resolution for every IP in ``geo_cache`` with a null country. + + Clears the in-memory negative cache first so that previously failing IPs + are immediately eligible for a new API attempt. + + Args: + request: Incoming request (used to access ``app.state.http_session``). + _auth: Validated session — enforces authentication. + db: BanGUI application database (for reading/writing ``geo_cache``). + + Returns: + JSON object ``{"resolved": N, "total": M}`` where *N* is the number + of IPs that gained a country code and *M* is the total number of IPs + that were retried. + """ + # Collect all IPs in geo_cache that still lack a country code. + unresolved: list[str] = [] + async with db.execute( + "SELECT ip FROM geo_cache WHERE country_code IS NULL" + ) as cur: + async for row in cur: + unresolved.append(str(row[0])) + + if not unresolved: + return {"resolved": 0, "total": 0} + + # Clear negative cache so these IPs bypass the TTL check. + geo_service.clear_neg_cache() + + http_session: aiohttp.ClientSession = request.app.state.http_session + geo_map = await geo_service.lookup_batch(unresolved, http_session, db=db) + + resolved_count = sum( + 1 for info in geo_map.values() if info.country_code is not None + ) + return {"resolved": resolved_count, "total": len(unresolved)} diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..18c733d --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,37 @@ +"""Health check router. + +A lightweight ``GET /api/health`` endpoint that verifies the application +is running and can serve requests. Also reports the cached fail2ban liveness +state so monitoring tools and Docker health checks can observe daemon status +without probing the socket directly. +""" + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from app.models.server import ServerStatus + +router: APIRouter = APIRouter(prefix="/api", tags=["Health"]) + + +@router.get("/health", summary="Application health check") +async def health_check(request: Request) -> JSONResponse: + """Return 200 with application and fail2ban status. + + HTTP 200 is always returned so Docker health checks do not restart the + backend container when fail2ban is temporarily offline. The + ``fail2ban`` field in the body indicates the daemon's current state. + + Args: + request: FastAPI request (used to read cached server status). + + Returns: + A JSON object with ``{"status": "ok", "fail2ban": "online"|"offline"}``. + """ + cached: ServerStatus = getattr( + request.app.state, "server_status", ServerStatus(online=False) + ) + return JSONResponse(content={ + "status": "ok", + "fail2ban": "online" if cached.online else "offline", + }) diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py new file mode 100644 index 0000000..1abeb0e --- /dev/null +++ b/backend/app/routers/history.py @@ -0,0 +1,141 @@ +"""History router. + +Provides endpoints for forensic exploration of all historical ban records +stored in the fail2ban SQLite database. + +Routes +------ +``GET /api/history`` + Paginated list of all historical bans, filterable by jail, IP prefix, and + time range. + +``GET /api/history/{ip}`` + Per-IP detail: complete ban timeline, aggregated totals, and geolocation. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import aiohttp + +from fastapi import APIRouter, HTTPException, Query, Request + +from app.dependencies import AuthDep +from app.models.ban import TimeRange +from app.models.history import HistoryListResponse, IpDetailResponse +from app.services import geo_service, history_service + +router: APIRouter = APIRouter(prefix="/api/history", tags=["History"]) + +_DEFAULT_PAGE_SIZE: int = 100 + + +@router.get( + "", + response_model=HistoryListResponse, + summary="Return a paginated list of historical bans", +) +async def get_history( + request: Request, + _auth: AuthDep, + range: TimeRange | None = Query( + default=None, + description="Optional time-range filter. Omit for all-time.", + ), + jail: str | None = Query( + default=None, + description="Restrict results to this jail name.", + ), + ip: str | None = Query( + default=None, + description="Restrict results to IPs matching this prefix.", + ), + page: int = Query(default=1, ge=1, description="1-based page number."), + page_size: int = Query( + default=_DEFAULT_PAGE_SIZE, + ge=1, + le=500, + description="Items per page (max 500).", + ), +) -> HistoryListResponse: + """Return a paginated list of historical bans with optional filters. + + Queries the fail2ban database for all ban records, applying the requested + filters. Results are ordered newest-first and enriched with geolocation. + + Args: + request: The incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + range: Optional time-range preset. ``None`` means all-time. + jail: Optional jail name filter (exact match). + ip: Optional IP prefix filter (prefix match). + page: 1-based page number. + page_size: Items per page (1–500). + + Returns: + :class:`~app.models.history.HistoryListResponse` with paginated items + and the total matching count. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + async def _enricher(addr: str) -> geo_service.GeoInfo | None: + return await geo_service.lookup(addr, http_session) + + return await history_service.list_history( + socket_path, + range_=range, + jail=jail, + ip_filter=ip, + page=page, + page_size=page_size, + geo_enricher=_enricher, + ) + + +@router.get( + "/{ip}", + response_model=IpDetailResponse, + summary="Return the full ban history for a single IP address", +) +async def get_ip_history( + request: Request, + _auth: AuthDep, + ip: str, +) -> IpDetailResponse: + """Return the complete historical record for a single IP address. + + Fetches all ban events for the given IP from the fail2ban database and + aggregates them into a timeline. Returns ``404`` if the IP has no + recorded history. + + Args: + request: The incoming request. + _auth: Validated session dependency. + ip: The IP address to look up. + + Returns: + :class:`~app.models.history.IpDetailResponse` with aggregated totals + and a full ban timeline. + + Raises: + HTTPException: 404 if the IP has no history in the database. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + http_session: aiohttp.ClientSession = request.app.state.http_session + + async def _enricher(addr: str) -> geo_service.GeoInfo | None: + return await geo_service.lookup(addr, http_session) + + detail: IpDetailResponse | None = await history_service.get_ip_detail( + socket_path, + ip, + geo_enricher=_enricher, + ) + + if detail is None: + raise HTTPException(status_code=404, detail=f"No history found for IP {ip!r}.") + + return detail diff --git a/backend/app/routers/jails.py b/backend/app/routers/jails.py new file mode 100644 index 0000000..e15265d --- /dev/null +++ b/backend/app/routers/jails.py @@ -0,0 +1,615 @@ +"""Jails router. + +Provides CRUD and control operations for fail2ban jails: + +* ``GET /api/jails`` — list all jails +* ``GET /api/jails/{name}`` — full detail for one jail +* ``GET /api/jails/{name}/banned`` — paginated currently-banned IPs for one jail +* ``POST /api/jails/{name}/start`` — start a jail +* ``POST /api/jails/{name}/stop`` — stop a jail +* ``POST /api/jails/{name}/idle`` — toggle idle mode +* ``POST /api/jails/{name}/reload`` — reload a single jail +* ``POST /api/jails/reload-all`` — reload every jail + +* ``GET /api/jails/{name}/ignoreip`` — ignore-list for a jail +* ``POST /api/jails/{name}/ignoreip`` — add IP to ignore list +* ``DELETE /api/jails/{name}/ignoreip`` — remove IP from ignore list +* ``POST /api/jails/{name}/ignoreself`` — toggle ignoreself option +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Body, HTTPException, Path, Request, status + +from app.dependencies import AuthDep +from app.models.ban import JailBannedIpsResponse +from app.models.jail import ( + IgnoreIpRequest, + JailCommandResponse, + JailDetailResponse, + JailListResponse, +) +from app.services import jail_service +from app.services.jail_service import JailNotFoundError, JailOperationError +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter(prefix="/api/jails", tags=["Jails"]) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")] + + +def _not_found(name: str) -> HTTPException: + """Return a 404 response for an unknown jail. + + Args: + name: Jail name that was not found. + + Returns: + :class:`fastapi.HTTPException` with status 404. + """ + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Jail not found: {name!r}", + ) + + +def _bad_gateway(exc: Exception) -> HTTPException: + """Return a 502 response when fail2ban is unreachable. + + Args: + exc: The underlying connection error. + + Returns: + :class:`fastapi.HTTPException` with status 502. + """ + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Cannot reach fail2ban: {exc}", + ) + + +def _conflict(message: str) -> HTTPException: + """Return a 409 response for invalid jail state transitions. + + Args: + message: Human-readable description of the conflict. + + Returns: + :class:`fastapi.HTTPException` with status 409. + """ + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=message, + ) + + +# --------------------------------------------------------------------------- +# Jail listing & detail +# --------------------------------------------------------------------------- + + +@router.get( + "", + response_model=JailListResponse, + summary="List all active fail2ban jails", +) +async def get_jails( + request: Request, + _auth: AuthDep, +) -> JailListResponse: + """Return a summary of every active fail2ban jail. + + Includes runtime metrics (currently banned, total bans, failures) and + key configuration (find time, ban time, max retries, backend, idle state) + for each jail. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.jail.JailListResponse` with all active jails. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await jail_service.list_jails(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.get( + "/{name}", + response_model=JailDetailResponse, + summary="Return full detail for a single jail", +) +async def get_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailDetailResponse: + """Return the complete configuration and runtime state for one jail. + + Includes log paths, fail regex and ignore regex patterns, date pattern, + log encoding, attached action names, ban-time settings, and runtime + counters. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + + Returns: + :class:`~app.models.jail.JailDetailResponse` with the full jail. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await jail_service.get_jail(socket_path, name) + except JailNotFoundError: + raise _not_found(name) from None + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Jail control commands +# --------------------------------------------------------------------------- + + +@router.post( + "/reload-all", + response_model=JailCommandResponse, + summary="Reload all fail2ban jails", +) +async def reload_all_jails( + request: Request, + _auth: AuthDep, +) -> JailCommandResponse: + """Reload every fail2ban jail to apply configuration changes. + + This command instructs fail2ban to re-read its configuration for all + jails simultaneously. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the reload. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + HTTPException: 409 when fail2ban reports the operation failed. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.reload_all(socket_path) + return JailCommandResponse(message="All jails reloaded successfully.", jail="*") + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/{name}/start", + response_model=JailCommandResponse, + summary="Start a stopped jail", +) +async def start_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailCommandResponse: + """Start a fail2ban jail that is currently stopped. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the start. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.start_jail(socket_path, name) + return JailCommandResponse(message=f"Jail {name!r} started.", jail=name) + except JailNotFoundError: + raise _not_found(name) from None + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/{name}/stop", + response_model=JailCommandResponse, + summary="Stop a running jail", +) +async def stop_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailCommandResponse: + """Stop a running fail2ban jail. + + The jail will no longer monitor logs or issue new bans. Existing bans + may or may not be removed depending on fail2ban configuration. If the + jail is already stopped the request succeeds silently (idempotent). + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the stop. + + Raises: + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.stop_jail(socket_path, name) + return JailCommandResponse(message=f"Jail {name!r} stopped.", jail=name) + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/{name}/idle", + response_model=JailCommandResponse, + summary="Toggle idle mode for a jail", +) +async def toggle_idle( + request: Request, + _auth: AuthDep, + name: _NamePath, + on: bool = Body(..., description="``true`` to enable idle, ``false`` to disable."), +) -> JailCommandResponse: + """Enable or disable idle mode for a fail2ban jail. + + In idle mode the jail suspends log monitoring without fully stopping, + preserving all existing bans. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + on: ``true`` to enable idle, ``false`` to disable. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the change. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + state_str = "on" if on else "off" + try: + await jail_service.set_idle(socket_path, name, on=on) + return JailCommandResponse( + message=f"Jail {name!r} idle mode turned {state_str}.", + jail=name, + ) + except JailNotFoundError: + raise _not_found(name) from None + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/{name}/reload", + response_model=JailCommandResponse, + summary="Reload a single jail", +) +async def reload_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> JailCommandResponse: + """Reload a single fail2ban jail to pick up configuration changes. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the reload. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.reload_jail(socket_path, name) + return JailCommandResponse(message=f"Jail {name!r} reloaded.", jail=name) + except JailNotFoundError: + raise _not_found(name) from None + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Ignore list (IP whitelist) +# --------------------------------------------------------------------------- + + +class _IgnoreSelfRequest(IgnoreIpRequest): + """Request body for the ignoreself toggle endpoint. + + Inherits from :class:`~app.models.jail.IgnoreIpRequest` but overrides + the ``ip`` field with a boolean ``on`` field. + """ + + +@router.get( + "/{name}/ignoreip", + response_model=list[str], + summary="List the ignore IPs for a jail", +) +async def get_ignore_list( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> list[str]: + """Return the current ignore list (IP whitelist) for a fail2ban jail. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + + Returns: + List of IP addresses and CIDR networks on the ignore list. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await jail_service.get_ignore_list(socket_path, name) + except JailNotFoundError: + raise _not_found(name) from None + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/{name}/ignoreip", + status_code=status.HTTP_201_CREATED, + response_model=JailCommandResponse, + summary="Add an IP or network to the ignore list", +) +async def add_ignore_ip( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: IgnoreIpRequest, +) -> JailCommandResponse: + """Add an IP address or CIDR network to a jail's ignore list. + + IPs on the ignore list are never banned by that jail, even if they + trigger the configured fail regex. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + body: Payload containing the IP or CIDR to add. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the addition. + + Raises: + HTTPException: 400 when the IP address or network is invalid. + HTTPException: 404 when the jail does not exist. + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.add_ignore_ip(socket_path, name, body.ip) + return JailCommandResponse( + message=f"IP {body.ip!r} added to ignore list of jail {name!r}.", + jail=name, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except JailNotFoundError: + raise _not_found(name) from None + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.delete( + "/{name}/ignoreip", + response_model=JailCommandResponse, + summary="Remove an IP or network from the ignore list", +) +async def del_ignore_ip( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: IgnoreIpRequest, +) -> JailCommandResponse: + """Remove an IP address or CIDR network from a jail's ignore list. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + body: Payload containing the IP or CIDR to remove. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the removal. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await jail_service.del_ignore_ip(socket_path, name, body.ip) + return JailCommandResponse( + message=f"IP {body.ip!r} removed from ignore list of jail {name!r}.", + jail=name, + ) + except JailNotFoundError: + raise _not_found(name) from None + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/{name}/ignoreself", + response_model=JailCommandResponse, + summary="Toggle the ignoreself option for a jail", +) +async def toggle_ignore_self( + request: Request, + _auth: AuthDep, + name: _NamePath, + on: bool = Body(..., description="``true`` to enable ignoreself, ``false`` to disable."), +) -> JailCommandResponse: + """Toggle the ``ignoreself`` flag for a fail2ban jail. + + When ``ignoreself`` is enabled fail2ban automatically adds the server's + own IP addresses to the ignore list so the host can never ban itself. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + on: ``true`` to enable, ``false`` to disable. + + Returns: + :class:`~app.models.jail.JailCommandResponse` confirming the change. + + Raises: + HTTPException: 404 when the jail does not exist. + HTTPException: 409 when fail2ban reports the operation failed. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + state_str = "enabled" if on else "disabled" + try: + await jail_service.set_ignore_self(socket_path, name, on=on) + return JailCommandResponse( + message=f"ignoreself {state_str} for jail {name!r}.", + jail=name, + ) + except JailNotFoundError: + raise _not_found(name) from None + except JailOperationError as exc: + raise _conflict(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Currently banned IPs (paginated) +# --------------------------------------------------------------------------- + + +@router.get( + "/{name}/banned", + response_model=JailBannedIpsResponse, + summary="Return paginated currently-banned IPs for a single jail", +) +async def get_jail_banned_ips( + request: Request, + _auth: AuthDep, + name: _NamePath, + page: int = 1, + page_size: int = 25, + search: str | None = None, +) -> JailBannedIpsResponse: + """Return a paginated list of IPs currently banned by a specific jail. + + The full ban list is fetched from the fail2ban socket, filtered by the + optional *search* substring, sliced to the requested page, and then + geo-enriched exclusively for that page slice. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + name: Jail name. + page: 1-based page number (default 1, min 1). + page_size: Items per page (default 25, max 100). + search: Optional case-insensitive substring filter on the IP address. + + Returns: + :class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans. + + Raises: + HTTPException: 400 when *page* or *page_size* are out of range. + HTTPException: 404 when the jail does not exist. + HTTPException: 502 when fail2ban is unreachable. + """ + if page < 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="page must be >= 1.", + ) + if not (1 <= page_size <= 100): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="page_size must be between 1 and 100.", + ) + + socket_path: str = request.app.state.settings.fail2ban_socket + http_session = getattr(request.app.state, "http_session", None) + app_db = getattr(request.app.state, "db", None) + + try: + return await jail_service.get_jail_banned_ips( + socket_path=socket_path, + jail_name=name, + page=page, + page_size=page_size, + search=search, + http_session=http_session, + app_db=app_db, + ) + except JailNotFoundError: + raise _not_found(name) from None + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc diff --git a/backend/app/routers/server.py b/backend/app/routers/server.py new file mode 100644 index 0000000..1e2e488 --- /dev/null +++ b/backend/app/routers/server.py @@ -0,0 +1,144 @@ +"""Server settings router. + +Provides endpoints to view and update fail2ban server-level settings and +to flush log files. + +* ``GET /api/server/settings`` — current log level, target, and DB config +* ``PUT /api/server/settings`` — update server-level settings +* ``POST /api/server/flush-logs`` — flush and re-open log files +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, status + +from app.dependencies import AuthDep +from app.models.server import ServerSettingsResponse, ServerSettingsUpdate +from app.services import server_service +from app.services.server_service import ServerOperationError +from app.utils.fail2ban_client import Fail2BanConnectionError + +router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _bad_gateway(exc: Exception) -> HTTPException: + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Cannot reach fail2ban: {exc}", + ) + + +def _bad_request(message: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=message, + ) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/settings", + response_model=ServerSettingsResponse, + summary="Return fail2ban server-level settings", +) +async def get_server_settings( + request: Request, + _auth: AuthDep, +) -> ServerSettingsResponse: + """Return the current fail2ban server-level settings. + + Includes log level, log target, syslog socket, database file path, + database purge age, and maximum stored matches per record. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.server.ServerSettingsResponse`. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await server_service.get_settings(socket_path) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.put( + "/settings", + status_code=status.HTTP_204_NO_CONTENT, + summary="Update fail2ban server-level settings", +) +async def update_server_settings( + request: Request, + _auth: AuthDep, + body: ServerSettingsUpdate, +) -> None: + """Update fail2ban server-level settings. + + Only non-None fields in the request body are written. Changes take + effect immediately without a daemon restart. + + Args: + request: Incoming request. + _auth: Validated session. + body: Partial settings update. + + Raises: + HTTPException: 400 when a set command is rejected by fail2ban. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await server_service.update_settings(socket_path, body) + except ServerOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + +@router.post( + "/flush-logs", + status_code=status.HTTP_200_OK, + summary="Flush and re-open fail2ban log files", +) +async def flush_logs( + request: Request, + _auth: AuthDep, +) -> dict[str, str]: + """Flush and re-open fail2ban log files. + + Useful after log rotation so the daemon writes to the newly created + log file rather than continuing to append to the rotated one. + + Args: + request: Incoming request. + _auth: Validated session. + + Returns: + ``{"message": ""}`` + + Raises: + HTTPException: 400 when the command is rejected. + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + result = await server_service.flush_logs(socket_path) + return {"message": result} + except ServerOperationError as exc: + raise _bad_request(str(exc)) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py new file mode 100644 index 0000000..c42c6f6 --- /dev/null +++ b/backend/app/routers/setup.py @@ -0,0 +1,91 @@ +"""Setup router. + +Exposes the ``POST /api/setup`` endpoint for the one-time first-run +configuration wizard. Once setup has been completed, subsequent calls +return ``409 Conflict``. +""" + +from __future__ import annotations + +import structlog +from fastapi import APIRouter, HTTPException, status + +from app.dependencies import DbDep +from app.models.setup import SetupRequest, SetupResponse, SetupStatusResponse, SetupTimezoneResponse +from app.services import setup_service + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +router = APIRouter(prefix="/api/setup", tags=["setup"]) + + +@router.get( + "", + response_model=SetupStatusResponse, + summary="Check whether setup has been completed", +) +async def get_setup_status(db: DbDep) -> SetupStatusResponse: + """Return whether the initial setup wizard has been completed. + + Returns: + :class:`~app.models.setup.SetupStatusResponse` with ``completed`` + set to ``True`` if setup is done, ``False`` otherwise. + """ + done = await setup_service.is_setup_complete(db) + return SetupStatusResponse(completed=done) + + +@router.post( + "", + response_model=SetupResponse, + status_code=status.HTTP_201_CREATED, + summary="Run the initial setup wizard", +) +async def post_setup(body: SetupRequest, db: DbDep) -> SetupResponse: + """Persist the initial BanGUI configuration. + + Args: + body: Setup request payload validated by Pydantic. + db: Injected aiosqlite connection. + + Returns: + :class:`~app.models.setup.SetupResponse` on success. + + Raises: + HTTPException: 409 if setup has already been completed. + """ + if await setup_service.is_setup_complete(db): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Setup has already been completed.", + ) + + await setup_service.run_setup( + db, + master_password=body.master_password, + database_path=body.database_path, + fail2ban_socket=body.fail2ban_socket, + timezone=body.timezone, + session_duration_minutes=body.session_duration_minutes, + ) + return SetupResponse() + + +@router.get( + "/timezone", + response_model=SetupTimezoneResponse, + summary="Return the configured IANA timezone", +) +async def get_timezone(db: DbDep) -> SetupTimezoneResponse: + """Return the IANA timezone configured during the initial setup wizard. + + The frontend uses this to convert UTC timestamps to the local time zone + chosen by the administrator. + + Returns: + :class:`~app.models.setup.SetupTimezoneResponse` with ``timezone`` + set to the stored IANA identifier (e.g. ``"UTC"`` or + ``"Europe/Berlin"``), defaulting to ``"UTC"`` if unset. + """ + tz = await setup_service.get_timezone(db) + return SetupTimezoneResponse(timezone=tz) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..180fa71 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Business logic services package.""" diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..a947bcf --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,122 @@ +"""Authentication service. + +Handles password verification, session creation, session validation, and +session expiry. Sessions are stored in the SQLite database so they +survive server restarts. +""" + +from __future__ import annotations + +import asyncio +import secrets +from typing import TYPE_CHECKING + +import bcrypt +import structlog + +if TYPE_CHECKING: + import aiosqlite + + from app.models.auth import Session + +from app.repositories import session_repo +from app.services import setup_service +from app.utils.time_utils import add_minutes, utc_now + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + + +async def _check_password(plain: str, hashed: str) -> bool: + """Return ``True`` if *plain* matches the bcrypt *hashed* password. + + Runs in a thread executor so the blocking bcrypt operation does not stall + the asyncio event loop. + + Args: + plain: The plain-text password to verify. + hashed: The stored bcrypt hash string. + + Returns: + ``True`` on a successful match, ``False`` otherwise. + """ + plain_bytes = plain.encode() + hashed_bytes = hashed.encode() + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, lambda: bool(bcrypt.checkpw(plain_bytes, hashed_bytes)) + ) + + +async def login( + db: aiosqlite.Connection, + password: str, + session_duration_minutes: int, +) -> Session: + """Verify *password* and create a new session on success. + + Args: + db: Active aiosqlite connection. + password: Plain-text password supplied by the user. + session_duration_minutes: How long the new session is valid for. + + Returns: + A :class:`~app.models.auth.Session` domain model for the new session. + + Raises: + ValueError: If the password is incorrect or no password hash is stored. + """ + stored_hash = await setup_service.get_password_hash(db) + if stored_hash is None: + log.warning("bangui_login_no_hash") + raise ValueError("No password is configured — run setup first.") + + if not await _check_password(password, stored_hash): + log.warning("bangui_login_wrong_password") + raise ValueError("Incorrect password.") + + token = secrets.token_hex(32) + now = utc_now() + created_iso = now.isoformat() + expires_iso = add_minutes(now, session_duration_minutes).isoformat() + + session = await session_repo.create_session( + db, token=token, created_at=created_iso, expires_at=expires_iso + ) + log.info("bangui_login_success", token_prefix=token[:8]) + return session + + +async def validate_session(db: aiosqlite.Connection, token: str) -> Session: + """Return the session for *token* if it is valid and not expired. + + Args: + db: Active aiosqlite connection. + token: The opaque session token from the client. + + Returns: + The :class:`~app.models.auth.Session` if it is valid. + + Raises: + ValueError: If the token is not found or has expired. + """ + session = await session_repo.get_session(db, token) + if session is None: + raise ValueError("Session not found.") + + now_iso = utc_now().isoformat() + if session.expires_at <= now_iso: + await session_repo.delete_session(db, token) + raise ValueError("Session has expired.") + + return session + + +async def logout(db: aiosqlite.Connection, token: str) -> None: + """Invalidate the session identified by *token*. + + Args: + db: Active aiosqlite connection. + token: The session token to revoke. + """ + await session_repo.delete_session(db, token) + log.info("bangui_logout", token_prefix=token[:8]) diff --git a/backend/app/services/ban_service.py b/backend/app/services/ban_service.py new file mode 100644 index 0000000..ab08ab4 --- /dev/null +++ b/backend/app/services/ban_service.py @@ -0,0 +1,692 @@ +"""Ban service. + +Queries the fail2ban SQLite database for ban history. The fail2ban database +path is obtained at runtime by sending ``get dbfile`` to the fail2ban daemon +via the Unix domain socket. + +All database I/O is performed through aiosqlite opened in **read-only** mode +so BanGUI never modifies or locks the fail2ban database. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +import aiosqlite +import structlog + +from app.models.ban import ( + BLOCKLIST_JAIL, + BUCKET_SECONDS, + BUCKET_SIZE_LABEL, + TIME_RANGE_SECONDS, + BanOrigin, + BansByCountryResponse, + BansByJailResponse, + BanTrendBucket, + BanTrendResponse, + DashboardBanItem, + DashboardBanListResponse, + JailBanCount, + TimeRange, + _derive_origin, + bucket_count, +) +from app.utils.fail2ban_client import Fail2BanClient + +if TYPE_CHECKING: + import aiohttp + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_PAGE_SIZE: int = 100 +_MAX_PAGE_SIZE: int = 500 +_SOCKET_TIMEOUT: float = 5.0 + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]: + """Return a SQL fragment and its parameters for the origin filter. + + Args: + origin: ``"blocklist"`` to restrict to the blocklist-import jail, + ``"selfblock"`` to exclude it, or ``None`` for no restriction. + + Returns: + A ``(sql_fragment, params)`` pair — the fragment starts with ``" AND"`` + so it can be appended directly to an existing WHERE clause. + """ + if origin == "blocklist": + return " AND jail = ?", (BLOCKLIST_JAIL,) + if origin == "selfblock": + return " AND jail != ?", (BLOCKLIST_JAIL,) + return "", () + + +def _since_unix(range_: TimeRange) -> int: + """Return the Unix timestamp representing the start of the time window. + + Uses :func:`time.time` (always UTC epoch seconds on all platforms) to be + consistent with how fail2ban stores ``timeofban`` values in its SQLite + database. fail2ban records ``time.time()`` values directly, so + comparing against a timezone-aware ``datetime.now(UTC).timestamp()`` would + theoretically produce the same number but using :func:`time.time` avoids + any tz-aware datetime pitfalls on misconfigured systems. + + Args: + range_: One of the supported time-range presets. + + Returns: + Unix timestamp (seconds since epoch) equal to *now − range_*. + """ + seconds: int = TIME_RANGE_SECONDS[range_] + return int(time.time()) - seconds + + +def _ts_to_iso(unix_ts: int) -> str: + """Convert a Unix timestamp to an ISO 8601 UTC string. + + Args: + unix_ts: Seconds since the Unix epoch. + + Returns: + ISO 8601 UTC timestamp, e.g. ``"2026-03-01T12:00:00+00:00"``. + """ + return datetime.fromtimestamp(unix_ts, tz=UTC).isoformat() + + +async def _get_fail2ban_db_path(socket_path: str) -> str: + """Query fail2ban for the path to its SQLite database. + + Sends the ``get dbfile`` command via the fail2ban socket and returns + the value of the ``dbfile`` setting. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + Absolute path to the fail2ban SQLite database file. + + Raises: + RuntimeError: If fail2ban reports that no database is configured + or if the socket response is unexpected. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + async with Fail2BanClient(socket_path, timeout=_SOCKET_TIMEOUT) as client: + response = await client.send(["get", "dbfile"]) + + try: + code, data = response + except (TypeError, ValueError) as exc: + raise RuntimeError(f"Unexpected response from fail2ban: {response!r}") from exc + + if code != 0: + raise RuntimeError(f"fail2ban error code {code}: {data!r}") + + if data is None: + raise RuntimeError("fail2ban has no database configured (dbfile is None)") + + return str(data) + + +def _parse_data_json(raw: Any) -> tuple[list[str], int]: + """Extract matches and failure count from the ``bans.data`` column. + + The ``data`` column stores a JSON blob with optional keys: + + * ``matches`` — list of raw matched log lines. + * ``failures`` — total failure count that triggered the ban. + + Args: + raw: The raw ``data`` column value (string, dict, or ``None``). + + Returns: + A ``(matches, failures)`` tuple. Both default to empty/zero when + parsing fails or the column is absent. + """ + if raw is None: + return [], 0 + + obj: dict[str, Any] = {} + if isinstance(raw, str): + try: + parsed: Any = json.loads(raw) + if isinstance(parsed, dict): + obj = parsed + # json.loads("null") → None, or other non-dict — treat as empty + except json.JSONDecodeError: + return [], 0 + elif isinstance(raw, dict): + obj = raw + + matches: list[str] = [str(m) for m in (obj.get("matches") or [])] + failures: int = int(obj.get("failures", 0)) + return matches, failures + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def list_bans( + socket_path: str, + range_: TimeRange, + *, + page: int = 1, + page_size: int = _DEFAULT_PAGE_SIZE, + http_session: aiohttp.ClientSession | None = None, + app_db: aiosqlite.Connection | None = None, + geo_enricher: Any | None = None, + origin: BanOrigin | None = None, +) -> DashboardBanListResponse: + """Return a paginated list of bans within the selected time window. + + Queries the fail2ban database ``bans`` table for records whose + ``timeofban`` falls within the specified *range_*. Results are ordered + newest-first. + + Geo enrichment strategy (highest priority first): + + 1. When *http_session* is provided the entire page of IPs is resolved in + one :func:`~app.services.geo_service.lookup_batch` call (up to 100 IPs + per HTTP request). This avoids the 45 req/min rate limit of the + single-IP endpoint and is the preferred production path. + 2. When only *geo_enricher* is provided (legacy / test path) each IP is + resolved individually via the supplied async callable. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``). + page: 1-based page number (default: ``1``). + page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE`` + (default: ``100``). + http_session: Optional shared :class:`aiohttp.ClientSession`. When + provided, :func:`~app.services.geo_service.lookup_batch` is used + for efficient bulk geo resolution. + app_db: Optional BanGUI application database used to persist newly + resolved geo entries and to read back cached results. + geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``. + Used as a fallback when *http_session* is ``None`` (e.g. tests). + origin: Optional origin filter — ``"blocklist"`` restricts results to + the ``blocklist-import`` jail, ``"selfblock"`` excludes it. + + Returns: + :class:`~app.models.ban.DashboardBanListResponse` containing the + paginated items and total count. + """ + from app.services import geo_service # noqa: PLC0415 + + since: int = _since_unix(range_) + effective_page_size: int = min(page_size, _MAX_PAGE_SIZE) + offset: int = (page - 1) * effective_page_size + origin_clause, origin_params = _origin_sql_filter(origin) + + db_path: str = await _get_fail2ban_db_path(socket_path) + log.info( + "ban_service_list_bans", + db_path=db_path, + since=since, + range=range_, + origin=origin, + ) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + + async with f2b_db.execute( + "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, + (since, *origin_params), + ) as cur: + count_row = await cur.fetchone() + total: int = int(count_row[0]) if count_row else 0 + + async with f2b_db.execute( + "SELECT jail, ip, timeofban, bancount, data " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " ORDER BY timeofban DESC " + "LIMIT ? OFFSET ?", + (since, *origin_params, effective_page_size, offset), + ) as cur: + rows = await cur.fetchall() + + # Batch-resolve geo data for all IPs on this page in a single API call. + # This avoids hitting the 45 req/min single-IP rate limit when the + # page contains many bans (e.g. after a large blocklist import). + geo_map: dict[str, Any] = {} + if http_session is not None and rows: + page_ips: list[str] = [str(r["ip"]) for r in rows] + try: + geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db) + except Exception: # noqa: BLE001 + log.warning("ban_service_batch_geo_failed_list_bans") + + items: list[DashboardBanItem] = [] + for row in rows: + jail: str = str(row["jail"]) + ip: str = str(row["ip"]) + banned_at: str = _ts_to_iso(int(row["timeofban"])) + ban_count: int = int(row["bancount"]) + matches, _ = _parse_data_json(row["data"]) + service: str | None = matches[0] if matches else None + + country_code: str | None = None + country_name: str | None = None + asn: str | None = None + org: str | None = None + + if geo_map: + geo = geo_map.get(ip) + if geo is not None: + country_code = geo.country_code + country_name = geo.country_name + asn = geo.asn + org = geo.org + elif geo_enricher is not None: + try: + geo = await geo_enricher(ip) + if geo is not None: + country_code = geo.country_code + country_name = geo.country_name + asn = geo.asn + org = geo.org + except Exception: # noqa: BLE001 + log.warning("ban_service_geo_lookup_failed", ip=ip) + + items.append( + DashboardBanItem( + ip=ip, + jail=jail, + banned_at=banned_at, + service=service, + country_code=country_code, + country_name=country_name, + asn=asn, + org=org, + ban_count=ban_count, + origin=_derive_origin(jail), + ) + ) + + return DashboardBanListResponse( + items=items, + total=total, + page=page, + page_size=effective_page_size, + ) + + +# --------------------------------------------------------------------------- +# bans_by_country +# --------------------------------------------------------------------------- + +#: Maximum rows returned in the companion table alongside the map. +_MAX_COMPANION_BANS: int = 200 + + +async def bans_by_country( + socket_path: str, + range_: TimeRange, + http_session: aiohttp.ClientSession | None = None, + geo_enricher: Any | None = None, + app_db: aiosqlite.Connection | None = None, + origin: BanOrigin | None = None, +) -> BansByCountryResponse: + """Aggregate ban counts per country for the selected time window. + + Uses a two-step strategy optimised for large datasets: + + 1. Queries the fail2ban DB with ``GROUP BY ip`` to get the per-IP ban + counts for all unique IPs in the window — no row-count cap. + 2. Serves geo data from the in-memory cache only (non-blocking). + Any IPs not yet in the cache are scheduled for background resolution + via :func:`asyncio.create_task` so the response is returned immediately + and subsequent requests benefit from the warmed cache. + 3. Returns a ``{country_code: count}`` aggregation and the 200 most + recent raw rows for the companion table. + + Note: + On the very first request a large number of IPs may be uncached and + the country map will be sparse. The background task will resolve them + and the next request will return a complete map. This trade-off keeps + the endpoint fast regardless of dataset size. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset. + http_session: Optional :class:`aiohttp.ClientSession` for background + geo lookups. When ``None``, only cached data is used. + geo_enricher: Legacy async ``(ip) -> GeoInfo | None`` callable; + used when *http_session* is ``None`` (e.g. tests). + app_db: Optional BanGUI application database used to persist newly + resolved geo entries across restarts. + origin: Optional origin filter — ``"blocklist"`` restricts results to + the ``blocklist-import`` jail, ``"selfblock"`` excludes it. + + Returns: + :class:`~app.models.ban.BansByCountryResponse` with per-country + aggregation and the companion ban list. + """ + from app.services import geo_service # noqa: PLC0415 + + since: int = _since_unix(range_) + origin_clause, origin_params = _origin_sql_filter(origin) + db_path: str = await _get_fail2ban_db_path(socket_path) + log.info( + "ban_service_bans_by_country", + db_path=db_path, + since=since, + range=range_, + origin=origin, + ) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + + # Total count for the window. + async with f2b_db.execute( + "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, + (since, *origin_params), + ) as cur: + count_row = await cur.fetchone() + total: int = int(count_row[0]) if count_row else 0 + + # Aggregation: unique IPs + their total event count. + # No LIMIT here — we need all unique source IPs for accurate country counts. + async with f2b_db.execute( + "SELECT ip, COUNT(*) AS event_count " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " GROUP BY ip", + (since, *origin_params), + ) as cur: + agg_rows = await cur.fetchall() + + # Companion table: most recent raw rows for display alongside the map. + async with f2b_db.execute( + "SELECT jail, ip, timeofban, bancount, data " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " ORDER BY timeofban DESC " + "LIMIT ?", + (since, *origin_params, _MAX_COMPANION_BANS), + ) as cur: + companion_rows = await cur.fetchall() + + unique_ips: list[str] = [str(r["ip"]) for r in agg_rows] + geo_map: dict[str, Any] = {} + + if http_session is not None and unique_ips: + # Serve only what is already in the in-memory cache — no API calls on + # the hot path. Uncached IPs are resolved asynchronously in the + # background so subsequent requests benefit from a warmer cache. + geo_map, uncached = geo_service.lookup_cached_only(unique_ips) + if uncached: + log.info( + "ban_service_geo_background_scheduled", + uncached=len(uncached), + cached=len(geo_map), + ) + # Fire-and-forget: lookup_batch handles rate-limiting / retries. + # The dirty-set flush task persists results to the DB. + asyncio.create_task( # noqa: RUF006 + geo_service.lookup_batch(uncached, http_session, db=app_db), + name="geo_bans_by_country", + ) + elif geo_enricher is not None and unique_ips: + # Fallback: legacy per-IP enricher (used in tests / older callers). + async def _safe_lookup(ip: str) -> tuple[str, Any]: + try: + return ip, await geo_enricher(ip) + except Exception: # noqa: BLE001 + log.warning("ban_service_geo_lookup_failed", ip=ip) + return ip, None + + results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips)) + geo_map = dict(results) + + # Build country aggregation from the SQL-grouped rows. + countries: dict[str, int] = {} + country_names: dict[str, str] = {} + + for row in agg_rows: + ip: str = str(row["ip"]) + geo = geo_map.get(ip) + cc: str | None = geo.country_code if geo else None + cn: str | None = geo.country_name if geo else None + event_count: int = int(row["event_count"]) + + if cc: + countries[cc] = countries.get(cc, 0) + event_count + if cn and cc not in country_names: + country_names[cc] = cn + + # Build companion table from recent rows (geo already cached from batch step). + bans: list[DashboardBanItem] = [] + for row in companion_rows: + ip = str(row["ip"]) + geo = geo_map.get(ip) + cc = geo.country_code if geo else None + cn = geo.country_name if geo else None + asn: str | None = geo.asn if geo else None + org: str | None = geo.org if geo else None + matches, _ = _parse_data_json(row["data"]) + + bans.append( + DashboardBanItem( + ip=ip, + jail=str(row["jail"]), + banned_at=_ts_to_iso(int(row["timeofban"])), + service=matches[0] if matches else None, + country_code=cc, + country_name=cn, + asn=asn, + org=org, + ban_count=int(row["bancount"]), + origin=_derive_origin(str(row["jail"])), + ) + ) + + return BansByCountryResponse( + countries=countries, + country_names=country_names, + bans=bans, + total=total, + ) + + +# --------------------------------------------------------------------------- +# ban_trend +# --------------------------------------------------------------------------- + + +async def ban_trend( + socket_path: str, + range_: TimeRange, + *, + origin: BanOrigin | None = None, +) -> BanTrendResponse: + """Return ban counts aggregated into equal-width time buckets. + + Queries the fail2ban database ``bans`` table and groups records by a + computed bucket index so the frontend can render a continuous time-series + chart. All buckets within the requested window are returned — buckets + that contain zero bans are included as zero-count entries so the + frontend always receives a complete, gap-free series. + + Bucket sizes per time-range preset: + + * ``24h`` → 1-hour buckets (24 total) + * ``7d`` → 6-hour buckets (28 total) + * ``30d`` → 1-day buckets (30 total) + * ``365d`` → 7-day buckets (~53 total) + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``). + origin: Optional origin filter — ``"blocklist"`` restricts to the + ``blocklist-import`` jail, ``"selfblock"`` excludes it. + + Returns: + :class:`~app.models.ban.BanTrendResponse` with a full bucket list + and the human-readable bucket-size label. + """ + since: int = _since_unix(range_) + bucket_secs: int = BUCKET_SECONDS[range_] + num_buckets: int = bucket_count(range_) + origin_clause, origin_params = _origin_sql_filter(origin) + + db_path: str = await _get_fail2ban_db_path(socket_path) + log.info( + "ban_service_ban_trend", + db_path=db_path, + since=since, + range=range_, + origin=origin, + bucket_secs=bucket_secs, + num_buckets=num_buckets, + ) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + + async with f2b_db.execute( + "SELECT CAST((timeofban - ?) / ? AS INTEGER) AS bucket_idx, " + "COUNT(*) AS cnt " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " GROUP BY bucket_idx " + "ORDER BY bucket_idx", + (since, bucket_secs, since, *origin_params), + ) as cur: + rows = await cur.fetchall() + + # Map bucket_idx → count; ignore any out-of-range indices. + counts: dict[int, int] = {} + for row in rows: + idx: int = int(row["bucket_idx"]) + if 0 <= idx < num_buckets: + counts[idx] = int(row["cnt"]) + + buckets: list[BanTrendBucket] = [ + BanTrendBucket( + timestamp=_ts_to_iso(since + i * bucket_secs), + count=counts.get(i, 0), + ) + for i in range(num_buckets) + ] + + return BanTrendResponse( + buckets=buckets, + bucket_size=BUCKET_SIZE_LABEL[range_], + ) + + +# --------------------------------------------------------------------------- +# bans_by_jail +# --------------------------------------------------------------------------- + + +async def bans_by_jail( + socket_path: str, + range_: TimeRange, + *, + origin: BanOrigin | None = None, +) -> BansByJailResponse: + """Return ban counts aggregated per jail for the selected time window. + + Queries the fail2ban database ``bans`` table, groups records by jail + name, and returns them ordered by count descending. The origin filter + is applied when provided so callers can restrict results to blocklist- + imported bans or organic fail2ban bans. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or + ``"365d"``). + origin: Optional origin filter — ``"blocklist"`` restricts to the + ``blocklist-import`` jail, ``"selfblock"`` excludes it. + + Returns: + :class:`~app.models.ban.BansByJailResponse` with per-jail counts + sorted descending and the total ban count. + """ + since: int = _since_unix(range_) + origin_clause, origin_params = _origin_sql_filter(origin) + + db_path: str = await _get_fail2ban_db_path(socket_path) + log.debug( + "ban_service_bans_by_jail", + db_path=db_path, + since=since, + since_iso=_ts_to_iso(since), + range=range_, + origin=origin, + ) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + + async with f2b_db.execute( + "SELECT COUNT(*) FROM bans WHERE timeofban >= ?" + origin_clause, + (since, *origin_params), + ) as cur: + count_row = await cur.fetchone() + total: int = int(count_row[0]) if count_row else 0 + + # Diagnostic guard: if zero results were returned, check whether the + # table has *any* rows and log a warning with min/max timeofban so + # operators can diagnose timezone or filter mismatches from logs. + if total == 0: + async with f2b_db.execute( + "SELECT COUNT(*), MIN(timeofban), MAX(timeofban) FROM bans" + ) as cur: + diag_row = await cur.fetchone() + if diag_row and diag_row[0] > 0: + log.warning( + "ban_service_bans_by_jail_empty_despite_data", + table_row_count=diag_row[0], + min_timeofban=diag_row[1], + max_timeofban=diag_row[2], + since=since, + range=range_, + ) + + async with f2b_db.execute( + "SELECT jail, COUNT(*) AS cnt " + "FROM bans " + "WHERE timeofban >= ?" + + origin_clause + + " GROUP BY jail ORDER BY cnt DESC", + (since, *origin_params), + ) as cur: + rows = await cur.fetchall() + + jails: list[JailBanCount] = [ + JailBanCount(jail=str(row["jail"]), count=int(row["cnt"])) for row in rows + ] + log.debug( + "ban_service_bans_by_jail_result", + total=total, + jail_count=len(jails), + ) + return BansByJailResponse(jails=jails, total=total) diff --git a/backend/app/services/blocklist_service.py b/backend/app/services/blocklist_service.py new file mode 100644 index 0000000..5719a45 --- /dev/null +++ b/backend/app/services/blocklist_service.py @@ -0,0 +1,548 @@ +"""Blocklist service. + +Manages blocklist source CRUD, URL preview, IP import (download → validate → +ban via fail2ban), and schedule persistence. + +All ban operations target a dedicated fail2ban jail (default: +``"blocklist-import"``) so blocklist-origin bans are tracked separately from +regular bans. If that jail does not exist or fail2ban is unreachable, the +error is recorded in the import log and processing continues. + +Schedule configuration is stored as JSON in the application settings table +under the key ``"blocklist_schedule"``. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import structlog + +from app.models.blocklist import ( + BlocklistSource, + ImportRunResult, + ImportSourceResult, + PreviewResponse, + ScheduleConfig, + ScheduleInfo, +) +from app.repositories import blocklist_repo, import_log_repo, settings_repo +from app.utils.ip_utils import is_valid_ip, is_valid_network + +if TYPE_CHECKING: + import aiohttp + import aiosqlite + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +#: Settings key used to persist the schedule config. +_SCHEDULE_SETTINGS_KEY: str = "blocklist_schedule" + +#: fail2ban jail name for blocklist-origin bans. +BLOCKLIST_JAIL: str = "blocklist-import" + +#: Maximum number of sample entries returned by the preview endpoint. +_PREVIEW_LINES: int = 20 + +#: Maximum bytes to download for a preview (first 64 KB). +_PREVIEW_MAX_BYTES: int = 65536 + + +# --------------------------------------------------------------------------- +# Source CRUD helpers +# --------------------------------------------------------------------------- + + +def _row_to_source(row: dict[str, Any]) -> BlocklistSource: + """Convert a repository row dict to a :class:`BlocklistSource`. + + Args: + row: Dict with keys matching the ``blocklist_sources`` columns. + + Returns: + A validated :class:`~app.models.blocklist.BlocklistSource` instance. + """ + return BlocklistSource.model_validate(row) + + +async def list_sources(db: aiosqlite.Connection) -> list[BlocklistSource]: + """Return all configured blocklist sources. + + Args: + db: Active application database connection. + + Returns: + List of :class:`~app.models.blocklist.BlocklistSource` instances. + """ + rows = await blocklist_repo.list_sources(db) + return [_row_to_source(r) for r in rows] + + +async def get_source( + db: aiosqlite.Connection, + source_id: int, +) -> BlocklistSource | None: + """Return a single blocklist source, or ``None`` if not found. + + Args: + db: Active application database connection. + source_id: Primary key of the desired source. + + Returns: + :class:`~app.models.blocklist.BlocklistSource` or ``None``. + """ + row = await blocklist_repo.get_source(db, source_id) + return _row_to_source(row) if row is not None else None + + +async def create_source( + db: aiosqlite.Connection, + name: str, + url: str, + *, + enabled: bool = True, +) -> BlocklistSource: + """Create a new blocklist source and return the persisted record. + + Args: + db: Active application database connection. + name: Human-readable display name. + url: URL of the blocklist text file. + enabled: Whether the source is active. Defaults to ``True``. + + Returns: + The newly created :class:`~app.models.blocklist.BlocklistSource`. + """ + new_id = await blocklist_repo.create_source(db, name, url, enabled=enabled) + source = await get_source(db, new_id) + assert source is not None # noqa: S101 + log.info("blocklist_source_created", id=new_id, name=name, url=url) + return source + + +async def update_source( + db: aiosqlite.Connection, + source_id: int, + *, + name: str | None = None, + url: str | None = None, + enabled: bool | None = None, +) -> BlocklistSource | None: + """Update fields on a blocklist source. + + Args: + db: Active application database connection. + source_id: Primary key of the source to modify. + name: New display name, or ``None`` to leave unchanged. + url: New URL, or ``None`` to leave unchanged. + enabled: New enabled state, or ``None`` to leave unchanged. + + Returns: + Updated :class:`~app.models.blocklist.BlocklistSource`, or ``None`` + if the source does not exist. + """ + updated = await blocklist_repo.update_source( + db, source_id, name=name, url=url, enabled=enabled + ) + if not updated: + return None + source = await get_source(db, source_id) + log.info("blocklist_source_updated", id=source_id) + return source + + +async def delete_source(db: aiosqlite.Connection, source_id: int) -> bool: + """Delete a blocklist source. + + Args: + db: Active application database connection. + source_id: Primary key of the source to delete. + + Returns: + ``True`` if the source was found and deleted, ``False`` otherwise. + """ + deleted = await blocklist_repo.delete_source(db, source_id) + if deleted: + log.info("blocklist_source_deleted", id=source_id) + return deleted + + +# --------------------------------------------------------------------------- +# Preview +# --------------------------------------------------------------------------- + + +async def preview_source( + url: str, + http_session: aiohttp.ClientSession, + *, + sample_lines: int = _PREVIEW_LINES, +) -> PreviewResponse: + """Download the beginning of a blocklist URL and return a preview. + + Args: + url: URL to download. + http_session: Shared :class:`aiohttp.ClientSession`. + sample_lines: Maximum number of lines to include in the preview. + + Returns: + :class:`~app.models.blocklist.PreviewResponse` with a sample of + valid IP entries and validation statistics. + + Raises: + ValueError: If the URL cannot be reached or returns a non-200 status. + """ + try: + async with http_session.get(url, timeout=_aiohttp_timeout(10)) as resp: + if resp.status != 200: + raise ValueError(f"HTTP {resp.status} from {url}") + raw = await resp.content.read(_PREVIEW_MAX_BYTES) + except Exception as exc: + log.warning("blocklist_preview_failed", url=url, error=str(exc)) + raise ValueError(str(exc)) from exc + + lines = raw.decode(errors="replace").splitlines() + entries: list[str] = [] + valid = 0 + skipped = 0 + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if is_valid_ip(stripped) or is_valid_network(stripped): + valid += 1 + if len(entries) < sample_lines: + entries.append(stripped) + else: + skipped += 1 + + return PreviewResponse( + entries=entries, + total_lines=len(lines), + valid_count=valid, + skipped_count=skipped, + ) + + +# --------------------------------------------------------------------------- +# Import +# --------------------------------------------------------------------------- + + +async def import_source( + source: BlocklistSource, + http_session: aiohttp.ClientSession, + socket_path: str, + db: aiosqlite.Connection, +) -> ImportSourceResult: + """Download and apply bans from a single blocklist source. + + The function downloads the URL, validates each line as an IP address, + and bans valid IPv4/IPv6 addresses via fail2ban in + :data:`BLOCKLIST_JAIL`. CIDR ranges are counted as skipped since + fail2ban requires individual addresses. Any error encountered during + download is recorded and the result is returned without raising. + + After a successful import the geo cache is pre-warmed by batch-resolving + all newly banned IPs. This ensures the dashboard and map show country + data immediately after import rather than facing cold-cache lookups. + + Args: + source: The :class:`~app.models.blocklist.BlocklistSource` to import. + http_session: Shared :class:`aiohttp.ClientSession`. + socket_path: Path to the fail2ban Unix socket. + db: Application database for logging. + + Returns: + :class:`~app.models.blocklist.ImportSourceResult` with counters. + """ + # --- Download --- + try: + async with http_session.get( + source.url, timeout=_aiohttp_timeout(30) + ) as resp: + if resp.status != 200: + error_msg = f"HTTP {resp.status}" + await _log_result(db, source, 0, 0, error_msg) + log.warning("blocklist_import_download_failed", url=source.url, status=resp.status) + return ImportSourceResult( + source_id=source.id, + source_url=source.url, + ips_imported=0, + ips_skipped=0, + error=error_msg, + ) + content = await resp.text(errors="replace") + except Exception as exc: + error_msg = str(exc) + await _log_result(db, source, 0, 0, error_msg) + log.warning("blocklist_import_download_error", url=source.url, error=error_msg) + return ImportSourceResult( + source_id=source.id, + source_url=source.url, + ips_imported=0, + ips_skipped=0, + error=error_msg, + ) + + # --- Validate and ban --- + imported = 0 + skipped = 0 + ban_error: str | None = None + imported_ips: list[str] = [] + + # Import jail_service here to avoid circular import at module level. + from app.services import jail_service # noqa: PLC0415 + + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + if not is_valid_ip(stripped): + # Skip CIDRs and malformed entries gracefully. + skipped += 1 + continue + + try: + await jail_service.ban_ip(socket_path, BLOCKLIST_JAIL, stripped) + imported += 1 + imported_ips.append(stripped) + except jail_service.JailNotFoundError as exc: + # The target jail does not exist in fail2ban — there is no point + # continuing because every subsequent ban would also fail. + ban_error = str(exc) + log.warning( + "blocklist_jail_not_found", + jail=BLOCKLIST_JAIL, + error=str(exc), + ) + break + except Exception as exc: + skipped += 1 + if ban_error is None: + ban_error = str(exc) + log.debug("blocklist_ban_failed", ip=stripped, error=str(exc)) + + await _log_result(db, source, imported, skipped, ban_error) + log.info( + "blocklist_source_imported", + source_id=source.id, + url=source.url, + imported=imported, + skipped=skipped, + error=ban_error, + ) + + # --- Pre-warm geo cache for newly imported IPs --- + if imported_ips: + from app.services import geo_service # noqa: PLC0415 + + uncached_ips: list[str] = [ + ip for ip in imported_ips if not geo_service.is_cached(ip) + ] + skipped_geo: int = len(imported_ips) - len(uncached_ips) + + if skipped_geo > 0: + log.info( + "blocklist_geo_prewarm_cache_hit", + source_id=source.id, + skipped=skipped_geo, + to_lookup=len(uncached_ips), + ) + + if uncached_ips: + try: + await geo_service.lookup_batch(uncached_ips, http_session, db=db) + log.info( + "blocklist_geo_prewarm_complete", + source_id=source.id, + count=len(uncached_ips), + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "blocklist_geo_prewarm_failed", + source_id=source.id, + error=str(exc), + ) + + return ImportSourceResult( + source_id=source.id, + source_url=source.url, + ips_imported=imported, + ips_skipped=skipped, + error=ban_error, + ) + + +async def import_all( + db: aiosqlite.Connection, + http_session: aiohttp.ClientSession, + socket_path: str, +) -> ImportRunResult: + """Import all enabled blocklist sources. + + Iterates over every source with ``enabled = True``, calls + :func:`import_source` for each, and aggregates the results. + + Args: + db: Application database connection. + http_session: Shared :class:`aiohttp.ClientSession`. + socket_path: fail2ban socket path. + + Returns: + :class:`~app.models.blocklist.ImportRunResult` with aggregated + counters and per-source results. + """ + sources = await blocklist_repo.list_enabled_sources(db) + results: list[ImportSourceResult] = [] + total_imported = 0 + total_skipped = 0 + errors_count = 0 + + for row in sources: + source = _row_to_source(row) + result = await import_source(source, http_session, socket_path, db) + results.append(result) + total_imported += result.ips_imported + total_skipped += result.ips_skipped + if result.error is not None: + errors_count += 1 + + log.info( + "blocklist_import_all_complete", + sources=len(sources), + total_imported=total_imported, + total_skipped=total_skipped, + errors=errors_count, + ) + return ImportRunResult( + results=results, + total_imported=total_imported, + total_skipped=total_skipped, + errors_count=errors_count, + ) + + +# --------------------------------------------------------------------------- +# Schedule +# --------------------------------------------------------------------------- + +_DEFAULT_SCHEDULE = ScheduleConfig() + + +async def get_schedule(db: aiosqlite.Connection) -> ScheduleConfig: + """Read the import schedule config from the settings table. + + Returns the default config (daily at 03:00 UTC) if no schedule has been + saved yet. + + Args: + db: Active application database connection. + + Returns: + The stored (or default) :class:`~app.models.blocklist.ScheduleConfig`. + """ + raw = await settings_repo.get_setting(db, _SCHEDULE_SETTINGS_KEY) + if raw is None: + return _DEFAULT_SCHEDULE + try: + data = json.loads(raw) + return ScheduleConfig.model_validate(data) + except Exception: + log.warning("blocklist_schedule_invalid", raw=raw) + return _DEFAULT_SCHEDULE + + +async def set_schedule( + db: aiosqlite.Connection, + config: ScheduleConfig, +) -> ScheduleConfig: + """Persist a new schedule configuration. + + Args: + db: Active application database connection. + config: The :class:`~app.models.blocklist.ScheduleConfig` to store. + + Returns: + The saved configuration (same object after validation). + """ + await settings_repo.set_setting( + db, _SCHEDULE_SETTINGS_KEY, config.model_dump_json() + ) + log.info("blocklist_schedule_updated", frequency=config.frequency, hour=config.hour) + return config + + +async def get_schedule_info( + db: aiosqlite.Connection, + next_run_at: str | None, +) -> ScheduleInfo: + """Return the schedule config together with last-run metadata. + + Args: + db: Active application database connection. + next_run_at: ISO 8601 string of the next scheduled run, or ``None`` + if not yet scheduled (provided by the caller from APScheduler). + + Returns: + :class:`~app.models.blocklist.ScheduleInfo` combining config and + runtime metadata. + """ + config = await get_schedule(db) + last_log = await import_log_repo.get_last_log(db) + last_run_at = last_log["timestamp"] if last_log else None + last_run_errors: bool | None = (last_log["errors"] is not None) if last_log else None + return ScheduleInfo( + config=config, + next_run_at=next_run_at, + last_run_at=last_run_at, + last_run_errors=last_run_errors, + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _aiohttp_timeout(seconds: float) -> Any: + """Return an :class:`aiohttp.ClientTimeout` with the given total timeout. + + Args: + seconds: Total timeout in seconds. + + Returns: + An :class:`aiohttp.ClientTimeout` instance. + """ + import aiohttp # noqa: PLC0415 + + return aiohttp.ClientTimeout(total=seconds) + + +async def _log_result( + db: aiosqlite.Connection, + source: BlocklistSource, + ips_imported: int, + ips_skipped: int, + error: str | None, +) -> None: + """Write an import log entry for a completed source import. + + Args: + db: Application database connection. + source: The source that was imported. + ips_imported: Count of successfully banned IPs. + ips_skipped: Count of skipped/invalid entries. + error: Error string, or ``None`` on success. + """ + await import_log_repo.add_log( + db, + source_id=source.id, + source_url=source.url, + ips_imported=ips_imported, + ips_skipped=ips_skipped, + errors=error, + ) diff --git a/backend/app/services/conffile_parser.py b/backend/app/services/conffile_parser.py new file mode 100644 index 0000000..2dbc9eb --- /dev/null +++ b/backend/app/services/conffile_parser.py @@ -0,0 +1,695 @@ +"""Fail2ban INI-style configuration file parser and serializer. + +Provides structured parsing and serialization for ``filter.d/*.conf`` and +``action.d/*.conf`` files, mirroring fail2ban's own ``RawConfigParser``-based +reading logic. + +Key design decisions: +- Uses :class:`configparser.RawConfigParser` with ``interpolation=None`` so + fail2ban-style ``%`` / ``<>`` tags are preserved verbatim. +- Multi-line values (lines that begin with whitespace) are handled by + configparser automatically; the raw string is then post-processed to split + ``failregex``/``ignoreregex`` into individual patterns. +- Section ordering in serialized output: ``[INCLUDES]`` → ``[DEFAULT]`` → + ``[Definition]`` → ``[Init]``. Unknown extra sections from action files + (e.g. ``[ipt_oneport]``) are intentionally discarded because the structured + model does not capture them — users should edit those sections via the raw + (Export) tab. +""" + +from __future__ import annotations + +import configparser +import contextlib +import io +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from pathlib import Path + +from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + FilterConfig, + FilterConfigUpdate, + JailFileConfig, + JailFileConfigUpdate, + JailSectionConfig, +) + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants — well-known Definition keys for action files +# --------------------------------------------------------------------------- + +_ACTION_LIFECYCLE_KEYS: frozenset[str] = frozenset( + { + "actionstart", + "actionstop", + "actioncheck", + "actionban", + "actionunban", + "actionflush", + } +) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _make_parser() -> configparser.RawConfigParser: + """Create a :class:`configparser.RawConfigParser` configured for fail2ban. + + Returns: + A parser with interpolation disabled, case-sensitive keys, and a + ``DEFAULT`` section that does not inherit into other sections. + """ + parser = configparser.RawConfigParser( + # Disable interpolation so fail2ban % / <> tags survive unchanged. + interpolation=None, + # Preserve original key casing (fail2ban keys are lowercase but some + # custom config files may use mixed case). + strict=False, + ) + # Keys are case-sensitive in fail2ban. + parser.optionxform = str # type: ignore[assignment] + return parser + + +def _split_multiline_patterns(raw: str) -> list[str]: + """Split a raw multi-line configparser value into individual patterns. + + Each non-blank, non-comment line becomes a separate entry. + + Args: + raw: The raw multi-line string from configparser (may include blank + lines and ``#`` comments). + + Returns: + List of stripped non-empty, non-comment pattern strings. + """ + result: list[str] = [] + for line in raw.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + result.append(stripped) + return result + + +def _get_opt(parser: configparser.RawConfigParser, section: str, key: str) -> str | None: + """Return the value of *key* in *section*, or ``None`` if absent. + + Args: + parser: Populated parser instance. + section: Section name. + key: Option name. + + Returns: + Option value string, or ``None``. + """ + if parser.has_section(section) and parser.has_option(section, key): + return parser.get(section, key) + return None + + +def _section_dict( + parser: configparser.RawConfigParser, section: str, skip: frozenset[str] | None = None +) -> dict[str, str]: + """Return all key-value pairs from *section* as a plain dict. + + Args: + parser: Populated parser instance. + section: Section name. + skip: Optional set of keys to exclude. + + Returns: + Dict of option → value for the section. + """ + if not parser.has_section(section): + return {} + drop = skip or frozenset() + return { + k: v + for k, v in parser.items(section) + if not k.startswith("__") and k not in drop # __ keys come from DEFAULT inheritance + } + + +# --------------------------------------------------------------------------- +# Filter file parser / serializer +# --------------------------------------------------------------------------- + + +def parse_filter_file(content: str, name: str = "", filename: str = "") -> FilterConfig: + """Parse a ``filter.d/*.conf`` file into a :class:`~app.models.config.FilterConfig`. + + Args: + content: Raw file content (UTF-8 string). + name: Filter base name (e.g. ``"sshd"``). Used only to populate the + ``name`` field on the returned model. + filename: Actual filename (e.g. ``"sshd.conf"``). + + Returns: + Populated :class:`~app.models.config.FilterConfig`. + """ + parser = _make_parser() + try: + parser.read_string(content) + except configparser.Error as exc: + log.warning("filter_parse_error", name=name, error=str(exc)) + + # [INCLUDES] + before = _get_opt(parser, "INCLUDES", "before") + after = _get_opt(parser, "INCLUDES", "after") + + # [DEFAULT] — all keys that aren't hidden configparser internals + # configparser stores DEFAULT keys accessible from every section; we + # reconstruct them by reading DEFAULT directly. + variables: dict[str, str] = {} + if parser.defaults(): + variables = dict(parser.defaults()) + + # [Definition] + prefregex = _get_opt(parser, "Definition", "prefregex") + + raw_failregex = _get_opt(parser, "Definition", "failregex") or "" + failregex = _split_multiline_patterns(raw_failregex) + + raw_ignoreregex = _get_opt(parser, "Definition", "ignoreregex") or "" + ignoreregex = _split_multiline_patterns(raw_ignoreregex) + + maxlines_raw = _get_opt(parser, "Definition", "maxlines") + maxlines: int | None = None + if maxlines_raw is not None: + with contextlib.suppress(ValueError): + maxlines = int(maxlines_raw.strip()) + + datepattern = _get_opt(parser, "Definition", "datepattern") + journalmatch = _get_opt(parser, "Definition", "journalmatch") + + log.debug("filter_parsed", name=name, failregex_count=len(failregex)) + return FilterConfig( + name=name, + filename=filename, + before=before, + after=after, + variables=variables, + prefregex=prefregex, + failregex=failregex, + ignoreregex=ignoreregex, + maxlines=maxlines, + datepattern=datepattern, + journalmatch=journalmatch, + ) + + +def serialize_filter_config(cfg: FilterConfig) -> str: + """Serialize a :class:`~app.models.config.FilterConfig` to a ``.conf`` string. + + The output preserves the canonical fail2ban INI section ordering: + ``[INCLUDES]`` → ``[DEFAULT]`` → ``[Definition]``. + + Args: + cfg: The filter configuration to serialize. + + Returns: + UTF-8 string suitable for writing to a ``.conf`` file. + """ + buf = io.StringIO() + + # [INCLUDES] + if cfg.before is not None or cfg.after is not None: + buf.write("[INCLUDES]\n\n") + if cfg.before is not None: + buf.write(f"before = {cfg.before}\n") + if cfg.after is not None: + buf.write(f"after = {cfg.after}\n") + buf.write("\n") + + # [DEFAULT] + if cfg.variables: + buf.write("[DEFAULT]\n\n") + for key, value in cfg.variables.items(): + buf.write(f"{key} = {value}\n") + buf.write("\n") + + # [Definition] + buf.write("[Definition]\n\n") + + if cfg.prefregex is not None: + buf.write(f"prefregex = {cfg.prefregex}\n\n") + + if cfg.failregex: + buf.write("failregex = " + cfg.failregex[0] + "\n") + for pattern in cfg.failregex[1:]: + buf.write(f" {pattern}\n") + buf.write("\n") + + if cfg.ignoreregex: + buf.write("ignoreregex = " + cfg.ignoreregex[0] + "\n") + for pattern in cfg.ignoreregex[1:]: + buf.write(f" {pattern}\n") + buf.write("\n") + + if cfg.maxlines is not None: + buf.write(f"maxlines = {cfg.maxlines}\n\n") + + if cfg.datepattern is not None: + buf.write(f"datepattern = {cfg.datepattern}\n\n") + + if cfg.journalmatch is not None: + buf.write(f"journalmatch = {cfg.journalmatch}\n\n") + + return buf.getvalue() + + +def merge_filter_update(cfg: FilterConfig, update: FilterConfigUpdate) -> FilterConfig: + """Apply a partial :class:`~app.models.config.FilterConfigUpdate` onto *cfg*. + + Only fields that are explicitly set (not ``None``) in *update* are written. + Returns a new :class:`~app.models.config.FilterConfig` with the merged + values; the original is not mutated. + + Args: + cfg: Current filter configuration. + update: Partial update to apply. + + Returns: + Updated :class:`~app.models.config.FilterConfig`. + """ + return FilterConfig( + name=cfg.name, + filename=cfg.filename, + before=update.before if update.before is not None else cfg.before, + after=update.after if update.after is not None else cfg.after, + variables=update.variables if update.variables is not None else cfg.variables, + prefregex=update.prefregex if update.prefregex is not None else cfg.prefregex, + failregex=update.failregex if update.failregex is not None else cfg.failregex, + ignoreregex=update.ignoreregex if update.ignoreregex is not None else cfg.ignoreregex, + maxlines=update.maxlines if update.maxlines is not None else cfg.maxlines, + datepattern=update.datepattern if update.datepattern is not None else cfg.datepattern, + journalmatch=update.journalmatch if update.journalmatch is not None else cfg.journalmatch, + ) + + +# --------------------------------------------------------------------------- +# Action file parser / serializer +# --------------------------------------------------------------------------- + + +def parse_action_file(content: str, name: str = "", filename: str = "") -> ActionConfig: + """Parse an ``action.d/*.conf`` file into a :class:`~app.models.config.ActionConfig`. + + Args: + content: Raw file content (UTF-8 string). + name: Action base name (e.g. ``"iptables"``). + filename: Actual filename (e.g. ``"iptables.conf"``). + + Returns: + Populated :class:`~app.models.config.ActionConfig`. + """ + parser = _make_parser() + try: + parser.read_string(content) + except configparser.Error as exc: + log.warning("action_parse_error", name=name, error=str(exc)) + + # [INCLUDES] + before = _get_opt(parser, "INCLUDES", "before") + after = _get_opt(parser, "INCLUDES", "after") + + # [Definition] — extract well-known lifecycle keys, rest goes to definition_vars + def_lifecycle: dict[str, str | None] = dict.fromkeys(_ACTION_LIFECYCLE_KEYS) + definition_vars: dict[str, str] = {} + + if parser.has_section("Definition"): + for key, value in parser.items("Definition"): + if key in _ACTION_LIFECYCLE_KEYS: + def_lifecycle[key] = value + else: + definition_vars[key] = value + + # [Init] — all keys go into init_vars (multiple [Init?...] sections are ignored) + init_vars: dict[str, str] = {} + if parser.has_section("Init"): + for key, value in parser.items("Init"): + init_vars[key] = value + + log.debug("action_parsed", name=name, init_vars_count=len(init_vars)) + return ActionConfig( + name=name, + filename=filename, + before=before, + after=after, + actionstart=def_lifecycle.get("actionstart"), + actionstop=def_lifecycle.get("actionstop"), + actioncheck=def_lifecycle.get("actioncheck"), + actionban=def_lifecycle.get("actionban"), + actionunban=def_lifecycle.get("actionunban"), + actionflush=def_lifecycle.get("actionflush"), + definition_vars=definition_vars, + init_vars=init_vars, + ) + + +def serialize_action_config(cfg: ActionConfig) -> str: + """Serialize an :class:`~app.models.config.ActionConfig` to a ``.conf`` string. + + Section order: ``[INCLUDES]`` → ``[Definition]`` → ``[Init]``. + + Args: + cfg: The action configuration to serialize. + + Returns: + UTF-8 string suitable for writing to a ``.conf`` file. + """ + buf = io.StringIO() + + # [INCLUDES] + if cfg.before is not None or cfg.after is not None: + buf.write("[INCLUDES]\n\n") + if cfg.before is not None: + buf.write(f"before = {cfg.before}\n") + if cfg.after is not None: + buf.write(f"after = {cfg.after}\n") + buf.write("\n") + + # [Definition] + buf.write("[Definition]\n\n") + + # Lifecycle commands first (in canonical order) + _lifecycle_order = ( + "actionstart", + "actionstop", + "actioncheck", + "actionban", + "actionunban", + "actionflush", + ) + for key in _lifecycle_order: + value = getattr(cfg, key) + if value is not None: + lines = value.splitlines() + if lines: + buf.write(f"{key} = {lines[0]}\n") + for extra in lines[1:]: + buf.write(f" {extra}\n") + buf.write("\n") + + # Extra definition variables + for key, value in cfg.definition_vars.items(): + lines = value.splitlines() + if lines: + buf.write(f"{key} = {lines[0]}\n") + for extra in lines[1:]: + buf.write(f" {extra}\n") + if cfg.definition_vars: + buf.write("\n") + + # [Init] + if cfg.init_vars: + buf.write("[Init]\n\n") + for key, value in cfg.init_vars.items(): + buf.write(f"{key} = {value}\n") + buf.write("\n") + + return buf.getvalue() + + +def merge_action_update(cfg: ActionConfig, update: ActionConfigUpdate) -> ActionConfig: + """Apply a partial :class:`~app.models.config.ActionConfigUpdate` onto *cfg*. + + Args: + cfg: Current action configuration. + update: Partial update to apply. + + Returns: + Updated :class:`~app.models.config.ActionConfig`. + """ + return ActionConfig( + name=cfg.name, + filename=cfg.filename, + before=update.before if update.before is not None else cfg.before, + after=update.after if update.after is not None else cfg.after, + actionstart=update.actionstart if update.actionstart is not None else cfg.actionstart, + actionstop=update.actionstop if update.actionstop is not None else cfg.actionstop, + actioncheck=update.actioncheck if update.actioncheck is not None else cfg.actioncheck, + actionban=update.actionban if update.actionban is not None else cfg.actionban, + actionunban=update.actionunban if update.actionunban is not None else cfg.actionunban, + actionflush=update.actionflush if update.actionflush is not None else cfg.actionflush, + definition_vars=update.definition_vars + if update.definition_vars is not None + else cfg.definition_vars, + init_vars=update.init_vars if update.init_vars is not None else cfg.init_vars, + ) + + +# --------------------------------------------------------------------------- +# Convenience helpers for reading/writing files +# --------------------------------------------------------------------------- + + +def read_and_parse_filter(path: Path) -> FilterConfig: + """Read *path* and return a parsed :class:`~app.models.config.FilterConfig`. + + Args: + path: Absolute path to the filter file. + + Returns: + Parsed filter config. + """ + content = path.read_text(encoding="utf-8") + return parse_filter_file(content, name=path.stem, filename=path.name) + + +def read_and_parse_action(path: Path) -> ActionConfig: + """Read *path* and return a parsed :class:`~app.models.config.ActionConfig`. + + Args: + path: Absolute path to the action file. + + Returns: + Parsed action config. + """ + content = path.read_text(encoding="utf-8") + return parse_action_file(content, name=path.stem, filename=path.name) + + +# --------------------------------------------------------------------------- +# Jail file parser / serializer (Task 6.1) +# --------------------------------------------------------------------------- + +# Keys handled by named fields in JailSectionConfig. +_JAIL_NAMED_KEYS: frozenset[str] = frozenset( + { + "enabled", + "port", + "filter", + "logpath", + "maxretry", + "findtime", + "bantime", + "action", + "backend", + } +) + + +def _parse_bool(value: str) -> bool | None: + """Parse a fail2ban boolean string. + + Args: + value: Raw string value from config (e.g. "true", "false", "yes", "no", "1", "0"). + + Returns: + Boolean, or ``None`` if the value is not a recognised boolean token. + """ + lower = value.strip().lower() + if lower in {"true", "yes", "1"}: + return True + if lower in {"false", "no", "0"}: + return False + return None + + +def _parse_int(value: str) -> int | None: + """Parse an integer string, returning ``None`` on failure. + + Args: + value: Raw string value from config. + + Returns: + Integer, or ``None``. + """ + with contextlib.suppress(ValueError): + return int(value.strip()) + return None + + +def _parse_multiline_list(raw: str) -> list[str]: + """Split a multi-line configparser value into a list of non-blank lines. + + Args: + raw: Raw multi-line string from configparser. + + Returns: + List of stripped, non-empty, non-comment strings. + """ + result: list[str] = [] + for line in raw.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + result.append(stripped) + return result + + +def parse_jail_file(content: str, filename: str = "") -> JailFileConfig: + """Parse a ``jail.d/*.conf`` file into a :class:`~app.models.config.JailFileConfig`. + + Each INI section in the file maps to a jail. The ``[DEFAULT]`` section (if + present) is silently ignored — fail2ban merges it with jail sections, but + the structured model represents per-jail settings only. + + Args: + content: Raw file content (UTF-8 string). + filename: Filename (e.g. ``"sshd.conf"``). + + Returns: + Populated :class:`~app.models.config.JailFileConfig`. + """ + parser = _make_parser() + try: + parser.read_string(content) + except configparser.Error as exc: + log.warning("jail_file_parse_error", filename=filename, error=str(exc)) + + jails: dict[str, JailSectionConfig] = {} + for section in parser.sections(): + # Skip meta-sections used by fail2ban include system. + if section in {"INCLUDES", "DEFAULT"}: + continue + + items = dict(parser.items(section)) + + enabled_raw = items.get("enabled") + enabled = _parse_bool(enabled_raw) if enabled_raw is not None else None + + port = items.get("port") + filter_name = items.get("filter") + backend = items.get("backend") + + logpath_raw = items.get("logpath", "") + logpath = _parse_multiline_list(logpath_raw) + + action_raw = items.get("action", "") + action = _parse_multiline_list(action_raw) + + maxretry = _parse_int(items.get("maxretry", "")) if "maxretry" in items else None + findtime = _parse_int(items.get("findtime", "")) if "findtime" in items else None + bantime = _parse_int(items.get("bantime", "")) if "bantime" in items else None + + extra: dict[str, str] = { + k: v for k, v in items.items() if k not in _JAIL_NAMED_KEYS + } + + jails[section] = JailSectionConfig( + enabled=enabled, + port=port, + filter=filter_name, + logpath=logpath, + maxretry=maxretry, + findtime=findtime, + bantime=bantime, + action=action, + backend=backend, + extra=extra, + ) + + log.debug("jail_file_parsed", filename=filename, jail_count=len(jails)) + return JailFileConfig(filename=filename, jails=jails) + + +def serialize_jail_file_config(cfg: JailFileConfig) -> str: + """Serialize a :class:`~app.models.config.JailFileConfig` to a fail2ban INI string. + + Args: + cfg: Structured jail file configuration. + + Returns: + UTF-8 file content suitable for writing to a ``jail.d/*.conf`` file. + """ + buf = io.StringIO() + buf.write(f"# Generated by BanGUI — {cfg.filename}\n") + + for jail_name, jail in cfg.jails.items(): + buf.write(f"\n[{jail_name}]\n\n") + + if jail.enabled is not None: + buf.write(f"enabled = {'true' if jail.enabled else 'false'}\n") + if jail.port is not None: + buf.write(f"port = {jail.port}\n") + if jail.filter is not None: + buf.write(f"filter = {jail.filter}\n") + if jail.backend is not None: + buf.write(f"backend = {jail.backend}\n") + if jail.maxretry is not None: + buf.write(f"maxretry = {jail.maxretry}\n") + if jail.findtime is not None: + buf.write(f"findtime = {jail.findtime}\n") + if jail.bantime is not None: + buf.write(f"bantime = {jail.bantime}\n") + + if jail.logpath: + first, *rest = jail.logpath + buf.write(f"logpath = {first}\n") + for path in rest: + buf.write(f" {path}\n") + + if jail.action: + first_action, *rest_actions = jail.action + buf.write(f"action = {first_action}\n") + for a in rest_actions: + buf.write(f" {a}\n") + + for key, value in jail.extra.items(): + buf.write(f"{key} = {value}\n") + + return buf.getvalue() + + +def merge_jail_file_update(cfg: JailFileConfig, update: JailFileConfigUpdate) -> JailFileConfig: + """Apply a partial :class:`~app.models.config.JailFileConfigUpdate` onto *cfg*. + + Only jails present in ``update.jails`` are replaced; other jails are left + unchanged. + + Args: + cfg: Current jail file configuration. + update: Partial update to apply. + + Returns: + Updated :class:`~app.models.config.JailFileConfig`. + """ + if update.jails is None: + return cfg + merged = dict(cfg.jails) + merged.update(update.jails) + return JailFileConfig(filename=cfg.filename, jails=merged) + + +def read_and_parse_jail_file(path: Path) -> JailFileConfig: + """Read *path* and return a parsed :class:`~app.models.config.JailFileConfig`. + + Args: + path: Absolute path to the jail config file. + + Returns: + Parsed jail file config. + """ + content = path.read_text(encoding="utf-8") + return parse_jail_file(content, filename=path.name) diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py new file mode 100644 index 0000000..3360b4b --- /dev/null +++ b/backend/app/services/config_file_service.py @@ -0,0 +1,3131 @@ +"""Fail2ban jail configuration file parser and activator. + +Parses the full set of fail2ban jail configuration files +(``jail.conf``, ``jail.local``, ``jail.d/*.conf``, ``jail.d/*.local``) +to discover all defined jails — both active and inactive — and provides +functions to activate or deactivate them by writing ``.local`` override +files. + +Merge order (fail2ban convention): + 1. ``jail.conf`` + 2. ``jail.local`` + 3. ``jail.d/*.conf`` (alphabetical) + 4. ``jail.d/*.local`` (alphabetical) + +Security note: the ``activate_jail`` and ``deactivate_jail`` callers must +supply a validated jail name. This module validates the name against an +allowlist pattern before constructing any filesystem paths to prevent +directory traversal. +""" + +from __future__ import annotations + +import asyncio +import configparser +import contextlib +import io +import os +import re +import tempfile +from pathlib import Path +from typing import Any + +import structlog + +from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + ActionCreateRequest, + ActionListResponse, + ActionUpdateRequest, + ActivateJailRequest, + AssignActionRequest, + AssignFilterRequest, + BantimeEscalation, + FilterConfig, + FilterConfigUpdate, + FilterCreateRequest, + FilterListResponse, + FilterUpdateRequest, + InactiveJail, + InactiveJailListResponse, + JailActivationResponse, + JailValidationIssue, + JailValidationResult, + RollbackResponse, +) +from app.services import conffile_parser, jail_service +from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_SOCKET_TIMEOUT: float = 10.0 + +# Allowlist pattern for jail names used in path construction. +_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" +) + +# Sections that are not jail definitions. +_META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"}) + +# True-ish values for the ``enabled`` key. +_TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"}) + +# False-ish values for the ``enabled`` key. +_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"}) + + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class JailNotFoundInConfigError(Exception): + """Raised when the requested jail name is not defined in any config file.""" + + def __init__(self, name: str) -> None: + """Initialise with the jail name that was not found. + + Args: + name: The jail name that could not be located. + """ + self.name: str = name + super().__init__(f"Jail not found in config files: {name!r}") + + +class JailAlreadyActiveError(Exception): + """Raised when trying to activate a jail that is already active.""" + + def __init__(self, name: str) -> None: + """Initialise with the jail name. + + Args: + name: The jail that is already active. + """ + self.name: str = name + super().__init__(f"Jail is already active: {name!r}") + + +class JailAlreadyInactiveError(Exception): + """Raised when trying to deactivate a jail that is already inactive.""" + + def __init__(self, name: str) -> None: + """Initialise with the jail name. + + Args: + name: The jail that is already inactive. + """ + self.name: str = name + super().__init__(f"Jail is already inactive: {name!r}") + + +class JailNameError(Exception): + """Raised when a jail name contains invalid characters.""" + + +class ConfigWriteError(Exception): + """Raised when writing a ``.local`` override file fails.""" + + +class FilterNameError(Exception): + """Raised when a filter name contains invalid characters.""" + + +class FilterAlreadyExistsError(Exception): + """Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists.""" + + def __init__(self, name: str) -> None: + """Initialise with the filter name that already exists. + + Args: + name: The filter name that already exists. + """ + self.name: str = name + super().__init__(f"Filter already exists: {name!r}") + + +class FilterReadonlyError(Exception): + """Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override.""" + + def __init__(self, name: str) -> None: + """Initialise with the filter name that cannot be deleted. + + Args: + name: The filter name that is read-only (shipped ``.conf`` only). + """ + self.name: str = name + super().__init__( + f"Filter {name!r} is a shipped default (.conf only); " + "only user-created .local files can be deleted." + ) + + +class FilterInvalidRegexError(Exception): + """Raised when a regex pattern fails to compile.""" + + def __init__(self, pattern: str, error: str) -> None: + """Initialise with the invalid pattern and the compile error. + + Args: + pattern: The regex string that failed to compile. + error: The ``re.error`` message. + """ + self.pattern: str = pattern + self.error: str = error + super().__init__(f"Invalid regex {pattern!r}: {error}") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _safe_jail_name(name: str) -> str: + """Validate *name* and return it unchanged or raise :class:`JailNameError`. + + Args: + name: Proposed jail name. + + Returns: + The name unchanged if valid. + + Raises: + JailNameError: If *name* contains unsafe characters. + """ + if not _SAFE_JAIL_NAME_RE.match(name): + raise JailNameError( + f"Jail name {name!r} contains invalid characters. " + "Only alphanumeric characters, hyphens, underscores, and dots are " + "allowed; must start with an alphanumeric character." + ) + return name + + +def _safe_filter_name(name: str) -> str: + """Validate *name* and return it unchanged or raise :class:`FilterNameError`. + + Args: + name: Proposed filter name (without extension). + + Returns: + The name unchanged if valid. + + Raises: + FilterNameError: If *name* contains unsafe characters. + """ + if not _SAFE_FILTER_NAME_RE.match(name): + raise FilterNameError( + f"Filter name {name!r} contains invalid characters. " + "Only alphanumeric characters, hyphens, underscores, and dots are " + "allowed; must start with an alphanumeric character." + ) + return name + + +def _ordered_config_files(config_dir: Path) -> list[Path]: + """Return all jail config files in fail2ban merge order. + + Args: + config_dir: The fail2ban configuration root directory. + + Returns: + List of paths in ascending priority order (later entries override + earlier ones). + """ + files: list[Path] = [] + + jail_conf = config_dir / "jail.conf" + if jail_conf.is_file(): + files.append(jail_conf) + + jail_local = config_dir / "jail.local" + if jail_local.is_file(): + files.append(jail_local) + + jail_d = config_dir / "jail.d" + if jail_d.is_dir(): + files.extend(sorted(jail_d.glob("*.conf"))) + files.extend(sorted(jail_d.glob("*.local"))) + + return files + + +def _build_parser() -> configparser.RawConfigParser: + """Create a :class:`configparser.RawConfigParser` for fail2ban configs. + + Returns: + Parser with interpolation disabled and case-sensitive option names. + """ + parser = configparser.RawConfigParser(interpolation=None, strict=False) + # fail2ban keys are lowercase but preserve case to be safe. + parser.optionxform = str # type: ignore[assignment] + return parser + + +def _is_truthy(value: str) -> bool: + """Return ``True`` if *value* is a fail2ban boolean true string. + + Args: + value: Raw string from config (e.g. ``"true"``, ``"yes"``, ``"1"``). + + Returns: + ``True`` when the value represents enabled. + """ + return value.strip().lower() in _TRUE_VALUES + + +def _parse_int_safe(value: str) -> int | None: + """Parse *value* as int, returning ``None`` on failure. + + Args: + value: Raw string to parse. + + Returns: + Integer value, or ``None``. + """ + try: + return int(value.strip()) + except (ValueError, AttributeError): + return None + + +def _parse_time_to_seconds(value: str | None, default: int) -> int: + """Convert a fail2ban time string (e.g. ``1h``, ``10m``, ``3600``) to seconds. + + Supports the suffixes ``s`` (seconds), ``m`` (minutes), ``h`` (hours), + ``d`` (days), ``w`` (weeks), and plain integers (already seconds). + ``-1`` is treated as a permanent ban and returned as-is. + + Args: + value: Raw time string from config, or ``None``. + default: Value to return when ``value`` is absent or unparseable. + + Returns: + Duration in seconds, or ``-1`` for permanent, or ``default`` on failure. + """ + if not value: + return default + stripped = value.strip() + if stripped == "-1": + return -1 + multipliers: dict[str, int] = { + "w": 604800, + "d": 86400, + "h": 3600, + "m": 60, + "s": 1, + } + for suffix, factor in multipliers.items(): + if stripped.endswith(suffix) and len(stripped) > 1: + try: + return int(stripped[:-1]) * factor + except ValueError: + return default + try: + return int(stripped) + except ValueError: + return default + + +def _parse_multiline(raw: str) -> list[str]: + """Split a multi-line INI value into individual non-blank lines. + + Args: + raw: Raw multi-line string from configparser. + + Returns: + List of stripped, non-empty, non-comment strings. + """ + result: list[str] = [] + for line in raw.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + result.append(stripped) + return result + + +def _resolve_filter(raw_filter: str, jail_name: str, mode: str) -> str: + """Resolve fail2ban variable placeholders in a filter string. + + Handles the common default ``%(__name__)s[mode=%(mode)s]`` pattern that + fail2ban uses so the filter name displayed to the user is readable. + + Args: + raw_filter: Raw ``filter`` value from config (may contain ``%()s``). + jail_name: The jail's section name, used to substitute ``%(__name__)s``. + mode: The jail's ``mode`` value, used to substitute ``%(mode)s``. + + Returns: + Human-readable filter string. + """ + result = raw_filter.replace("%(__name__)s", jail_name) + result = result.replace("%(mode)s", mode) + return result + + +def _parse_jails_sync( + config_dir: Path, +) -> tuple[dict[str, dict[str, str]], dict[str, str]]: + """Synchronously parse all jail configs and return merged definitions. + + This is a CPU-bound / IO-bound sync function; callers must dispatch to + an executor for async use. + + Args: + config_dir: The fail2ban configuration root directory. + + Returns: + A two-tuple ``(jails, source_files)`` where: + + - ``jails``: ``{jail_name: {key: value}}`` – merged settings for each + jail with DEFAULT values already applied. + - ``source_files``: ``{jail_name: str(path)}`` – path of the file that + last defined each jail section (for display in the UI). + """ + parser = _build_parser() + files = _ordered_config_files(config_dir) + + # Track which file each section came from (last write wins). + source_files: dict[str, str] = {} + for path in files: + try: + single = _build_parser() + single.read(str(path), encoding="utf-8") + for section in single.sections(): + if section not in _META_SECTIONS: + source_files[section] = str(path) + except (configparser.Error, OSError) as exc: + log.warning("jail_config_read_error", path=str(path), error=str(exc)) + + # Full merged parse: configparser applies DEFAULT values to every section. + try: + parser.read([str(p) for p in files], encoding="utf-8") + except configparser.Error as exc: + log.warning("jail_config_parse_error", error=str(exc)) + + jails: dict[str, dict[str, str]] = {} + for section in parser.sections(): + if section in _META_SECTIONS: + continue + try: + # items() merges DEFAULT values automatically. + jails[section] = dict(parser.items(section)) + except configparser.Error as exc: + log.warning( + "jail_section_parse_error", section=section, error=str(exc) + ) + + log.debug("jails_parsed", count=len(jails), config_dir=str(config_dir)) + return jails, source_files + + +def _build_inactive_jail( + name: str, + settings: dict[str, str], + source_file: str, +) -> InactiveJail: + """Construct an :class:`~app.models.config.InactiveJail` from raw settings. + + Args: + name: Jail section name. + settings: Merged key→value dict (DEFAULT values already applied). + source_file: Path of the file that last defined this section. + + Returns: + Populated :class:`~app.models.config.InactiveJail`. + """ + raw_filter = settings.get("filter", "") + mode = settings.get("mode", "normal") + filter_name = _resolve_filter(raw_filter, name, mode) if raw_filter else name + + raw_action = settings.get("action", "") + actions = _parse_multiline(raw_action) if raw_action else [] + + raw_logpath = settings.get("logpath", "") + logpath = _parse_multiline(raw_logpath) if raw_logpath else [] + + enabled_raw = settings.get("enabled", "false") + enabled = _is_truthy(enabled_raw) + + maxretry_raw = settings.get("maxretry", "") + maxretry = _parse_int_safe(maxretry_raw) + + # Extended fields for full GUI display + ban_time_seconds = _parse_time_to_seconds(settings.get("bantime"), 600) + find_time_seconds = _parse_time_to_seconds(settings.get("findtime"), 600) + log_encoding = settings.get("logencoding") or "auto" + backend = settings.get("backend") or "auto" + date_pattern = settings.get("datepattern") or None + use_dns = settings.get("usedns") or "warn" + prefregex = settings.get("prefregex") or "" + fail_regex = _parse_multiline(settings.get("failregex", "")) + ignore_regex = _parse_multiline(settings.get("ignoreregex", "")) + + # Ban-time escalation + esc_increment = _is_truthy(settings.get("bantime.increment", "false")) + esc_factor_raw = settings.get("bantime.factor") + esc_factor = float(esc_factor_raw) if esc_factor_raw else None + esc_formula = settings.get("bantime.formula") or None + esc_multipliers = settings.get("bantime.multipliers") or None + esc_max_raw = settings.get("bantime.maxtime") + esc_max_time = _parse_time_to_seconds(esc_max_raw, 0) if esc_max_raw else None + esc_rnd_raw = settings.get("bantime.rndtime") + esc_rnd_time = _parse_time_to_seconds(esc_rnd_raw, 0) if esc_rnd_raw else None + esc_overall = _is_truthy(settings.get("bantime.overalljails", "false")) + bantime_escalation = ( + BantimeEscalation( + increment=esc_increment, + factor=esc_factor, + formula=esc_formula, + multipliers=esc_multipliers, + max_time=esc_max_time, + rnd_time=esc_rnd_time, + overall_jails=esc_overall, + ) + if esc_increment + else None + ) + + return InactiveJail( + name=name, + filter=filter_name, + actions=actions, + port=settings.get("port") or None, + logpath=logpath, + bantime=settings.get("bantime") or None, + findtime=settings.get("findtime") or None, + maxretry=maxretry, + ban_time_seconds=ban_time_seconds, + find_time_seconds=find_time_seconds, + log_encoding=log_encoding, + backend=backend, + date_pattern=date_pattern, + use_dns=use_dns, + prefregex=prefregex, + fail_regex=fail_regex, + ignore_regex=ignore_regex, + bantime_escalation=bantime_escalation, + source_file=source_file, + enabled=enabled, + ) + + +async def _get_active_jail_names(socket_path: str) -> set[str]: + """Fetch the set of currently running jail names from fail2ban. + + Returns an empty set gracefully if fail2ban is unreachable. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + Set of active jail names, or empty set on connection failure. + """ + try: + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + def _to_dict_inner(pairs: Any) -> dict[str, Any]: + if not isinstance(pairs, (list, tuple)): + return {} + result: dict[str, Any] = {} + for item in pairs: + try: + k, v = item + result[str(k)] = v + except (TypeError, ValueError): + pass + return result + + def _ok(response: Any) -> Any: + code, data = response + if code != 0: + raise ValueError(f"fail2ban error {code}: {data!r}") + return data + + status_raw = _ok(await client.send(["status"])) + status_dict = _to_dict_inner(status_raw) + jail_list_raw: str = str(status_dict.get("Jail list", "") or "").strip() + if not jail_list_raw: + return set() + return {j.strip() for j in jail_list_raw.split(",") if j.strip()} + except Fail2BanConnectionError: + log.warning("fail2ban_unreachable_during_inactive_list") + return set() + except Exception as exc: # noqa: BLE001 + log.warning( + "fail2ban_status_error_during_inactive_list", error=str(exc) + ) + return set() + + +# --------------------------------------------------------------------------- +# Validation helpers (Task 3) +# --------------------------------------------------------------------------- + +# Seconds to wait between fail2ban liveness probes after a reload. +_POST_RELOAD_PROBE_INTERVAL: float = 2.0 + +# Maximum number of post-reload probe attempts (initial attempt + retries). +_POST_RELOAD_MAX_ATTEMPTS: int = 4 + + +def _extract_action_base_name(action_str: str) -> str | None: + """Return the base action name from an action assignment string. + + Returns ``None`` for complex fail2ban expressions that cannot be resolved + to a single filename (e.g. ``%(action_)s`` interpolations or multi-token + composite actions). + + Args: + action_str: A single line from the jail's ``action`` setting. + + Returns: + Simple base name suitable for a filesystem lookup, or ``None``. + """ + if "%" in action_str or "$" in action_str: + return None + base = action_str.split("[")[0].strip() + if _SAFE_ACTION_NAME_RE.match(base): + return base + return None + + +def _validate_jail_config_sync( + config_dir: Path, + name: str, +) -> JailValidationResult: + """Run synchronous pre-activation checks on a jail configuration. + + Validates: + 1. Filter file existence in ``filter.d/``. + 2. Action file existence in ``action.d/`` (for resolvable action names). + 3. Regex compilation for every ``failregex`` and ``ignoreregex`` pattern. + 4. Log path existence on disk (generates warnings, not errors). + + Args: + config_dir: The fail2ban configuration root directory. + name: Validated jail name. + + Returns: + :class:`~app.models.config.JailValidationResult` with any issues found. + """ + issues: list[JailValidationIssue] = [] + + all_jails, _ = _parse_jails_sync(config_dir) + settings = all_jails.get(name) + + if settings is None: + return JailValidationResult( + jail_name=name, + valid=False, + issues=[ + JailValidationIssue( + field="name", + message=f"Jail {name!r} not found in config files.", + ) + ], + ) + + filter_d = config_dir / "filter.d" + action_d = config_dir / "action.d" + + # 1. Filter existence check. + raw_filter = settings.get("filter", "") + if raw_filter: + mode = settings.get("mode", "normal") + resolved = _resolve_filter(raw_filter, name, mode) + base_filter = _extract_filter_base_name(resolved) + if base_filter: + conf_ok = (filter_d / f"{base_filter}.conf").is_file() + local_ok = (filter_d / f"{base_filter}.local").is_file() + if not conf_ok and not local_ok: + issues.append( + JailValidationIssue( + field="filter", + message=( + f"Filter file not found: filter.d/{base_filter}.conf" + " (or .local)" + ), + ) + ) + + # 2. Action existence check. + raw_action = settings.get("action", "") + if raw_action: + for action_line in _parse_multiline(raw_action): + action_name = _extract_action_base_name(action_line) + if action_name: + conf_ok = (action_d / f"{action_name}.conf").is_file() + local_ok = (action_d / f"{action_name}.local").is_file() + if not conf_ok and not local_ok: + issues.append( + JailValidationIssue( + field="action", + message=( + f"Action file not found: action.d/{action_name}.conf" + " (or .local)" + ), + ) + ) + + # 3. failregex compilation. + for pattern in _parse_multiline(settings.get("failregex", "")): + try: + re.compile(pattern) + except re.error as exc: + issues.append( + JailValidationIssue( + field="failregex", + message=f"Invalid regex pattern: {exc}", + ) + ) + + # 4. ignoreregex compilation. + for pattern in _parse_multiline(settings.get("ignoreregex", "")): + try: + re.compile(pattern) + except re.error as exc: + issues.append( + JailValidationIssue( + field="ignoreregex", + message=f"Invalid regex pattern: {exc}", + ) + ) + + # 5. Log path existence (warning only — paths may be created at runtime). + raw_logpath = settings.get("logpath", "") + if raw_logpath: + for log_path in _parse_multiline(raw_logpath): + # Skip glob patterns and fail2ban variable references. + if "*" in log_path or "?" in log_path or "%(" in log_path: + continue + if not Path(log_path).exists(): + issues.append( + JailValidationIssue( + field="logpath", + message=f"Log file not found on disk: {log_path}", + ) + ) + + valid = len(issues) == 0 + log.debug( + "jail_validation_complete", + jail=name, + valid=valid, + issue_count=len(issues), + ) + return JailValidationResult(jail_name=name, valid=valid, issues=issues) + + +async def _probe_fail2ban_running(socket_path: str) -> bool: + """Return ``True`` if the fail2ban socket responds to a ping. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + ``True`` when fail2ban is reachable, ``False`` otherwise. + """ + try: + client = Fail2BanClient(socket_path=socket_path, timeout=5.0) + resp = await client.send(["ping"]) + return isinstance(resp, (list, tuple)) and resp[0] == 0 + except Exception: # noqa: BLE001 + return False + + +async def _wait_for_fail2ban( + socket_path: str, + max_wait_seconds: float = 10.0, + poll_interval: float = 2.0, +) -> bool: + """Poll the fail2ban socket until it responds or the timeout expires. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + max_wait_seconds: Total time budget in seconds. + poll_interval: Delay between probe attempts in seconds. + + Returns: + ``True`` if fail2ban came online within the budget. + """ + elapsed = 0.0 + while elapsed < max_wait_seconds: + if await _probe_fail2ban_running(socket_path): + return True + await asyncio.sleep(poll_interval) + elapsed += poll_interval + return False + + +async def _start_daemon(start_cmd_parts: list[str]) -> bool: + """Start the fail2ban daemon using *start_cmd_parts*. + + Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation) + to avoid command injection. + + Args: + start_cmd_parts: Command and arguments, e.g. + ``["fail2ban-client", "start"]``. + + Returns: + ``True`` when the process exited with code 0. + """ + if not start_cmd_parts: + log.warning("fail2ban_start_cmd_empty") + return False + try: + proc = await asyncio.create_subprocess_exec( + *start_cmd_parts, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(proc.wait(), timeout=30.0) + success = proc.returncode == 0 + if not success: + log.warning( + "fail2ban_start_cmd_nonzero", + cmd=start_cmd_parts, + returncode=proc.returncode, + ) + return success + except (TimeoutError, OSError) as exc: + log.warning("fail2ban_start_cmd_error", cmd=start_cmd_parts, error=str(exc)) + return False + + +def _write_local_override_sync( + config_dir: Path, + jail_name: str, + enabled: bool, + overrides: dict[str, Any], +) -> None: + """Write a ``jail.d/{name}.local`` file atomically. + + Always writes to ``jail.d/{jail_name}.local``. If the file already + exists it is replaced entirely. The write is atomic: content is + written to a temp file first, then renamed into place. + + Args: + config_dir: The fail2ban configuration root directory. + jail_name: Validated jail name (used as filename stem). + enabled: Value to write for ``enabled =``. + overrides: Optional setting overrides (bantime, findtime, maxretry, + port, logpath). + + Raises: + ConfigWriteError: If writing fails. + """ + jail_d = config_dir / "jail.d" + try: + jail_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create jail.d directory: {exc}" + ) from exc + + local_path = jail_d / f"{jail_name}.local" + + lines: list[str] = [ + "# Managed by BanGUI — do not edit manually", + "", + f"[{jail_name}]", + "", + f"enabled = {'true' if enabled else 'false'}", + # Provide explicit banaction defaults so fail2ban can resolve the + # %(banaction)s interpolation used in the built-in action_ chain. + "banaction = iptables-multiport", + "banaction_allports = iptables-allports", + ] + + if overrides.get("bantime") is not None: + lines.append(f"bantime = {overrides['bantime']}") + if overrides.get("findtime") is not None: + lines.append(f"findtime = {overrides['findtime']}") + if overrides.get("maxretry") is not None: + lines.append(f"maxretry = {overrides['maxretry']}") + if overrides.get("port") is not None: + lines.append(f"port = {overrides['port']}") + if overrides.get("logpath"): + paths: list[str] = overrides["logpath"] + if paths: + lines.append(f"logpath = {paths[0]}") + for p in paths[1:]: + lines.append(f" {p}") + + content = "\n".join(lines) + "\n" + + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=jail_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + # Clean up temp file if rename failed. + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 — only reachable when tmp_name is set + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info( + "jail_local_written", + jail=jail_name, + path=str(local_path), + enabled=enabled, + ) + + +def _restore_local_file_sync(local_path: Path, original_content: bytes | None) -> None: + """Restore a ``.local`` file to its pre-activation state. + + If *original_content* is ``None``, the file is deleted (it did not exist + before the activation). Otherwise the original bytes are written back + atomically via a temp-file rename. + + Args: + local_path: Absolute path to the ``.local`` file to restore. + original_content: Original raw bytes to write back, or ``None`` to + delete the file. + + Raises: + ConfigWriteError: If the write or delete operation fails. + """ + if original_content is None: + try: + local_path.unlink(missing_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Failed to delete {local_path} during rollback: {exc}" + ) from exc + return + + tmp_name: str | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=local_path.parent, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(original_content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + if tmp_name is not None: + os.unlink(tmp_name) + raise ConfigWriteError( + f"Failed to restore {local_path} during rollback: {exc}" + ) from exc + + +def _validate_regex_patterns(patterns: list[str]) -> None: + """Validate each pattern in *patterns* using Python's ``re`` module. + + Args: + patterns: List of regex strings to validate. + + Raises: + FilterInvalidRegexError: If any pattern fails to compile. + """ + for pattern in patterns: + try: + re.compile(pattern) + except re.error as exc: + raise FilterInvalidRegexError(pattern, str(exc)) from exc + + +def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None: + """Write *content* to ``filter.d/{name}.local`` atomically. + + The write is atomic: content is written to a temp file first, then + renamed into place. The ``filter.d/`` directory is created if absent. + + Args: + filter_d: Path to the ``filter.d`` directory. + name: Validated filter base name (used as filename stem). + content: Full serialized filter content to write. + + Raises: + ConfigWriteError: If writing fails. + """ + try: + filter_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create filter.d directory: {exc}" + ) from exc + + local_path = filter_d / f"{name}.local" + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=filter_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info("filter_local_written", filter=name, path=str(local_path)) + + +def _set_jail_local_key_sync( + config_dir: Path, + jail_name: str, + key: str, + value: str, +) -> None: + """Update ``jail.d/{jail_name}.local`` to set a single key in the jail section. + + If the ``.local`` file already exists it is read, the key is updated (or + added), and the file is written back atomically without disturbing other + settings. If the file does not exist a new one is created containing + only the BanGUI header comment, the jail section, and the requested key. + + Args: + config_dir: The fail2ban configuration root directory. + jail_name: Validated jail name (used as section name and filename stem). + key: Config key to set inside the jail section. + value: Config value to assign. + + Raises: + ConfigWriteError: If writing fails. + """ + jail_d = config_dir / "jail.d" + try: + jail_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create jail.d directory: {exc}" + ) from exc + + local_path = jail_d / f"{jail_name}.local" + + parser = _build_parser() + if local_path.is_file(): + try: + parser.read(str(local_path), encoding="utf-8") + except (configparser.Error, OSError) as exc: + log.warning( + "jail_local_read_for_update_error", + jail=jail_name, + error=str(exc), + ) + + if not parser.has_section(jail_name): + parser.add_section(jail_name) + parser.set(jail_name, key, value) + + # Serialize: write a BanGUI header then the parser output. + buf = io.StringIO() + buf.write("# Managed by BanGUI — do not edit manually\n\n") + parser.write(buf) + content = buf.getvalue() + + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=jail_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info( + "jail_local_key_set", + jail=jail_name, + key=key, + path=str(local_path), + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def list_inactive_jails( + config_dir: str, + socket_path: str, +) -> InactiveJailListResponse: + """Return all jails defined in config files that are not currently active. + + Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the + fail2ban merge order. A jail is considered inactive when: + + - Its merged ``enabled`` value is ``false`` (or absent, which defaults to + ``false`` in fail2ban), **or** + - Its ``enabled`` value is ``true`` in config but fail2ban does not report + it as running. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.InactiveJailListResponse` with all + inactive jails. + """ + loop = asyncio.get_event_loop() + parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = ( + await loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)) + ) + all_jails, source_files = parsed_result + active_names: set[str] = await _get_active_jail_names(socket_path) + + inactive: list[InactiveJail] = [] + for jail_name, settings in sorted(all_jails.items()): + if jail_name in active_names: + # fail2ban reports this jail as running — skip it. + continue + + source = source_files.get(jail_name, config_dir) + inactive.append(_build_inactive_jail(jail_name, settings, source)) + + log.info( + "inactive_jails_listed", + total_defined=len(all_jails), + active=len(active_names), + inactive=len(inactive), + ) + return InactiveJailListResponse(jails=inactive, total=len(inactive)) + + +async def activate_jail( + config_dir: str, + socket_path: str, + name: str, + req: ActivateJailRequest, +) -> JailActivationResponse: + """Enable an inactive jail and reload fail2ban. + + Performs pre-activation validation, writes ``enabled = true`` (plus any + override values from *req*) to ``jail.d/{name}.local``, and triggers a + full fail2ban reload. After the reload a multi-attempt health probe + determines whether fail2ban (and the specific jail) are still running. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Name of the jail to activate. Must exist in the parsed config. + req: Optional override values to write alongside ``enabled = true``. + + Returns: + :class:`~app.models.config.JailActivationResponse` including + ``fail2ban_running`` and ``validation_warnings`` fields. + + Raises: + JailNameError: If *name* contains invalid characters. + JailNotFoundInConfigError: If *name* is not defined in any config file. + JailAlreadyActiveError: If fail2ban already reports *name* as running. + ConfigWriteError: If writing the ``.local`` file fails. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban + socket is unreachable during reload. + """ + _safe_jail_name(name) + + loop = asyncio.get_event_loop() + all_jails, _source_files = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + + if name not in all_jails: + raise JailNotFoundInConfigError(name) + + active_names = await _get_active_jail_names(socket_path) + if name in active_names: + raise JailAlreadyActiveError(name) + + # ---------------------------------------------------------------------- # + # Pre-activation validation — collect warnings but do not block # + # ---------------------------------------------------------------------- # + validation_result: JailValidationResult = await loop.run_in_executor( + None, _validate_jail_config_sync, Path(config_dir), name + ) + warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues] + if warnings: + log.warning( + "jail_activation_validation_warnings", + jail=name, + warnings=warnings, + ) + + # Block activation on critical validation failures (missing filter or logpath). + blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")] + if blocking: + log.warning( + "jail_activation_blocked", + jail=name, + issues=[f"{i.field}: {i.message}" for i in blocking], + ) + return JailActivationResponse( + name=name, + active=False, + fail2ban_running=True, + validation_warnings=warnings, + message=( + f"Jail {name!r} cannot be activated: " + + "; ".join(i.message for i in blocking) + ), + ) + + overrides: dict[str, Any] = { + "bantime": req.bantime, + "findtime": req.findtime, + "maxretry": req.maxretry, + "port": req.port, + "logpath": req.logpath, + } + + # ---------------------------------------------------------------------- # + # Backup the existing .local file (if any) before overwriting it so that # + # we can restore it if activation fails. # + # ---------------------------------------------------------------------- # + local_path = Path(config_dir) / "jail.d" / f"{name}.local" + original_content: bytes | None = await loop.run_in_executor( + None, + lambda: local_path.read_bytes() if local_path.exists() else None, + ) + + await loop.run_in_executor( + None, + _write_local_override_sync, + Path(config_dir), + name, + True, + overrides, + ) + + # ---------------------------------------------------------------------- # + # Activation reload — if it fails, roll back immediately # + # ---------------------------------------------------------------------- # + try: + await jail_service.reload_all(socket_path, include_jails=[name]) + except Exception as exc: # noqa: BLE001 + log.warning("reload_after_activate_failed", jail=name, error=str(exc)) + recovered = await _rollback_activation_async( + config_dir, name, socket_path, original_content + ) + return JailActivationResponse( + name=name, + active=False, + fail2ban_running=False, + recovered=recovered, + validation_warnings=warnings, + message=( + f"Jail {name!r} activation failed during reload and the " + "configuration was " + + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") + ), + ) + + # ---------------------------------------------------------------------- # + # Post-reload health probe with retries # + # ---------------------------------------------------------------------- # + fail2ban_running = False + for attempt in range(_POST_RELOAD_MAX_ATTEMPTS): + if attempt > 0: + await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL) + if await _probe_fail2ban_running(socket_path): + fail2ban_running = True + break + + if not fail2ban_running: + log.warning( + "fail2ban_down_after_activate", + jail=name, + message="fail2ban socket unreachable after reload — initiating rollback.", + ) + recovered = await _rollback_activation_async( + config_dir, name, socket_path, original_content + ) + return JailActivationResponse( + name=name, + active=False, + fail2ban_running=False, + recovered=recovered, + validation_warnings=warnings, + message=( + f"Jail {name!r} activation failed: fail2ban stopped responding " + "after reload. The configuration was " + + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") + ), + ) + + # Verify the jail actually started (config error may prevent it silently). + post_reload_names = await _get_active_jail_names(socket_path) + actually_running = name in post_reload_names + if not actually_running: + log.warning( + "jail_activation_unverified", + jail=name, + message="Jail did not appear in running jails — initiating rollback.", + ) + recovered = await _rollback_activation_async( + config_dir, name, socket_path, original_content + ) + return JailActivationResponse( + name=name, + active=False, + fail2ban_running=True, + recovered=recovered, + validation_warnings=warnings, + message=( + f"Jail {name!r} was written to config but did not start after " + "reload. The configuration was " + + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") + ), + ) + + log.info("jail_activated", jail=name) + return JailActivationResponse( + name=name, + active=True, + fail2ban_running=True, + validation_warnings=warnings, + message=f"Jail {name!r} activated successfully.", + ) + + +async def _rollback_activation_async( + config_dir: str, + name: str, + socket_path: str, + original_content: bytes | None, +) -> bool: + """Restore the pre-activation ``.local`` file and reload fail2ban. + + Called internally by :func:`activate_jail` when the activation fails after + the config file was already written. Tries to: + + 1. Restore the original file content (or delete the file if it was newly + created by the activation attempt). + 2. Reload fail2ban so the daemon runs with the restored configuration. + 3. Probe fail2ban to confirm it came back up. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + name: Name of the jail whose ``.local`` file should be restored. + socket_path: Path to the fail2ban Unix domain socket. + original_content: Raw bytes of the original ``.local`` file, or + ``None`` if the file did not exist before the activation. + + Returns: + ``True`` if fail2ban is responsive again after the rollback, ``False`` + if recovery also failed. + """ + loop = asyncio.get_event_loop() + local_path = Path(config_dir) / "jail.d" / f"{name}.local" + + # Step 1 — restore original file (or delete it). + try: + await loop.run_in_executor( + None, _restore_local_file_sync, local_path, original_content + ) + log.info("jail_activation_rollback_file_restored", jail=name) + except ConfigWriteError as exc: + log.error( + "jail_activation_rollback_restore_failed", jail=name, error=str(exc) + ) + return False + + # Step 2 — reload fail2ban with the restored config. + try: + await jail_service.reload_all(socket_path) + log.info("jail_activation_rollback_reload_ok", jail=name) + except Exception as exc: # noqa: BLE001 + log.warning( + "jail_activation_rollback_reload_failed", jail=name, error=str(exc) + ) + return False + + # Step 3 — wait for fail2ban to come back. + for attempt in range(_POST_RELOAD_MAX_ATTEMPTS): + if attempt > 0: + await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL) + if await _probe_fail2ban_running(socket_path): + log.info("jail_activation_rollback_recovered", jail=name) + return True + + log.warning("jail_activation_rollback_still_down", jail=name) + return False + + +async def deactivate_jail( + config_dir: str, + socket_path: str, + name: str, +) -> JailActivationResponse: + """Disable an active jail and reload fail2ban. + + Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a + full fail2ban reload so the jail stops immediately. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Name of the jail to deactivate. Must exist in the parsed config. + + Returns: + :class:`~app.models.config.JailActivationResponse`. + + Raises: + JailNameError: If *name* contains invalid characters. + JailNotFoundInConfigError: If *name* is not defined in any config file. + JailAlreadyInactiveError: If fail2ban already reports *name* as not + running. + ConfigWriteError: If writing the ``.local`` file fails. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban + socket is unreachable during reload. + """ + _safe_jail_name(name) + + loop = asyncio.get_event_loop() + all_jails, _source_files = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + + if name not in all_jails: + raise JailNotFoundInConfigError(name) + + active_names = await _get_active_jail_names(socket_path) + if name not in active_names: + raise JailAlreadyInactiveError(name) + + await loop.run_in_executor( + None, + _write_local_override_sync, + Path(config_dir), + name, + False, + {}, + ) + + try: + await jail_service.reload_all(socket_path, exclude_jails=[name]) + except Exception as exc: # noqa: BLE001 + log.warning("reload_after_deactivate_failed", jail=name, error=str(exc)) + + log.info("jail_deactivated", jail=name) + return JailActivationResponse( + name=name, + active=False, + message=f"Jail {name!r} deactivated successfully.", + ) + + +async def validate_jail_config( + config_dir: str, + name: str, +) -> JailValidationResult: + """Run pre-activation validation checks on a jail configuration. + + Validates that referenced filter and action files exist in ``filter.d/`` + and ``action.d/``, that all regex patterns compile, and that declared log + paths exist on disk. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + name: Name of the jail to validate. + + Returns: + :class:`~app.models.config.JailValidationResult` with any issues found. + + Raises: + JailNameError: If *name* contains invalid characters. + """ + _safe_jail_name(name) + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + _validate_jail_config_sync, + Path(config_dir), + name, + ) + + +async def rollback_jail( + config_dir: str, + socket_path: str, + name: str, + start_cmd_parts: list[str], +) -> RollbackResponse: + """Disable a bad jail config and restart the fail2ban daemon. + + Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when + fail2ban is down — only a file write), then attempts to start the daemon + with *start_cmd_parts*. Waits up to 10 seconds for the socket to respond. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Name of the jail to disable. + start_cmd_parts: Argument list for the daemon start command, e.g. + ``["fail2ban-client", "start"]``. + + Returns: + :class:`~app.models.config.RollbackResponse`. + + Raises: + JailNameError: If *name* contains invalid characters. + ConfigWriteError: If writing the ``.local`` file fails. + """ + _safe_jail_name(name) + + loop = asyncio.get_event_loop() + + # Write enabled=false — this must succeed even when fail2ban is down. + await loop.run_in_executor( + None, + _write_local_override_sync, + Path(config_dir), + name, + False, + {}, + ) + log.info("jail_rolled_back_disabled", jail=name) + + # Attempt to start the daemon. + started = await _start_daemon(start_cmd_parts) + log.info("jail_rollback_start_attempted", jail=name, start_ok=started) + + # Wait for the socket to come back. + fail2ban_running = await _wait_for_fail2ban( + socket_path, max_wait_seconds=10.0, poll_interval=2.0 + ) + + active_jails = 0 + if fail2ban_running: + names = await _get_active_jail_names(socket_path) + active_jails = len(names) + + if fail2ban_running: + log.info("jail_rollback_success", jail=name, active_jails=active_jails) + return RollbackResponse( + jail_name=name, + disabled=True, + fail2ban_running=True, + active_jails=active_jails, + message=( + f"Jail {name!r} disabled and fail2ban restarted successfully " + f"with {active_jails} active jail(s)." + ), + ) + + log.warning("jail_rollback_fail2ban_still_down", jail=name) + return RollbackResponse( + jail_name=name, + disabled=True, + fail2ban_running=False, + active_jails=0, + message=( + f"Jail {name!r} was disabled but fail2ban did not come back online. " + "Check the fail2ban log for additional errors." + ), + ) + + +# --------------------------------------------------------------------------- +# Filter discovery helpers (Task 2.1) +# --------------------------------------------------------------------------- + +# Allowlist pattern for filter names used in path construction. +_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" +) + + +class FilterNotFoundError(Exception): + """Raised when the requested filter name is not found in ``filter.d/``.""" + + def __init__(self, name: str) -> None: + """Initialise with the filter name that was not found. + + Args: + name: The filter name that could not be located. + """ + self.name: str = name + super().__init__(f"Filter not found: {name!r}") + + +def _extract_filter_base_name(filter_raw: str) -> str: + """Extract the base filter name from a raw fail2ban filter string. + + fail2ban jail configs may specify a filter with an optional mode suffix, + e.g. ``sshd``, ``sshd[mode=aggressive]``, or + ``%(__name__)s[mode=%(mode)s]``. This function strips the ``[…]`` mode + block and any leading/trailing whitespace to return just the file-system + base name used to look up ``filter.d/{name}.conf``. + + Args: + filter_raw: Raw ``filter`` value from a jail config (already + with ``%(__name__)s`` substituted by the caller). + + Returns: + Base filter name, e.g. ``"sshd"``. + """ + bracket = filter_raw.find("[") + if bracket != -1: + return filter_raw[:bracket].strip() + return filter_raw.strip() + + +def _build_filter_to_jails_map( + all_jails: dict[str, dict[str, str]], + active_names: set[str], +) -> dict[str, list[str]]: + """Return a mapping of filter base name → list of active jail names. + + Iterates over every jail whose name is in *active_names*, resolves its + ``filter`` config key, and records the jail against the base filter name. + + Args: + all_jails: Merged jail config dict — ``{jail_name: {key: value}}``. + active_names: Set of jail names currently running in fail2ban. + + Returns: + ``{filter_base_name: [jail_name, …]}``. + """ + mapping: dict[str, list[str]] = {} + for jail_name, settings in all_jails.items(): + if jail_name not in active_names: + continue + raw_filter = settings.get("filter", "") + mode = settings.get("mode", "normal") + resolved = _resolve_filter(raw_filter, jail_name, mode) if raw_filter else jail_name + base = _extract_filter_base_name(resolved) + if base: + mapping.setdefault(base, []).append(jail_name) + return mapping + + +def _parse_filters_sync( + filter_d: Path, +) -> list[tuple[str, str, str, bool, str]]: + """Synchronously scan ``filter.d/`` and return per-filter tuples. + + Each tuple contains: + + - ``name`` — filter base name (``"sshd"``). + - ``filename`` — actual filename (``"sshd.conf"`` or ``"sshd.local"``). + - ``content`` — merged file content (``conf`` overridden by ``local``). + - ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``. + - ``source_path`` — absolute path to the primary (``conf``) source file, or + to the ``.local`` file for user-created (local-only) filters. + + Also discovers ``.local``-only files (user-created filters with no + corresponding ``.conf``). These are returned with ``has_local = False`` + and ``source_path`` pointing to the ``.local`` file itself. + + Args: + filter_d: Path to the ``filter.d`` directory. + + Returns: + List of ``(name, filename, content, has_local, source_path)`` tuples, + sorted by name. + """ + if not filter_d.is_dir(): + log.warning("filter_d_not_found", path=str(filter_d)) + return [] + + conf_names: set[str] = set() + results: list[tuple[str, str, str, bool, str]] = [] + + # ---- .conf-based filters (with optional .local override) ---------------- + for conf_path in sorted(filter_d.glob("*.conf")): + if not conf_path.is_file(): + continue + name = conf_path.stem + filename = conf_path.name + conf_names.add(name) + local_path = conf_path.with_suffix(".local") + has_local = local_path.is_file() + + try: + content = conf_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "filter_read_error", name=name, path=str(conf_path), error=str(exc) + ) + continue + + if has_local: + try: + local_content = local_path.read_text(encoding="utf-8") + # Append local content after conf so configparser reads local + # values last (higher priority). + content = content + "\n" + local_content + except OSError as exc: + log.warning( + "filter_local_read_error", + name=name, + path=str(local_path), + error=str(exc), + ) + + results.append((name, filename, content, has_local, str(conf_path))) + + # ---- .local-only filters (user-created, no corresponding .conf) ---------- + for local_path in sorted(filter_d.glob("*.local")): + if not local_path.is_file(): + continue + name = local_path.stem + if name in conf_names: + # Already covered above as a .conf filter with a .local override. + continue + try: + content = local_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "filter_local_read_error", + name=name, + path=str(local_path), + error=str(exc), + ) + continue + results.append((name, local_path.name, content, False, str(local_path))) + + results.sort(key=lambda t: t[0]) + log.debug("filters_scanned", count=len(results), filter_d=str(filter_d)) + return results + + +# --------------------------------------------------------------------------- +# Public API — filter discovery (Task 2.1) +# --------------------------------------------------------------------------- + + +async def list_filters( + config_dir: str, + socket_path: str, +) -> FilterListResponse: + """Return all available filters from ``filter.d/`` with active/inactive status. + + Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, parses each file into a + :class:`~app.models.config.FilterConfig`, and cross-references with the + currently running jails to determine which filters are active. + + A filter is considered *active* when its base name matches the ``filter`` + field of at least one currently running jail. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.FilterListResponse` with all filters + sorted alphabetically, active ones carrying non-empty + ``used_by_jails`` lists. + """ + filter_d = Path(config_dir) / "filter.d" + loop = asyncio.get_event_loop() + + # Run the synchronous scan in a thread-pool executor. + raw_filters: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor( + None, _parse_filters_sync, filter_d + ) + + # Fetch active jail names and their configs concurrently. + all_jails_result, active_names = await asyncio.gather( + loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), + ) + all_jails, _source_files = all_jails_result + + filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) + + filters: list[FilterConfig] = [] + for name, filename, content, has_local, source_path in raw_filters: + cfg = conffile_parser.parse_filter_file( + content, name=name, filename=filename + ) + used_by = sorted(filter_to_jails.get(name, [])) + filters.append( + FilterConfig( + name=cfg.name, + filename=cfg.filename, + before=cfg.before, + after=cfg.after, + variables=cfg.variables, + prefregex=cfg.prefregex, + failregex=cfg.failregex, + ignoreregex=cfg.ignoreregex, + maxlines=cfg.maxlines, + datepattern=cfg.datepattern, + journalmatch=cfg.journalmatch, + active=len(used_by) > 0, + used_by_jails=used_by, + source_file=source_path, + has_local_override=has_local, + ) + ) + + log.info("filters_listed", total=len(filters), active=sum(1 for f in filters if f.active)) + return FilterListResponse(filters=filters, total=len(filters)) + + +async def get_filter( + config_dir: str, + socket_path: str, + name: str, +) -> FilterConfig: + """Return a single filter from ``filter.d/`` with active/inactive status. + + Reads ``{config_dir}/filter.d/{name}.conf``, merges any ``.local`` + override, and enriches the parsed :class:`~app.models.config.FilterConfig` + with ``active``, ``used_by_jails``, ``source_file``, and + ``has_local_override``. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``). + + Returns: + :class:`~app.models.config.FilterConfig` with status fields populated. + + Raises: + FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file + exists in ``filter.d/``. + """ + # Normalise — strip extension if provided (.conf=5 chars, .local=6 chars). + if name.endswith(".conf"): + base_name = name[:-5] + elif name.endswith(".local"): + base_name = name[:-6] + else: + base_name = name + + filter_d = Path(config_dir) / "filter.d" + conf_path = filter_d / f"{base_name}.conf" + local_path = filter_d / f"{base_name}.local" + loop = asyncio.get_event_loop() + + def _read() -> tuple[str, bool, str]: + """Read filter content and return (content, has_local_override, source_path).""" + has_local = local_path.is_file() + if conf_path.is_file(): + content = conf_path.read_text(encoding="utf-8") + if has_local: + try: + content += "\n" + local_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "filter_local_read_error", + name=base_name, + path=str(local_path), + error=str(exc), + ) + return content, has_local, str(conf_path) + elif has_local: + # Local-only filter: created by the user, no shipped .conf base. + content = local_path.read_text(encoding="utf-8") + return content, False, str(local_path) + else: + raise FilterNotFoundError(base_name) + + content, has_local, source_path = await loop.run_in_executor(None, _read) + + cfg = conffile_parser.parse_filter_file( + content, name=base_name, filename=f"{base_name}.conf" + ) + + all_jails_result, active_names = await asyncio.gather( + loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), + ) + all_jails, _source_files = all_jails_result + filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) + + used_by = sorted(filter_to_jails.get(base_name, [])) + log.info("filter_fetched", name=base_name, active=len(used_by) > 0) + return FilterConfig( + name=cfg.name, + filename=cfg.filename, + before=cfg.before, + after=cfg.after, + variables=cfg.variables, + prefregex=cfg.prefregex, + failregex=cfg.failregex, + ignoreregex=cfg.ignoreregex, + maxlines=cfg.maxlines, + datepattern=cfg.datepattern, + journalmatch=cfg.journalmatch, + active=len(used_by) > 0, + used_by_jails=used_by, + source_file=source_path, + has_local_override=has_local, + ) + + +# --------------------------------------------------------------------------- +# Public API — filter write operations (Task 2.2) +# --------------------------------------------------------------------------- + + +async def update_filter( + config_dir: str, + socket_path: str, + name: str, + req: FilterUpdateRequest, + do_reload: bool = False, +) -> FilterConfig: + """Update a filter's ``.local`` override with new regex/pattern values. + + Reads the current merged configuration for *name* (``conf`` + any existing + ``local``), applies the non-``None`` fields in *req* on top of it, and + writes the resulting definition to ``filter.d/{name}.local``. The + original ``.conf`` file is never modified. + + All regex patterns in *req* are validated with Python's ``re`` module + before any write occurs. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``). + req: Partial update — only non-``None`` fields are applied. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Returns: + :class:`~app.models.config.FilterConfig` reflecting the updated state. + + Raises: + FilterNameError: If *name* contains invalid characters. + FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists. + FilterInvalidRegexError: If any supplied regex pattern is invalid. + ConfigWriteError: If writing the ``.local`` file fails. + """ + base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name + _safe_filter_name(base_name) + + # Validate regex patterns before touching the filesystem. + patterns: list[str] = [] + if req.failregex is not None: + patterns.extend(req.failregex) + if req.ignoreregex is not None: + patterns.extend(req.ignoreregex) + _validate_regex_patterns(patterns) + + # Fetch the current merged config (raises FilterNotFoundError if absent). + current = await get_filter(config_dir, socket_path, base_name) + + # Build a FilterConfigUpdate from the request fields. + update = FilterConfigUpdate( + failregex=req.failregex, + ignoreregex=req.ignoreregex, + datepattern=req.datepattern, + journalmatch=req.journalmatch, + ) + + merged = conffile_parser.merge_filter_update(current, update) + content = conffile_parser.serialize_filter_config(merged) + + filter_d = Path(config_dir) / "filter.d" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _write_filter_local_sync, filter_d, base_name, content) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_filter_update_failed", + filter=base_name, + error=str(exc), + ) + + log.info("filter_updated", filter=base_name, reload=do_reload) + return await get_filter(config_dir, socket_path, base_name) + + +async def create_filter( + config_dir: str, + socket_path: str, + req: FilterCreateRequest, + do_reload: bool = False, +) -> FilterConfig: + """Create a brand-new user-defined filter in ``filter.d/{name}.local``. + + No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a + ``.conf`` or ``.local`` file already exists for the requested name, a + :class:`FilterAlreadyExistsError` is raised. + + All regex patterns are validated with Python's ``re`` module before + writing. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + req: Filter name and definition fields. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Returns: + :class:`~app.models.config.FilterConfig` for the newly created filter. + + Raises: + FilterNameError: If ``req.name`` contains invalid characters. + FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists. + FilterInvalidRegexError: If any regex pattern is invalid. + ConfigWriteError: If writing fails. + """ + _safe_filter_name(req.name) + + filter_d = Path(config_dir) / "filter.d" + conf_path = filter_d / f"{req.name}.conf" + local_path = filter_d / f"{req.name}.local" + + def _check_not_exists() -> None: + if conf_path.is_file() or local_path.is_file(): + raise FilterAlreadyExistsError(req.name) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _check_not_exists) + + # Validate regex patterns. + patterns: list[str] = list(req.failregex) + list(req.ignoreregex) + _validate_regex_patterns(patterns) + + # Build a FilterConfig and serialise it. + cfg = FilterConfig( + name=req.name, + filename=f"{req.name}.local", + failregex=req.failregex, + ignoreregex=req.ignoreregex, + prefregex=req.prefregex, + datepattern=req.datepattern, + journalmatch=req.journalmatch, + ) + content = conffile_parser.serialize_filter_config(cfg) + + await loop.run_in_executor(None, _write_filter_local_sync, filter_d, req.name, content) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_filter_create_failed", + filter=req.name, + error=str(exc), + ) + + log.info("filter_created", filter=req.name, reload=do_reload) + # Re-fetch to get the canonical FilterConfig (source_file, active, etc.). + return await get_filter(config_dir, socket_path, req.name) + + +async def delete_filter( + config_dir: str, + name: str, +) -> None: + """Delete a user-created filter's ``.local`` file. + + Deletion rules: + - If only a ``.conf`` file exists (shipped default, no user override) → + :class:`FilterReadonlyError`. + - If a ``.local`` file exists (whether or not a ``.conf`` also exists) → + the ``.local`` file is deleted. The shipped ``.conf`` is never touched. + - If neither file exists → :class:`FilterNotFoundError`. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + name: Filter base name (e.g. ``"sshd"``). + + Raises: + FilterNameError: If *name* contains invalid characters. + FilterNotFoundError: If no filter file is found for *name*. + FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``). + ConfigWriteError: If deletion of the ``.local`` file fails. + """ + base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name + _safe_filter_name(base_name) + + filter_d = Path(config_dir) / "filter.d" + conf_path = filter_d / f"{base_name}.conf" + local_path = filter_d / f"{base_name}.local" + + loop = asyncio.get_event_loop() + + def _delete() -> None: + has_conf = conf_path.is_file() + has_local = local_path.is_file() + + if not has_conf and not has_local: + raise FilterNotFoundError(base_name) + + if has_conf and not has_local: + # Shipped default — nothing user-writable to remove. + raise FilterReadonlyError(base_name) + + try: + local_path.unlink() + except OSError as exc: + raise ConfigWriteError( + f"Failed to delete {local_path}: {exc}" + ) from exc + + log.info("filter_local_deleted", filter=base_name, path=str(local_path)) + + await loop.run_in_executor(None, _delete) + + +async def assign_filter_to_jail( + config_dir: str, + socket_path: str, + jail_name: str, + req: AssignFilterRequest, + do_reload: bool = False, +) -> None: + """Assign a filter to a jail by updating the jail's ``.local`` file. + + Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section + of ``jail.d/{jail_name}.local``. If the ``.local`` file already contains + other settings for this jail they are preserved. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + jail_name: Name of the jail to update. + req: Request containing the filter name to assign. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Raises: + JailNameError: If *jail_name* contains invalid characters. + FilterNameError: If ``req.filter_name`` contains invalid characters. + JailNotFoundInConfigError: If *jail_name* is not defined in any config + file. + FilterNotFoundError: If ``req.filter_name`` does not exist in + ``filter.d/``. + ConfigWriteError: If writing fails. + """ + _safe_jail_name(jail_name) + _safe_filter_name(req.filter_name) + + loop = asyncio.get_event_loop() + + # Verify the jail exists in config. + all_jails, _src = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + if jail_name not in all_jails: + raise JailNotFoundInConfigError(jail_name) + + # Verify the filter exists (conf or local). + filter_d = Path(config_dir) / "filter.d" + + def _check_filter() -> None: + conf_exists = (filter_d / f"{req.filter_name}.conf").is_file() + local_exists = (filter_d / f"{req.filter_name}.local").is_file() + if not conf_exists and not local_exists: + raise FilterNotFoundError(req.filter_name) + + await loop.run_in_executor(None, _check_filter) + + await loop.run_in_executor( + None, + _set_jail_local_key_sync, + Path(config_dir), + jail_name, + "filter", + req.filter_name, + ) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_assign_filter_failed", + jail=jail_name, + filter=req.filter_name, + error=str(exc), + ) + + log.info( + "filter_assigned_to_jail", + jail=jail_name, + filter=req.filter_name, + reload=do_reload, + ) + + +# --------------------------------------------------------------------------- +# Action discovery helpers (Task 3.1) +# --------------------------------------------------------------------------- + +# Allowlist pattern for action names used in path construction. +_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" +) + + +class ActionNotFoundError(Exception): + """Raised when the requested action name is not found in ``action.d/``.""" + + def __init__(self, name: str) -> None: + """Initialise with the action name that was not found. + + Args: + name: The action name that could not be located. + """ + self.name: str = name + super().__init__(f"Action not found: {name!r}") + + +class ActionAlreadyExistsError(Exception): + """Raised when trying to create an action whose ``.conf`` or ``.local`` already exists.""" + + def __init__(self, name: str) -> None: + """Initialise with the action name that already exists. + + Args: + name: The action name that already exists. + """ + self.name: str = name + super().__init__(f"Action already exists: {name!r}") + + +class ActionReadonlyError(Exception): + """Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override.""" + + def __init__(self, name: str) -> None: + """Initialise with the action name that cannot be deleted. + + Args: + name: The action name that is read-only (shipped ``.conf`` only). + """ + self.name: str = name + super().__init__( + f"Action {name!r} is a shipped default (.conf only); " + "only user-created .local files can be deleted." + ) + + +class ActionNameError(Exception): + """Raised when an action name contains invalid characters.""" + + +def _safe_action_name(name: str) -> str: + """Validate *name* and return it unchanged or raise :class:`ActionNameError`. + + Args: + name: Proposed action name (without extension). + + Returns: + The name unchanged if valid. + + Raises: + ActionNameError: If *name* contains unsafe characters. + """ + if not _SAFE_ACTION_NAME_RE.match(name): + raise ActionNameError( + f"Action name {name!r} contains invalid characters. " + "Only alphanumeric characters, hyphens, underscores, and dots are " + "allowed; must start with an alphanumeric character." + ) + return name + + +def _build_action_to_jails_map( + all_jails: dict[str, dict[str, str]], + active_names: set[str], +) -> dict[str, list[str]]: + """Return a mapping of action base name → list of active jail names. + + Iterates over every jail whose name is in *active_names*, resolves each + entry in its ``action`` config key to an action base name (stripping + ``[…]`` parameter blocks), and records the jail against each base name. + + Args: + all_jails: Merged jail config dict — ``{jail_name: {key: value}}``. + active_names: Set of jail names currently running in fail2ban. + + Returns: + ``{action_base_name: [jail_name, …]}``. + """ + mapping: dict[str, list[str]] = {} + for jail_name, settings in all_jails.items(): + if jail_name not in active_names: + continue + raw_action = settings.get("action", "") + if not raw_action: + continue + for line in raw_action.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # Strip optional [key=value] parameter block to get the base name. + bracket = stripped.find("[") + base = stripped[:bracket].strip() if bracket != -1 else stripped + if base: + mapping.setdefault(base, []).append(jail_name) + return mapping + + +def _parse_actions_sync( + action_d: Path, +) -> list[tuple[str, str, str, bool, str]]: + """Synchronously scan ``action.d/`` and return per-action tuples. + + Each tuple contains: + + - ``name`` — action base name (``"iptables"``). + - ``filename`` — actual filename (``"iptables.conf"``). + - ``content`` — merged file content (``conf`` overridden by ``local``). + - ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``. + - ``source_path`` — absolute path to the primary (``conf``) source file, or + to the ``.local`` file for user-created (local-only) actions. + + Also discovers ``.local``-only files (user-created actions with no + corresponding ``.conf``). + + Args: + action_d: Path to the ``action.d`` directory. + + Returns: + List of ``(name, filename, content, has_local, source_path)`` tuples, + sorted by name. + """ + if not action_d.is_dir(): + log.warning("action_d_not_found", path=str(action_d)) + return [] + + conf_names: set[str] = set() + results: list[tuple[str, str, str, bool, str]] = [] + + # ---- .conf-based actions (with optional .local override) ---------------- + for conf_path in sorted(action_d.glob("*.conf")): + if not conf_path.is_file(): + continue + name = conf_path.stem + filename = conf_path.name + conf_names.add(name) + local_path = conf_path.with_suffix(".local") + has_local = local_path.is_file() + + try: + content = conf_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "action_read_error", name=name, path=str(conf_path), error=str(exc) + ) + continue + + if has_local: + try: + local_content = local_path.read_text(encoding="utf-8") + content = content + "\n" + local_content + except OSError as exc: + log.warning( + "action_local_read_error", + name=name, + path=str(local_path), + error=str(exc), + ) + + results.append((name, filename, content, has_local, str(conf_path))) + + # ---- .local-only actions (user-created, no corresponding .conf) ---------- + for local_path in sorted(action_d.glob("*.local")): + if not local_path.is_file(): + continue + name = local_path.stem + if name in conf_names: + continue + try: + content = local_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "action_local_read_error", + name=name, + path=str(local_path), + error=str(exc), + ) + continue + results.append((name, local_path.name, content, False, str(local_path))) + + results.sort(key=lambda t: t[0]) + log.debug("actions_scanned", count=len(results), action_d=str(action_d)) + return results + + +def _append_jail_action_sync( + config_dir: Path, + jail_name: str, + action_entry: str, +) -> None: + """Append an action entry to the ``action`` key in ``jail.d/{jail_name}.local``. + + If the ``.local`` file already contains an ``action`` key under the jail + section, the new entry is appended as an additional line (multi-line + configparser format) unless it is already present. If no ``action`` key + exists, one is created. + + Args: + config_dir: The fail2ban configuration root directory. + jail_name: Validated jail name. + action_entry: Full action string including any ``[…]`` parameters. + + Raises: + ConfigWriteError: If writing fails. + """ + jail_d = config_dir / "jail.d" + try: + jail_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create jail.d directory: {exc}" + ) from exc + + local_path = jail_d / f"{jail_name}.local" + + parser = _build_parser() + if local_path.is_file(): + try: + parser.read(str(local_path), encoding="utf-8") + except (configparser.Error, OSError) as exc: + log.warning( + "jail_local_read_for_update_error", + jail=jail_name, + error=str(exc), + ) + + if not parser.has_section(jail_name): + parser.add_section(jail_name) + + existing_raw = parser.get(jail_name, "action") if parser.has_option(jail_name, "action") else "" + existing_lines = [ + line.strip() + for line in existing_raw.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + # Extract base names from existing entries for duplicate checking. + def _base(entry: str) -> str: + bracket = entry.find("[") + return entry[:bracket].strip() if bracket != -1 else entry.strip() + + new_base = _base(action_entry) + if not any(_base(e) == new_base for e in existing_lines): + existing_lines.append(action_entry) + + if existing_lines: + # configparser multi-line: continuation lines start with whitespace. + new_value = existing_lines[0] + "".join( + f"\n {line}" for line in existing_lines[1:] + ) + parser.set(jail_name, "action", new_value) + else: + parser.set(jail_name, "action", action_entry) + + buf = io.StringIO() + buf.write("# Managed by BanGUI — do not edit manually\n\n") + parser.write(buf) + content = buf.getvalue() + + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=jail_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info( + "jail_action_appended", + jail=jail_name, + action=action_entry, + path=str(local_path), + ) + + +def _remove_jail_action_sync( + config_dir: Path, + jail_name: str, + action_name: str, +) -> None: + """Remove an action entry from the ``action`` key in ``jail.d/{jail_name}.local``. + + Reads the ``.local`` file, removes any ``action`` entries whose base name + matches *action_name*, and writes the result back atomically. If no + ``.local`` file exists, this is a no-op. + + Args: + config_dir: The fail2ban configuration root directory. + jail_name: Validated jail name. + action_name: Base name of the action to remove (without ``[…]``). + + Raises: + ConfigWriteError: If writing fails. + """ + jail_d = config_dir / "jail.d" + local_path = jail_d / f"{jail_name}.local" + + if not local_path.is_file(): + return + + parser = _build_parser() + try: + parser.read(str(local_path), encoding="utf-8") + except (configparser.Error, OSError) as exc: + log.warning( + "jail_local_read_for_update_error", + jail=jail_name, + error=str(exc), + ) + return + + if not parser.has_section(jail_name) or not parser.has_option(jail_name, "action"): + return + + existing_raw = parser.get(jail_name, "action") + existing_lines = [ + line.strip() + for line in existing_raw.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + + def _base(entry: str) -> str: + bracket = entry.find("[") + return entry[:bracket].strip() if bracket != -1 else entry.strip() + + filtered = [e for e in existing_lines if _base(e) != action_name] + + if len(filtered) == len(existing_lines): + # Action was not found — silently return (idempotent). + return + + if filtered: + new_value = filtered[0] + "".join( + f"\n {line}" for line in filtered[1:] + ) + parser.set(jail_name, "action", new_value) + else: + parser.remove_option(jail_name, "action") + + buf = io.StringIO() + buf.write("# Managed by BanGUI — do not edit manually\n\n") + parser.write(buf) + content = buf.getvalue() + + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=jail_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info( + "jail_action_removed", + jail=jail_name, + action=action_name, + path=str(local_path), + ) + + +def _write_action_local_sync(action_d: Path, name: str, content: str) -> None: + """Write *content* to ``action.d/{name}.local`` atomically. + + The write is atomic: content is written to a temp file first, then + renamed into place. The ``action.d/`` directory is created if absent. + + Args: + action_d: Path to the ``action.d`` directory. + name: Validated action base name (used as filename stem). + content: Full serialized action content to write. + + Raises: + ConfigWriteError: If writing fails. + """ + try: + action_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create action.d directory: {exc}" + ) from exc + + local_path = action_d / f"{name}.local" + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=action_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info("action_local_written", action=name, path=str(local_path)) + + +# --------------------------------------------------------------------------- +# Public API — action discovery (Task 3.1) +# --------------------------------------------------------------------------- + + +async def list_actions( + config_dir: str, + socket_path: str, +) -> ActionListResponse: + """Return all available actions from ``action.d/`` with active/inactive status. + + Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, parses each file into an + :class:`~app.models.config.ActionConfig`, and cross-references with the + currently running jails to determine which actions are active. + + An action is considered *active* when its base name appears in the + ``action`` field of at least one currently running jail. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.ActionListResponse` with all actions + sorted alphabetically, active ones carrying non-empty + ``used_by_jails`` lists. + """ + action_d = Path(config_dir) / "action.d" + loop = asyncio.get_event_loop() + + raw_actions: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor( + None, _parse_actions_sync, action_d + ) + + all_jails_result, active_names = await asyncio.gather( + loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), + ) + all_jails, _source_files = all_jails_result + + action_to_jails = _build_action_to_jails_map(all_jails, active_names) + + actions: list[ActionConfig] = [] + for name, filename, content, has_local, source_path in raw_actions: + cfg = conffile_parser.parse_action_file( + content, name=name, filename=filename + ) + used_by = sorted(action_to_jails.get(name, [])) + actions.append( + ActionConfig( + name=cfg.name, + filename=cfg.filename, + before=cfg.before, + after=cfg.after, + actionstart=cfg.actionstart, + actionstop=cfg.actionstop, + actioncheck=cfg.actioncheck, + actionban=cfg.actionban, + actionunban=cfg.actionunban, + actionflush=cfg.actionflush, + definition_vars=cfg.definition_vars, + init_vars=cfg.init_vars, + active=len(used_by) > 0, + used_by_jails=used_by, + source_file=source_path, + has_local_override=has_local, + ) + ) + + log.info("actions_listed", total=len(actions), active=sum(1 for a in actions if a.active)) + return ActionListResponse(actions=actions, total=len(actions)) + + +async def get_action( + config_dir: str, + socket_path: str, + name: str, +) -> ActionConfig: + """Return a single action from ``action.d/`` with active/inactive status. + + Reads ``{config_dir}/action.d/{name}.conf``, merges any ``.local`` + override, and enriches the parsed :class:`~app.models.config.ActionConfig` + with ``active``, ``used_by_jails``, ``source_file``, and + ``has_local_override``. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``). + + Returns: + :class:`~app.models.config.ActionConfig` with status fields populated. + + Raises: + ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file + exists in ``action.d/``. + """ + if name.endswith(".conf"): + base_name = name[:-5] + elif name.endswith(".local"): + base_name = name[:-6] + else: + base_name = name + + action_d = Path(config_dir) / "action.d" + conf_path = action_d / f"{base_name}.conf" + local_path = action_d / f"{base_name}.local" + loop = asyncio.get_event_loop() + + def _read() -> tuple[str, bool, str]: + """Read action content and return (content, has_local_override, source_path).""" + has_local = local_path.is_file() + if conf_path.is_file(): + content = conf_path.read_text(encoding="utf-8") + if has_local: + try: + content += "\n" + local_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "action_local_read_error", + name=base_name, + path=str(local_path), + error=str(exc), + ) + return content, has_local, str(conf_path) + elif has_local: + content = local_path.read_text(encoding="utf-8") + return content, False, str(local_path) + else: + raise ActionNotFoundError(base_name) + + content, has_local, source_path = await loop.run_in_executor(None, _read) + + cfg = conffile_parser.parse_action_file( + content, name=base_name, filename=f"{base_name}.conf" + ) + + all_jails_result, active_names = await asyncio.gather( + loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), + ) + all_jails, _source_files = all_jails_result + action_to_jails = _build_action_to_jails_map(all_jails, active_names) + + used_by = sorted(action_to_jails.get(base_name, [])) + log.info("action_fetched", name=base_name, active=len(used_by) > 0) + return ActionConfig( + name=cfg.name, + filename=cfg.filename, + before=cfg.before, + after=cfg.after, + actionstart=cfg.actionstart, + actionstop=cfg.actionstop, + actioncheck=cfg.actioncheck, + actionban=cfg.actionban, + actionunban=cfg.actionunban, + actionflush=cfg.actionflush, + definition_vars=cfg.definition_vars, + init_vars=cfg.init_vars, + active=len(used_by) > 0, + used_by_jails=used_by, + source_file=source_path, + has_local_override=has_local, + ) + + +# --------------------------------------------------------------------------- +# Public API — action write operations (Task 3.2) +# --------------------------------------------------------------------------- + + +async def update_action( + config_dir: str, + socket_path: str, + name: str, + req: ActionUpdateRequest, + do_reload: bool = False, +) -> ActionConfig: + """Update an action's ``.local`` override with new lifecycle command values. + + Reads the current merged configuration for *name* (``conf`` + any existing + ``local``), applies the non-``None`` fields in *req* on top of it, and + writes the resulting definition to ``action.d/{name}.local``. The + original ``.conf`` file is never modified. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``). + req: Partial update — only non-``None`` fields are applied. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Returns: + :class:`~app.models.config.ActionConfig` reflecting the updated state. + + Raises: + ActionNameError: If *name* contains invalid characters. + ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists. + ConfigWriteError: If writing the ``.local`` file fails. + """ + base_name = name[:-5] if name.endswith((".conf", ".local")) else name + _safe_action_name(base_name) + + current = await get_action(config_dir, socket_path, base_name) + + update = ActionConfigUpdate( + actionstart=req.actionstart, + actionstop=req.actionstop, + actioncheck=req.actioncheck, + actionban=req.actionban, + actionunban=req.actionunban, + actionflush=req.actionflush, + definition_vars=req.definition_vars, + init_vars=req.init_vars, + ) + + merged = conffile_parser.merge_action_update(current, update) + content = conffile_parser.serialize_action_config(merged) + + action_d = Path(config_dir) / "action.d" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _write_action_local_sync, action_d, base_name, content) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_action_update_failed", + action=base_name, + error=str(exc), + ) + + log.info("action_updated", action=base_name, reload=do_reload) + return await get_action(config_dir, socket_path, base_name) + + +async def create_action( + config_dir: str, + socket_path: str, + req: ActionCreateRequest, + do_reload: bool = False, +) -> ActionConfig: + """Create a brand-new user-defined action in ``action.d/{name}.local``. + + No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a + ``.conf`` or ``.local`` file already exists for the requested name, an + :class:`ActionAlreadyExistsError` is raised. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + req: Action name and definition fields. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Returns: + :class:`~app.models.config.ActionConfig` for the newly created action. + + Raises: + ActionNameError: If ``req.name`` contains invalid characters. + ActionAlreadyExistsError: If a ``.conf`` or ``.local`` already exists. + ConfigWriteError: If writing fails. + """ + _safe_action_name(req.name) + + action_d = Path(config_dir) / "action.d" + conf_path = action_d / f"{req.name}.conf" + local_path = action_d / f"{req.name}.local" + + def _check_not_exists() -> None: + if conf_path.is_file() or local_path.is_file(): + raise ActionAlreadyExistsError(req.name) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _check_not_exists) + + cfg = ActionConfig( + name=req.name, + filename=f"{req.name}.local", + actionstart=req.actionstart, + actionstop=req.actionstop, + actioncheck=req.actioncheck, + actionban=req.actionban, + actionunban=req.actionunban, + actionflush=req.actionflush, + definition_vars=req.definition_vars, + init_vars=req.init_vars, + ) + content = conffile_parser.serialize_action_config(cfg) + + await loop.run_in_executor(None, _write_action_local_sync, action_d, req.name, content) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_action_create_failed", + action=req.name, + error=str(exc), + ) + + log.info("action_created", action=req.name, reload=do_reload) + return await get_action(config_dir, socket_path, req.name) + + +async def delete_action( + config_dir: str, + name: str, +) -> None: + """Delete a user-created action's ``.local`` file. + + Deletion rules: + - If only a ``.conf`` file exists (shipped default, no user override) → + :class:`ActionReadonlyError`. + - If a ``.local`` file exists (whether or not a ``.conf`` also exists) → + only the ``.local`` file is deleted. + - If neither file exists → :class:`ActionNotFoundError`. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + name: Action base name (e.g. ``"iptables"``). + + Raises: + ActionNameError: If *name* contains invalid characters. + ActionNotFoundError: If no action file is found for *name*. + ActionReadonlyError: If only a shipped ``.conf`` exists (no ``.local``). + ConfigWriteError: If deletion of the ``.local`` file fails. + """ + base_name = name[:-5] if name.endswith((".conf", ".local")) else name + _safe_action_name(base_name) + + action_d = Path(config_dir) / "action.d" + conf_path = action_d / f"{base_name}.conf" + local_path = action_d / f"{base_name}.local" + + loop = asyncio.get_event_loop() + + def _delete() -> None: + has_conf = conf_path.is_file() + has_local = local_path.is_file() + + if not has_conf and not has_local: + raise ActionNotFoundError(base_name) + + if has_conf and not has_local: + raise ActionReadonlyError(base_name) + + try: + local_path.unlink() + except OSError as exc: + raise ConfigWriteError( + f"Failed to delete {local_path}: {exc}" + ) from exc + + log.info("action_local_deleted", action=base_name, path=str(local_path)) + + await loop.run_in_executor(None, _delete) + + +async def assign_action_to_jail( + config_dir: str, + socket_path: str, + jail_name: str, + req: AssignActionRequest, + do_reload: bool = False, +) -> None: + """Add an action to a jail by updating the jail's ``.local`` file. + + Appends ``{req.action_name}[{params}]`` (or just ``{req.action_name}`` when + no params are given) to the ``action`` key in the ``[{jail_name}]`` section + of ``jail.d/{jail_name}.local``. If the action is already listed it is not + duplicated. If the ``.local`` file does not exist it is created. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + jail_name: Name of the jail to update. + req: Request containing the action name and optional parameters. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Raises: + JailNameError: If *jail_name* contains invalid characters. + ActionNameError: If ``req.action_name`` contains invalid characters. + JailNotFoundInConfigError: If *jail_name* is not defined in any config + file. + ActionNotFoundError: If ``req.action_name`` does not exist in + ``action.d/``. + ConfigWriteError: If writing fails. + """ + _safe_jail_name(jail_name) + _safe_action_name(req.action_name) + + loop = asyncio.get_event_loop() + + all_jails, _src = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + if jail_name not in all_jails: + raise JailNotFoundInConfigError(jail_name) + + action_d = Path(config_dir) / "action.d" + + def _check_action() -> None: + if ( + not (action_d / f"{req.action_name}.conf").is_file() + and not (action_d / f"{req.action_name}.local").is_file() + ): + raise ActionNotFoundError(req.action_name) + + await loop.run_in_executor(None, _check_action) + + # Build the action string with optional parameters. + if req.params: + param_str = ", ".join(f"{k}={v}" for k, v in sorted(req.params.items())) + action_entry = f"{req.action_name}[{param_str}]" + else: + action_entry = req.action_name + + await loop.run_in_executor( + None, + _append_jail_action_sync, + Path(config_dir), + jail_name, + action_entry, + ) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_assign_action_failed", + jail=jail_name, + action=req.action_name, + error=str(exc), + ) + + log.info( + "action_assigned_to_jail", + jail=jail_name, + action=req.action_name, + reload=do_reload, + ) + + +async def remove_action_from_jail( + config_dir: str, + socket_path: str, + jail_name: str, + action_name: str, + do_reload: bool = False, +) -> None: + """Remove an action from a jail's ``.local`` config. + + Reads ``jail.d/{jail_name}.local``, removes the line(s) that reference + ``{action_name}`` from the ``action`` key (including any ``[…]`` parameter + blocks), and writes the file back atomically. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + jail_name: Name of the jail to update. + action_name: Base name of the action to remove. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Raises: + JailNameError: If *jail_name* contains invalid characters. + ActionNameError: If *action_name* contains invalid characters. + JailNotFoundInConfigError: If *jail_name* is not defined in any config. + ConfigWriteError: If writing fails. + """ + _safe_jail_name(jail_name) + _safe_action_name(action_name) + + loop = asyncio.get_event_loop() + + all_jails, _src = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + if jail_name not in all_jails: + raise JailNotFoundInConfigError(jail_name) + + await loop.run_in_executor( + None, + _remove_jail_action_sync, + Path(config_dir), + jail_name, + action_name, + ) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_remove_action_failed", + jail=jail_name, + action=action_name, + error=str(exc), + ) + + log.info( + "action_removed_from_jail", + jail=jail_name, + action=action_name, + reload=do_reload, + ) + diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py new file mode 100644 index 0000000..4f5a3c4 --- /dev/null +++ b/backend/app/services/config_service.py @@ -0,0 +1,929 @@ +"""Configuration inspection and editing service. + +Provides methods to read and update fail2ban jail configuration and global +server settings via the Unix domain socket. Regex validation is performed +locally with Python's :mod:`re` module before any write is sent to the daemon +so that invalid patterns are rejected early. + +Architecture note: this module is a pure service — it contains **no** +HTTP/FastAPI concerns. All results are returned as Pydantic models so +routers can serialise them directly. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import structlog + +if TYPE_CHECKING: + import aiosqlite + +from app.models.config import ( + AddLogPathRequest, + BantimeEscalation, + Fail2BanLogResponse, + GlobalConfigResponse, + GlobalConfigUpdate, + JailConfig, + JailConfigListResponse, + JailConfigResponse, + JailConfigUpdate, + LogPreviewLine, + LogPreviewRequest, + LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, + RegexTestRequest, + RegexTestResponse, + ServiceStatusResponse, +) +from app.services import setup_service +from app.utils.fail2ban_client import Fail2BanClient + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +_SOCKET_TIMEOUT: float = 10.0 + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class JailNotFoundError(Exception): + """Raised when a requested jail name does not exist in fail2ban.""" + + def __init__(self, name: str) -> None: + """Initialise with the jail name that was not found. + + Args: + name: The jail name that could not be located. + """ + self.name: str = name + super().__init__(f"Jail not found: {name!r}") + + +class ConfigValidationError(Exception): + """Raised when a configuration value fails validation before writing.""" + + +class ConfigOperationError(Exception): + """Raised when a configuration write command fails.""" + + +# --------------------------------------------------------------------------- +# Internal helpers (mirrored from jail_service for isolation) +# --------------------------------------------------------------------------- + + +def _ok(response: Any) -> Any: + """Extract payload from a fail2ban ``(return_code, data)`` response. + + Args: + response: Raw value returned by :meth:`~Fail2BanClient.send`. + + Returns: + The payload ``data`` portion of the response. + + Raises: + ValueError: If the return code indicates an error. + """ + try: + code, data = response + except (TypeError, ValueError) as exc: + raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc + if code != 0: + raise ValueError(f"fail2ban returned error code {code}: {data!r}") + return data + + +def _to_dict(pairs: Any) -> dict[str, Any]: + """Convert a list of ``(key, value)`` pairs to a plain dict.""" + if not isinstance(pairs, (list, tuple)): + return {} + result: dict[str, Any] = {} + for item in pairs: + try: + k, v = item + result[str(k)] = v + except (TypeError, ValueError): + pass + return result + + +def _ensure_list(value: Any) -> list[str]: + """Coerce a fail2ban ``get`` result to a list of strings.""" + if value is None: + return [] + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, (list, tuple)): + return [str(v) for v in value if v is not None] + return [str(value)] + + +async def _safe_get( + client: Fail2BanClient, + command: list[Any], + default: Any = None, +) -> Any: + """Send a command and return *default* if it fails.""" + try: + return _ok(await client.send(command)) + except Exception: + return default + + +def _is_not_found_error(exc: Exception) -> bool: + """Return ``True`` if *exc* signals an unknown jail.""" + msg = str(exc).lower() + return any( + phrase in msg + for phrase in ("unknown jail", "no jail", "does not exist", "not found") + ) + + +def _validate_regex(pattern: str) -> str | None: + """Try to compile *pattern* and return an error message if invalid. + + Args: + pattern: A regex pattern string to validate. + + Returns: + ``None`` if valid, or an error message string if the pattern is broken. + """ + try: + re.compile(pattern) + return None + except re.error as exc: + return str(exc) + + +# --------------------------------------------------------------------------- +# Public API — read jail configuration +# --------------------------------------------------------------------------- + + +async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: + """Return the editable configuration for a single jail. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + + Returns: + :class:`~app.models.config.JailConfigResponse`. + + Raises: + JailNotFoundError: If *name* is not a known jail. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # Verify existence. + try: + _ok(await client.send(["status", name, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise + + ( + bantime_raw, + findtime_raw, + maxretry_raw, + failregex_raw, + ignoreregex_raw, + logpath_raw, + datepattern_raw, + logencoding_raw, + backend_raw, + usedns_raw, + prefregex_raw, + actions_raw, + bt_increment_raw, + bt_factor_raw, + bt_formula_raw, + bt_multipliers_raw, + bt_maxtime_raw, + bt_rndtime_raw, + bt_overalljails_raw, + ) = await asyncio.gather( + _safe_get(client, ["get", name, "bantime"], 600), + _safe_get(client, ["get", name, "findtime"], 600), + _safe_get(client, ["get", name, "maxretry"], 5), + _safe_get(client, ["get", name, "failregex"], []), + _safe_get(client, ["get", name, "ignoreregex"], []), + _safe_get(client, ["get", name, "logpath"], []), + _safe_get(client, ["get", name, "datepattern"], None), + _safe_get(client, ["get", name, "logencoding"], "UTF-8"), + _safe_get(client, ["get", name, "backend"], "polling"), + _safe_get(client, ["get", name, "usedns"], "warn"), + _safe_get(client, ["get", name, "prefregex"], ""), + _safe_get(client, ["get", name, "actions"], []), + _safe_get(client, ["get", name, "bantime.increment"], False), + _safe_get(client, ["get", name, "bantime.factor"], None), + _safe_get(client, ["get", name, "bantime.formula"], None), + _safe_get(client, ["get", name, "bantime.multipliers"], None), + _safe_get(client, ["get", name, "bantime.maxtime"], None), + _safe_get(client, ["get", name, "bantime.rndtime"], None), + _safe_get(client, ["get", name, "bantime.overalljails"], False), + ) + + bantime_escalation = BantimeEscalation( + increment=bool(bt_increment_raw), + factor=float(bt_factor_raw) if bt_factor_raw is not None else None, + formula=str(bt_formula_raw) if bt_formula_raw else None, + multipliers=str(bt_multipliers_raw) if bt_multipliers_raw else None, + max_time=int(bt_maxtime_raw) if bt_maxtime_raw is not None else None, + rnd_time=int(bt_rndtime_raw) if bt_rndtime_raw is not None else None, + overall_jails=bool(bt_overalljails_raw), + ) + + jail_cfg = JailConfig( + name=name, + ban_time=int(bantime_raw or 600), + find_time=int(findtime_raw or 600), + max_retry=int(maxretry_raw or 5), + fail_regex=_ensure_list(failregex_raw), + ignore_regex=_ensure_list(ignoreregex_raw), + log_paths=_ensure_list(logpath_raw), + date_pattern=str(datepattern_raw) if datepattern_raw else None, + log_encoding=str(logencoding_raw or "UTF-8"), + backend=str(backend_raw or "polling"), + use_dns=str(usedns_raw or "warn"), + prefregex=str(prefregex_raw) if prefregex_raw else "", + actions=_ensure_list(actions_raw), + bantime_escalation=bantime_escalation, + ) + + log.info("jail_config_fetched", jail=name) + return JailConfigResponse(jail=jail_cfg) + + +async def list_jail_configs(socket_path: str) -> JailConfigListResponse: + """Return configuration for all active jails. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.JailConfigListResponse`. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + global_status = _to_dict(_ok(await client.send(["status"]))) + jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip() + jail_names: list[str] = ( + [j.strip() for j in jail_list_raw.split(",") if j.strip()] + if jail_list_raw + else [] + ) + + if not jail_names: + return JailConfigListResponse(jails=[], total=0) + + responses: list[JailConfigResponse] = await asyncio.gather( + *[get_jail_config(socket_path, name) for name in jail_names], + return_exceptions=False, + ) + + jails = [r.jail for r in responses] + log.info("jail_configs_listed", count=len(jails)) + return JailConfigListResponse(jails=jails, total=len(jails)) + + +# --------------------------------------------------------------------------- +# Public API — write jail configuration +# --------------------------------------------------------------------------- + + +async def update_jail_config( + socket_path: str, + name: str, + update: JailConfigUpdate, +) -> None: + """Apply *update* to the configuration of a running jail. + + Each non-None field in *update* is sent as a separate ``set`` command. + Regex patterns are validated locally before any write is sent. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + update: Partial update payload. + + Raises: + JailNotFoundError: If *name* is not a known jail. + ConfigValidationError: If a regex pattern fails to compile. + ConfigOperationError: If a ``set`` command is rejected by fail2ban. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + # Validate all regex patterns before touching the daemon. + for pattern_list, field in [ + (update.fail_regex, "fail_regex"), + (update.ignore_regex, "ignore_regex"), + ]: + if pattern_list is None: + continue + for pattern in pattern_list: + err = _validate_regex(pattern) + if err: + raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})") + if update.prefregex is not None and update.prefregex: + err = _validate_regex(update.prefregex) + if err: + raise ConfigValidationError(f"Invalid regex in 'prefregex': {err!r} (pattern: {update.prefregex!r})") + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # Verify existence. + try: + _ok(await client.send(["status", name, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise + + async def _set(key: str, value: Any) -> None: + try: + _ok(await client.send(["set", name, key, value])) + except ValueError as exc: + raise ConfigOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc + + if update.ban_time is not None: + await _set("bantime", update.ban_time) + if update.find_time is not None: + await _set("findtime", update.find_time) + if update.max_retry is not None: + await _set("maxretry", update.max_retry) + if update.date_pattern is not None: + await _set("datepattern", update.date_pattern) + if update.dns_mode is not None: + await _set("usedns", update.dns_mode) + if update.backend is not None: + await _set("backend", update.backend) + if update.log_encoding is not None: + await _set("logencoding", update.log_encoding) + if update.prefregex is not None: + await _set("prefregex", update.prefregex) + if update.enabled is not None: + await _set("idle", "off" if update.enabled else "on") + + # Ban-time escalation fields. + if update.bantime_escalation is not None: + esc = update.bantime_escalation + if esc.increment is not None: + await _set("bantime.increment", "true" if esc.increment else "false") + if esc.factor is not None: + await _set("bantime.factor", str(esc.factor)) + if esc.formula is not None: + await _set("bantime.formula", esc.formula) + if esc.multipliers is not None: + await _set("bantime.multipliers", esc.multipliers) + if esc.max_time is not None: + await _set("bantime.maxtime", esc.max_time) + if esc.rnd_time is not None: + await _set("bantime.rndtime", esc.rnd_time) + if esc.overall_jails is not None: + await _set("bantime.overalljails", "true" if esc.overall_jails else "false") + + # Replacing regex lists requires deleting old entries then adding new ones. + if update.fail_regex is not None: + await _replace_regex_list(client, name, "failregex", update.fail_regex) + if update.ignore_regex is not None: + await _replace_regex_list(client, name, "ignoreregex", update.ignore_regex) + + log.info("jail_config_updated", jail=name) + + +async def _replace_regex_list( + client: Fail2BanClient, + jail: str, + field: str, + new_patterns: list[str], +) -> None: + """Replace the full regex list for *field* in *jail*. + + Deletes all existing entries (highest index first to preserve ordering) + then inserts all *new_patterns* in order. + + Args: + client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`. + jail: Jail name. + field: Either ``"failregex"`` or ``"ignoreregex"``. + new_patterns: Replacement list (may be empty to clear). + """ + # Determine current count. + current_raw = await _safe_get(client, ["get", jail, field], []) + current: list[str] = _ensure_list(current_raw) + + del_cmd = f"del{field}" + add_cmd = f"add{field}" + + # Delete in reverse order so indices stay stable. + for idx in range(len(current) - 1, -1, -1): + with contextlib.suppress(ValueError): + _ok(await client.send(["set", jail, del_cmd, idx])) + + # Add new patterns. + for pattern in new_patterns: + err = _validate_regex(pattern) + if err: + raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})") + try: + _ok(await client.send(["set", jail, add_cmd, pattern])) + except ValueError as exc: + raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc + + +# --------------------------------------------------------------------------- +# Public API — global configuration +# --------------------------------------------------------------------------- + + +async def get_global_config(socket_path: str) -> GlobalConfigResponse: + """Return fail2ban global configuration settings. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.GlobalConfigResponse`. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + ( + log_level_raw, + log_target_raw, + db_purge_age_raw, + db_max_matches_raw, + ) = await asyncio.gather( + _safe_get(client, ["get", "loglevel"], "INFO"), + _safe_get(client, ["get", "logtarget"], "STDOUT"), + _safe_get(client, ["get", "dbpurgeage"], 86400), + _safe_get(client, ["get", "dbmaxmatches"], 10), + ) + + return GlobalConfigResponse( + log_level=str(log_level_raw or "INFO").upper(), + log_target=str(log_target_raw or "STDOUT"), + db_purge_age=int(db_purge_age_raw or 86400), + db_max_matches=int(db_max_matches_raw or 10), + ) + + +async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> None: + """Apply *update* to fail2ban global settings. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + update: Partial update payload. + + Raises: + ConfigOperationError: If a ``set`` command is rejected. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + async def _set_global(key: str, value: Any) -> None: + try: + _ok(await client.send(["set", key, value])) + except ValueError as exc: + raise ConfigOperationError(f"Failed to set global {key!r} = {value!r}: {exc}") from exc + + if update.log_level is not None: + await _set_global("loglevel", update.log_level.upper()) + if update.log_target is not None: + await _set_global("logtarget", update.log_target) + if update.db_purge_age is not None: + await _set_global("dbpurgeage", update.db_purge_age) + if update.db_max_matches is not None: + await _set_global("dbmaxmatches", update.db_max_matches) + + log.info("global_config_updated") + + +# --------------------------------------------------------------------------- +# Public API — regex tester (stateless, no socket) +# --------------------------------------------------------------------------- + + +def test_regex(request: RegexTestRequest) -> RegexTestResponse: + """Test a regex pattern against a sample log line. + + This is a pure in-process operation — no socket communication occurs. + + Args: + request: The :class:`~app.models.config.RegexTestRequest` payload. + + Returns: + :class:`~app.models.config.RegexTestResponse` with match result. + """ + try: + compiled = re.compile(request.fail_regex) + except re.error as exc: + return RegexTestResponse(matched=False, groups=[], error=str(exc)) + + match = compiled.search(request.log_line) + if match is None: + return RegexTestResponse(matched=False) + + groups: list[str] = list(match.groups() or []) + return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None]) + + +# --------------------------------------------------------------------------- +# Public API — log observation +# --------------------------------------------------------------------------- + + +async def add_log_path( + socket_path: str, + jail: str, + req: AddLogPathRequest, +) -> None: + """Add a log path to an existing jail. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + jail: Jail name to which the log path should be added. + req: :class:`~app.models.config.AddLogPathRequest` with the path to add. + + Raises: + JailNotFoundError: If *jail* is not a known jail. + ConfigOperationError: If the command is rejected by fail2ban. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + try: + _ok(await client.send(["status", jail, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(jail) from exc + raise + + tail_flag = "tail" if req.tail else "head" + try: + _ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag])) + log.info("log_path_added", jail=jail, path=req.log_path) + except ValueError as exc: + raise ConfigOperationError(f"Failed to add log path {req.log_path!r}: {exc}") from exc + + +async def delete_log_path( + socket_path: str, + jail: str, + log_path: str, +) -> None: + """Remove a monitored log path from an existing jail. + + Uses ``set dellogpath `` to remove the path at runtime + without requiring a daemon restart. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + jail: Jail name from which the log path should be removed. + log_path: Absolute path of the log file to stop monitoring. + + Raises: + JailNotFoundError: If *jail* is not a known jail. + ConfigOperationError: If the command is rejected by fail2ban. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + try: + _ok(await client.send(["status", jail, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(jail) from exc + raise + + try: + _ok(await client.send(["set", jail, "dellogpath", log_path])) + log.info("log_path_deleted", jail=jail, path=log_path) + except ValueError as exc: + raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc + + +async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse: + """Read the last *num_lines* of a log file and test *fail_regex* against each. + + This operation reads from the local filesystem — no socket is used. + + Args: + req: :class:`~app.models.config.LogPreviewRequest`. + + Returns: + :class:`~app.models.config.LogPreviewResponse` with line-by-line results. + """ + # Validate the regex first. + try: + compiled = re.compile(req.fail_regex) + except re.error as exc: + return LogPreviewResponse( + lines=[], + total_lines=0, + matched_count=0, + regex_error=str(exc), + ) + + path = Path(req.log_path) + if not path.is_file(): + return LogPreviewResponse( + lines=[], + total_lines=0, + matched_count=0, + regex_error=f"File not found: {req.log_path!r}", + ) + + # Read the last num_lines lines efficiently. + try: + raw_lines = await asyncio.get_event_loop().run_in_executor( + None, + _read_tail_lines, + str(path), + req.num_lines, + ) + except OSError as exc: + return LogPreviewResponse( + lines=[], + total_lines=0, + matched_count=0, + regex_error=f"Cannot read file: {exc}", + ) + + result_lines: list[LogPreviewLine] = [] + matched_count = 0 + for line in raw_lines: + m = compiled.search(line) + groups = [str(g) for g in (m.groups() or []) if g is not None] if m else [] + result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups)) + if m: + matched_count += 1 + + return LogPreviewResponse( + lines=result_lines, + total_lines=len(result_lines), + matched_count=matched_count, + ) + + +def _read_tail_lines(file_path: str, num_lines: int) -> list[str]: + """Read the last *num_lines* from *file_path* synchronously. + + Uses a memory-efficient approach that seeks from the end of the file. + + Args: + file_path: Absolute path to the log file. + num_lines: Number of lines to return. + + Returns: + A list of stripped line strings. + """ + chunk_size = 8192 + raw_lines: list[bytes] = [] + with open(file_path, "rb") as fh: + fh.seek(0, 2) # seek to end + end_pos = fh.tell() + if end_pos == 0: + return [] + buf = b"" + pos = end_pos + while len(raw_lines) <= num_lines and pos > 0: + read_size = min(chunk_size, pos) + pos -= read_size + fh.seek(pos) + chunk = fh.read(read_size) + buf = chunk + buf + raw_lines = buf.split(b"\n") + # Strip incomplete leading line unless we've read the whole file. + if pos > 0 and len(raw_lines) > 1: + raw_lines = raw_lines[1:] + return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()] + + +# --------------------------------------------------------------------------- +# Map color thresholds +# --------------------------------------------------------------------------- + + +async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThresholdsResponse: + """Retrieve the current map color threshold configuration. + + Args: + db: Active aiosqlite connection to the application database. + + Returns: + A :class:`MapColorThresholdsResponse` containing the three threshold values. + """ + high, medium, low = await setup_service.get_map_color_thresholds(db) + return MapColorThresholdsResponse( + threshold_high=high, + threshold_medium=medium, + threshold_low=low, + ) + + +async def update_map_color_thresholds( + db: aiosqlite.Connection, + update: MapColorThresholdsUpdate, +) -> None: + """Update the map color threshold configuration. + + Args: + db: Active aiosqlite connection to the application database. + update: The new threshold values. + + Raises: + ValueError: If validation fails (thresholds must satisfy high > medium > low). + """ + await setup_service.set_map_color_thresholds( + db, + threshold_high=update.threshold_high, + threshold_medium=update.threshold_medium, + threshold_low=update.threshold_low, + ) + + +# --------------------------------------------------------------------------- +# fail2ban log file reader +# --------------------------------------------------------------------------- + +# Log targets that are not file paths — log viewing is unavailable for these. +_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset( + {"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"} +) + +# Only allow reading log files under these base directories (security). +_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log") + + +def _count_file_lines(file_path: str) -> int: + """Count the total number of lines in *file_path* synchronously. + + Uses a memory-efficient buffered read to avoid loading the whole file. + + Args: + file_path: Absolute path to the file. + + Returns: + Total number of lines in the file. + """ + count = 0 + with open(file_path, "rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + count += chunk.count(b"\n") + return count + + +async def read_fail2ban_log( + socket_path: str, + lines: int, + filter_text: str | None = None, +) -> Fail2BanLogResponse: + """Read the tail of the fail2ban daemon log file. + + Queries the fail2ban socket for the current log target and log level, + validates that the target is a readable file, then returns the last + *lines* entries optionally filtered by *filter_text*. + + Security: the resolved log path is rejected unless it starts with one of + the paths in :data:`_SAFE_LOG_PREFIXES`, preventing path traversal. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + lines: Number of lines to return from the tail of the file (1–2000). + filter_text: Optional plain-text substring — only matching lines are + returned. Applied server-side; does not affect *total_lines*. + + Returns: + :class:`~app.models.config.Fail2BanLogResponse`. + + Raises: + ConfigOperationError: When the log target is not a file, when the + resolved path is outside the allowed directories, or when the + file cannot be read. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + log_level_raw, log_target_raw = await asyncio.gather( + _safe_get(client, ["get", "loglevel"], "INFO"), + _safe_get(client, ["get", "logtarget"], "STDOUT"), + ) + + log_level = str(log_level_raw or "INFO").upper() + log_target = str(log_target_raw or "STDOUT") + + # Reject non-file targets up front. + if log_target.upper() in _NON_FILE_LOG_TARGETS: + raise ConfigOperationError( + f"fail2ban is logging to {log_target!r}. " + "File-based log viewing is only available when fail2ban logs to a file path." + ) + + # Resolve and validate (security: no path traversal outside safe dirs). + try: + resolved = Path(log_target).resolve() + except (ValueError, OSError) as exc: + raise ConfigOperationError( + f"Cannot resolve log target path {log_target!r}: {exc}" + ) from exc + + resolved_str = str(resolved) + if not any(resolved_str.startswith(safe) for safe in _SAFE_LOG_PREFIXES): + raise ConfigOperationError( + f"Log path {resolved_str!r} is outside the allowed directory. " + "Only paths under /var/log or /config/log are permitted." + ) + + if not resolved.is_file(): + raise ConfigOperationError(f"Log file not found: {resolved_str!r}") + + loop = asyncio.get_event_loop() + + total_lines, raw_lines = await asyncio.gather( + loop.run_in_executor(None, _count_file_lines, resolved_str), + loop.run_in_executor(None, _read_tail_lines, resolved_str, lines), + ) + + filtered = ( + [ln for ln in raw_lines if filter_text in ln] + if filter_text + else raw_lines + ) + + log.info( + "fail2ban_log_read", + log_path=resolved_str, + lines_requested=lines, + lines_returned=len(filtered), + filter_active=filter_text is not None, + ) + + return Fail2BanLogResponse( + log_path=resolved_str, + lines=filtered, + total_lines=total_lines, + log_level=log_level, + log_target=log_target, + ) + + +async def get_service_status(socket_path: str) -> ServiceStatusResponse: + """Return fail2ban service health status with log configuration. + + Delegates to :func:`~app.services.health_service.probe` for the core + health snapshot and augments it with the current log-level and log-target + values from the socket. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.ServiceStatusResponse`. + """ + from app.services.health_service import probe # lazy import avoids circular dep + + server_status = await probe(socket_path) + + if server_status.online: + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + log_level_raw, log_target_raw = await asyncio.gather( + _safe_get(client, ["get", "loglevel"], "INFO"), + _safe_get(client, ["get", "logtarget"], "STDOUT"), + ) + log_level = str(log_level_raw or "INFO").upper() + log_target = str(log_target_raw or "STDOUT") + else: + log_level = "UNKNOWN" + log_target = "UNKNOWN" + + log.info( + "service_status_fetched", + online=server_status.online, + jail_count=server_status.active_jails, + ) + + return ServiceStatusResponse( + online=server_status.online, + version=server_status.version, + jail_count=server_status.active_jails, + total_bans=server_status.total_bans, + total_failures=server_status.total_failures, + log_level=log_level, + log_target=log_target, + ) diff --git a/backend/app/services/file_config_service.py b/backend/app/services/file_config_service.py new file mode 100644 index 0000000..271cbc8 --- /dev/null +++ b/backend/app/services/file_config_service.py @@ -0,0 +1,1011 @@ +"""File-based fail2ban configuration service. + +Provides functions to list, read, and write files in the fail2ban +configuration directory (``jail.d/``, ``filter.d/``, ``action.d/``). + +All file operations are synchronous (wrapped in +:func:`asyncio.get_event_loop().run_in_executor` by callers that need async +behaviour) because the config files are small and infrequently touched — the +overhead of async I/O is not warranted here. + +Security note: every path-related helper validates that the resolved path +stays strictly inside the configured config directory to prevent directory +traversal attacks. +""" + +from __future__ import annotations + +import asyncio +import configparser +import re +from pathlib import Path +from typing import TYPE_CHECKING + +import structlog + +from app.models.file_config import ( + ConfFileContent, + ConfFileCreateRequest, + ConfFileEntry, + ConfFilesResponse, + ConfFileUpdateRequest, + JailConfigFile, + JailConfigFileContent, + JailConfigFilesResponse, +) + +if TYPE_CHECKING: + from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + FilterConfig, + FilterConfigUpdate, + JailFileConfig, + JailFileConfigUpdate, + ) + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MAX_CONTENT_BYTES: int = 512 * 1024 # 512 KB – hard cap on file write size +_CONF_EXTENSIONS: tuple[str, str] = (".conf", ".local") + +# Allowed characters in a new file's base name. Tighter than the OS allows +# on purpose: alphanumeric, hyphen, underscore, dot (but not leading dot). +_SAFE_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class ConfigDirError(Exception): + """Raised when the fail2ban config directory is missing or inaccessible.""" + + +class ConfigFileNotFoundError(Exception): + """Raised when a requested config file does not exist.""" + + def __init__(self, filename: str) -> None: + """Initialise with the filename that was not found. + + Args: + filename: The filename that could not be located. + """ + self.filename = filename + super().__init__(f"Config file not found: {filename!r}") + + +class ConfigFileExistsError(Exception): + """Raised when trying to create a file that already exists.""" + + def __init__(self, filename: str) -> None: + """Initialise with the filename that already exists. + + Args: + filename: The filename that conflicts. + """ + self.filename = filename + super().__init__(f"Config file already exists: {filename!r}") + + +class ConfigFileWriteError(Exception): + """Raised when a file cannot be written (permissions, disk full, etc.).""" + + +class ConfigFileNameError(Exception): + """Raised when a supplied filename is invalid or unsafe.""" + + +# --------------------------------------------------------------------------- +# Internal path helpers +# --------------------------------------------------------------------------- + + +def _resolve_subdir(config_dir: str, subdir: str) -> Path: + """Resolve and return the path of *subdir* inside *config_dir*. + + Args: + config_dir: The top-level fail2ban config directory. + subdir: Subdirectory name (e.g. ``"jail.d"``). + + Returns: + Resolved :class:`~pathlib.Path` to the subdirectory. + + Raises: + ConfigDirError: If *config_dir* does not exist or is not a directory. + """ + base = Path(config_dir).resolve() + if not base.is_dir(): + raise ConfigDirError(f"fail2ban config directory not found: {config_dir!r}") + return base / subdir + + +def _assert_within(base: Path, target: Path) -> None: + """Raise :class:`ConfigFileNameError` if *target* is outside *base*. + + Args: + base: The allowed root directory (resolved). + target: The path to validate (resolved). + + Raises: + ConfigFileNameError: If *target* would escape *base*. + """ + try: + target.relative_to(base) + except ValueError as err: + raise ConfigFileNameError( + f"Path {str(target)!r} escapes config directory {str(base)!r}" + ) from err + + +def _validate_new_name(name: str) -> None: + """Validate a base name for a new config file. + + Args: + name: The proposed base name (without extension). + + Raises: + ConfigFileNameError: If *name* contains invalid characters or patterns. + """ + if not _SAFE_NAME_RE.match(name): + raise ConfigFileNameError( + f"Invalid config file name {name!r}. " + "Use only alphanumeric characters, hyphens, underscores, and dots; " + "must start with an alphanumeric character." + ) + + +def _validate_content(content: str) -> None: + """Reject content that exceeds the size limit. + + Args: + content: The proposed file content. + + Raises: + ConfigFileWriteError: If *content* exceeds :data:`_MAX_CONTENT_BYTES`. + """ + if len(content.encode("utf-8")) > _MAX_CONTENT_BYTES: + raise ConfigFileWriteError( + f"Content exceeds maximum allowed size of {_MAX_CONTENT_BYTES // 1024} KB." + ) + + +# --------------------------------------------------------------------------- +# Internal helpers — INI parsing / patching +# --------------------------------------------------------------------------- + + +def _parse_enabled(path: Path) -> bool: + """Return the ``enabled`` value for the primary section in *path*. + + Reads the INI file with :mod:`configparser` and looks for an ``enabled`` + key in the section whose name matches the file stem (or in ``DEFAULT``). + Returns ``True`` if the key is absent (fail2ban's own default). + + Args: + path: Path to a ``.conf`` or ``.local`` jail config file. + + Returns: + ``True`` if the jail is (or defaults to) enabled, ``False`` otherwise. + """ + cp = configparser.ConfigParser( + # Treat all keys case-insensitively; interpolation disabled because + # fail2ban uses %(variables)s which would confuse configparser. + interpolation=None, + ) + try: + cp.read(str(path), encoding="utf-8") + except configparser.Error: + return True # Unreadable files are treated as enabled (safe default). + + jail_name = path.stem + # Prefer the jail-specific section; fall back to DEFAULT. + for section in (jail_name, "DEFAULT"): + if cp.has_option(section, "enabled"): + raw = cp.get(section, "enabled").strip().lower() + return raw in ("true", "1", "yes") + return True + + +def _set_enabled_in_content(content: str, enabled: bool) -> str: + """Return *content* with the first ``enabled = …`` line replaced. + + If no ``enabled`` line exists, appends one to the last ``[section]`` block + found in the file. + + Args: + content: Current raw file content. + enabled: New value for the ``enabled`` key. + + Returns: + Modified file content as a string. + """ + value = "true" if enabled else "false" + # Try to replace an existing "enabled = ..." line (inside any section). + pattern = re.compile( + r"^(\s*enabled\s*=\s*).*$", + re.MULTILINE | re.IGNORECASE, + ) + if pattern.search(content): + return pattern.sub(rf"\g<1>{value}", content, count=1) + + # No existing enabled line. Find the last [section] header and append + # the enabled setting right after it. + section_pattern = re.compile(r"^\[([^\[\]]+)\]\s*$", re.MULTILINE) + matches = list(section_pattern.finditer(content)) + if matches: + # Insert after the last section header line. + last_match = matches[-1] + insert_pos = last_match.end() + return content[:insert_pos] + f"\nenabled = {value}" + content[insert_pos:] + + # No section found at all — prepend a minimal block. + return f"[DEFAULT]\nenabled = {value}\n\n" + content + + +# --------------------------------------------------------------------------- +# Public API — jail config files (Task 4a) +# --------------------------------------------------------------------------- + + +async def list_jail_config_files(config_dir: str) -> JailConfigFilesResponse: + """List all jail config files in ``/jail.d/``. + + Only ``.conf`` and ``.local`` files are returned. The ``enabled`` state + is parsed from each file's content. + + Args: + config_dir: Path to the fail2ban configuration directory. + + Returns: + :class:`~app.models.file_config.JailConfigFilesResponse`. + + Raises: + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> JailConfigFilesResponse: + jail_d = _resolve_subdir(config_dir, "jail.d") + if not jail_d.is_dir(): + log.warning("jail_d_not_found", config_dir=config_dir) + return JailConfigFilesResponse(files=[], total=0) + + files: list[JailConfigFile] = [] + for path in sorted(jail_d.iterdir()): + if not path.is_file(): + continue + if path.suffix not in _CONF_EXTENSIONS: + continue + _assert_within(jail_d.resolve(), path.resolve()) + files.append( + JailConfigFile( + name=path.stem, + filename=path.name, + enabled=_parse_enabled(path), + ) + ) + log.info("jail_config_files_listed", count=len(files)) + return JailConfigFilesResponse(files=files, total=len(files)) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_jail_config_file(config_dir: str, filename: str) -> JailConfigFileContent: + """Return the content and metadata of a single jail config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: The filename (e.g. ``sshd.conf``) — must end in ``.conf`` or ``.local``. + + Returns: + :class:`~app.models.file_config.JailConfigFileContent`. + + Raises: + ConfigFileNameError: If *filename* is unsafe. + ConfigFileNotFoundError: If the file does not exist. + ConfigDirError: If the config directory does not exist. + """ + + def _do() -> JailConfigFileContent: + jail_d = _resolve_subdir(config_dir, "jail.d").resolve() + if not jail_d.is_dir(): + raise ConfigFileNotFoundError(filename) + + path = (jail_d / filename).resolve() + _assert_within(jail_d, path) + if path.suffix not in _CONF_EXTENSIONS: + raise ConfigFileNameError( + f"Invalid file extension for {filename!r}. " + "Only .conf and .local files are supported." + ) + if not path.is_file(): + raise ConfigFileNotFoundError(filename) + + content = path.read_text(encoding="utf-8", errors="replace") + return JailConfigFileContent( + name=path.stem, + filename=path.name, + enabled=_parse_enabled(path), + content=content, + ) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def set_jail_config_enabled( + config_dir: str, + filename: str, + enabled: bool, +) -> None: + """Set the ``enabled`` flag in a jail config file. + + Reads the file, modifies (or inserts) the ``enabled`` key, and writes it + back. The update preserves all other content including comments. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: The filename (e.g. ``sshd.conf``). + enabled: New value for the ``enabled`` key. + + Raises: + ConfigFileNameError: If *filename* is unsafe. + ConfigFileNotFoundError: If the file does not exist. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If the config directory does not exist. + """ + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d").resolve() + if not jail_d.is_dir(): + raise ConfigFileNotFoundError(filename) + + path = (jail_d / filename).resolve() + _assert_within(jail_d, path) + if path.suffix not in _CONF_EXTENSIONS: + raise ConfigFileNameError( + f"Only .conf and .local files are supported, got {filename!r}." + ) + if not path.is_file(): + raise ConfigFileNotFoundError(filename) + + original = path.read_text(encoding="utf-8", errors="replace") + updated = _set_enabled_in_content(original, enabled) + try: + path.write_text(updated, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError( + f"Cannot write {filename!r}: {exc}" + ) from exc + log.info( + "jail_config_file_enabled_set", + filename=filename, + enabled=enabled, + ) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def create_jail_config_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new jail.d config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + jail_d = _resolve_subdir(config_dir, "jail.d") + filename = _create_conf_file(jail_d, req.name, req.content) + log.info("jail_config_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def write_jail_config_file( + config_dir: str, + filename: str, + req: ConfFileUpdateRequest, +) -> None: + """Overwrite an existing jail.d config file with new raw content. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``sshd.conf``). + req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new + content. + + Raises: + ConfigFileNotFoundError: If the file does not exist. + ConfigFileNameError: If *filename* is unsafe or has a bad extension. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d").resolve() + if not jail_d.is_dir(): + raise ConfigFileNotFoundError(filename) + path = (jail_d / filename).resolve() + _assert_within(jail_d, path) + if path.suffix not in _CONF_EXTENSIONS: + raise ConfigFileNameError( + f"Only .conf and .local files are supported, got {filename!r}." + ) + if not path.is_file(): + raise ConfigFileNotFoundError(filename) + try: + path.write_text(req.content, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError( + f"Cannot write {filename!r}: {exc}" + ) from exc + log.info("jail_config_file_written", filename=filename) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Internal helpers — generic conf file listing / reading / writing +# --------------------------------------------------------------------------- + + +def _list_conf_files(subdir: Path) -> ConfFilesResponse: + """List ``.conf`` and ``.local`` files in *subdir*. + + Args: + subdir: Resolved path to the directory to scan. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + """ + if not subdir.is_dir(): + return ConfFilesResponse(files=[], total=0) + + files: list[ConfFileEntry] = [] + for path in sorted(subdir.iterdir()): + if not path.is_file(): + continue + if path.suffix not in _CONF_EXTENSIONS: + continue + _assert_within(subdir.resolve(), path.resolve()) + files.append(ConfFileEntry(name=path.stem, filename=path.name)) + return ConfFilesResponse(files=files, total=len(files)) + + +def _read_conf_file(subdir: Path, name: str) -> ConfFileContent: + """Read a single conf file by base name. + + Args: + subdir: Resolved path to the containing directory. + name: Base name with optional extension. If no extension is given, + ``.conf`` is tried first, then ``.local``. + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + ConfigFileNameError: If *name* is unsafe. + ConfigFileNotFoundError: If no matching file is found. + """ + resolved_subdir = subdir.resolve() + # Accept names with or without extension. + if "." in name and not name.startswith("."): + candidates = [resolved_subdir / name] + else: + candidates = [resolved_subdir / (name + ext) for ext in _CONF_EXTENSIONS] + + for path in candidates: + resolved = path.resolve() + _assert_within(resolved_subdir, resolved) + if resolved.is_file(): + content = resolved.read_text(encoding="utf-8", errors="replace") + return ConfFileContent( + name=resolved.stem, + filename=resolved.name, + content=content, + ) + raise ConfigFileNotFoundError(name) + + +def _write_conf_file(subdir: Path, name: str, content: str) -> None: + """Overwrite or create a conf file. + + Args: + subdir: Resolved path to the containing directory. + name: Base name with optional extension. + content: New file content. + + Raises: + ConfigFileNameError: If *name* is unsafe. + ConfigFileNotFoundError: If *name* does not match an existing file + (use :func:`_create_conf_file` for new files). + ConfigFileWriteError: If the file cannot be written. + """ + resolved_subdir = subdir.resolve() + _validate_content(content) + + # Accept names with or without extension. + if "." in name and not name.startswith("."): + candidates = [resolved_subdir / name] + else: + candidates = [resolved_subdir / (name + ext) for ext in _CONF_EXTENSIONS] + + target: Path | None = None + for path in candidates: + resolved = path.resolve() + _assert_within(resolved_subdir, resolved) + if resolved.is_file(): + target = resolved + break + + if target is None: + raise ConfigFileNotFoundError(name) + + try: + target.write_text(content, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError(f"Cannot write {name!r}: {exc}") from exc + + +def _create_conf_file(subdir: Path, name: str, content: str) -> str: + """Create a new ``.conf`` file in *subdir*. + + Args: + subdir: Resolved path to the containing directory. + name: Base name for the new file (without extension). + content: Initial file content. + + Returns: + The filename that was created (e.g. ``myfilter.conf``). + + Raises: + ConfigFileNameError: If *name* is invalid. + ConfigFileExistsError: If a ``.conf`` or ``.local`` file with *name* already exists. + ConfigFileWriteError: If the file cannot be written. + """ + resolved_subdir = subdir.resolve() + _validate_new_name(name) + _validate_content(content) + + for ext in _CONF_EXTENSIONS: + existing = (resolved_subdir / (name + ext)).resolve() + _assert_within(resolved_subdir, existing) + if existing.exists(): + raise ConfigFileExistsError(name + ext) + + target = (resolved_subdir / (name + ".conf")).resolve() + _assert_within(resolved_subdir, target) + try: + target.write_text(content, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError(f"Cannot create {name!r}: {exc}") from exc + + return target.name + + +# --------------------------------------------------------------------------- +# Public API — filter files (Task 4d) +# --------------------------------------------------------------------------- + + +async def list_filter_files(config_dir: str) -> ConfFilesResponse: + """List all filter definition files in ``/filter.d/``. + + Args: + config_dir: Path to the fail2ban configuration directory. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + + Raises: + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFilesResponse: + filter_d = _resolve_subdir(config_dir, "filter.d") + result = _list_conf_files(filter_d) + log.info("filter_files_listed", count=result.total) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_filter_file(config_dir: str, name: str) -> ConfFileContent: + """Return the content of a filter definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name (with or without ``.conf``/``.local`` extension). + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFileContent: + filter_d = _resolve_subdir(config_dir, "filter.d") + return _read_conf_file(filter_d, name) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def write_filter_file( + config_dir: str, + name: str, + req: ConfFileUpdateRequest, +) -> None: + """Overwrite an existing filter definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update (with or without extension). + req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new content. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> None: + filter_d = _resolve_subdir(config_dir, "filter.d") + _write_conf_file(filter_d, name, req.content) + log.info("filter_file_written", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def create_filter_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new filter definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + filter_d = _resolve_subdir(config_dir, "filter.d") + filename = _create_conf_file(filter_d, req.name, req.content) + log.info("filter_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — action files (Task 4e) +# --------------------------------------------------------------------------- + + +async def list_action_files(config_dir: str) -> ConfFilesResponse: + """List all action definition files in ``/action.d/``. + + Args: + config_dir: Path to the fail2ban configuration directory. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + + Raises: + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFilesResponse: + action_d = _resolve_subdir(config_dir, "action.d") + result = _list_conf_files(action_d) + log.info("action_files_listed", count=result.total) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_action_file(config_dir: str, name: str) -> ConfFileContent: + """Return the content of an action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name (with or without ``.conf``/``.local`` extension). + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFileContent: + action_d = _resolve_subdir(config_dir, "action.d") + return _read_conf_file(action_d, name) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def write_action_file( + config_dir: str, + name: str, + req: ConfFileUpdateRequest, +) -> None: + """Overwrite an existing action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new content. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> None: + action_d = _resolve_subdir(config_dir, "action.d") + _write_conf_file(action_d, name, req.content) + log.info("action_file_written", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def create_action_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + action_d = _resolve_subdir(config_dir, "action.d") + filename = _create_conf_file(action_d, req.name, req.content) + log.info("action_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — structured (parsed) filter files (Task 2.1) +# --------------------------------------------------------------------------- + + +async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig: + """Parse a filter definition file and return its structured representation. + + Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with + :func:`~app.services.conffile_parser.parse_filter_file`, and returns the + result. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name with or without extension. + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import parse_filter_file # avoid circular imports + + def _do() -> FilterConfig: + filter_d = _resolve_subdir(config_dir, "filter.d") + raw = _read_conf_file(filter_d, name) + result = parse_filter_file(raw.content, name=raw.name, filename=raw.filename) + log.debug("filter_file_parsed", name=raw.name) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_filter_file( + config_dir: str, + name: str, + update: FilterConfigUpdate, +) -> None: + """Apply a structured partial update to a filter definition file. + + Reads the existing file, merges *update* onto it, serializes to INI format, + and writes the result back to disk. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import ( # avoid circular imports + merge_filter_update, + parse_filter_file, + serialize_filter_config, + ) + + def _do() -> None: + filter_d = _resolve_subdir(config_dir, "filter.d") + raw = _read_conf_file(filter_d, name) + current = parse_filter_file(raw.content, name=raw.name, filename=raw.filename) + merged = merge_filter_update(current, update) + new_content = serialize_filter_config(merged) + _validate_content(new_content) + _write_conf_file(filter_d, name, new_content) + log.info("filter_file_updated_parsed", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — structured (parsed) action files (Task 3.1) +# --------------------------------------------------------------------------- + + +async def get_parsed_action_file(config_dir: str, name: str) -> ActionConfig: + """Parse an action definition file and return its structured representation. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name with or without extension. + + Returns: + :class:`~app.models.config.ActionConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import parse_action_file # avoid circular imports + + def _do() -> ActionConfig: + action_d = _resolve_subdir(config_dir, "action.d") + raw = _read_conf_file(action_d, name) + result = parse_action_file(raw.content, name=raw.name, filename=raw.filename) + log.debug("action_file_parsed", name=raw.name) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_action_file( + config_dir: str, + name: str, + update: ActionConfigUpdate, +) -> None: + """Apply a structured partial update to an action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import ( # avoid circular imports + merge_action_update, + parse_action_file, + serialize_action_config, + ) + + def _do() -> None: + action_d = _resolve_subdir(config_dir, "action.d") + raw = _read_conf_file(action_d, name) + current = parse_action_file(raw.content, name=raw.name, filename=raw.filename) + merged = merge_action_update(current, update) + new_content = serialize_action_config(merged) + _validate_content(new_content) + _write_conf_file(action_d, name, new_content) + log.info("action_file_updated_parsed", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_parsed_jail_file(config_dir: str, filename: str) -> JailFileConfig: + """Parse a jail.d config file into a structured :class:`~app.models.config.JailFileConfig`. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``"sshd.conf"``). + + Returns: + :class:`~app.models.config.JailFileConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import parse_jail_file # avoid circular imports + + def _do() -> JailFileConfig: + jail_d = _resolve_subdir(config_dir, "jail.d") + raw = _read_conf_file(jail_d, filename) + result = parse_jail_file(raw.content, filename=raw.filename) + log.debug("jail_file_parsed", filename=raw.filename) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_jail_file( + config_dir: str, + filename: str, + update: JailFileConfigUpdate, +) -> None: + """Apply a structured partial update to a jail.d config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``"sshd.conf"``). + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.services.conffile_parser import ( # avoid circular imports + merge_jail_file_update, + parse_jail_file, + serialize_jail_file_config, + ) + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d") + raw = _read_conf_file(jail_d, filename) + current = parse_jail_file(raw.content, filename=raw.filename) + merged = merge_jail_file_update(current, update) + new_content = serialize_jail_file_config(merged) + _validate_content(new_content) + _write_conf_file(jail_d, filename, new_content) + log.info("jail_file_updated_parsed", filename=filename) + + await asyncio.get_event_loop().run_in_executor(None, _do) diff --git a/backend/app/services/geo_service.py b/backend/app/services/geo_service.py new file mode 100644 index 0000000..325517e --- /dev/null +++ b/backend/app/services/geo_service.py @@ -0,0 +1,816 @@ +"""Geo service. + +Resolves IP addresses to their country, ASN, and organisation using the +`ip-api.com `_ JSON API. Results are cached in two tiers: + +1. **In-memory dict** — fastest; survives for the life of the process. +2. **Persistent SQLite table** (``geo_cache``) — survives restarts; loaded + into the in-memory dict during application startup via + :func:`load_cache_from_db`. + +Only *successful* lookups (those returning a non-``None`` ``country_code``) +are written to the persistent cache. Failed lookups are **not** cached so +they will be retried on the next request. + +For bulk operations the batch endpoint ``http://ip-api.com/batch`` is used +(up to 100 IPs per HTTP call) which is far more efficient than one-at-a-time +requests. Use :func:`lookup_batch` from the ban or blocklist services. + +Usage:: + + import aiohttp + import aiosqlite + from app.services import geo_service + + # warm the cache from the persistent store at startup + async with aiosqlite.connect("bangui.db") as db: + await geo_service.load_cache_from_db(db) + + async with aiohttp.ClientSession() as session: + # single lookup + info = await geo_service.lookup("1.2.3.4", session) + if info: + print(info.country_code) # "DE" + + # bulk lookup (more efficient for large sets) + geo_map = await geo_service.lookup_batch(["1.2.3.4", "5.6.7.8"], session) +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import aiohttp +import structlog + +if TYPE_CHECKING: + import aiosqlite + import geoip2.database + import geoip2.errors + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +#: ip-api.com single-IP lookup endpoint (HTTP only on the free tier). +_API_URL: str = ( + "http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,org,as" +) + +#: ip-api.com batch endpoint — accepts up to 100 IPs per POST. +_BATCH_API_URL: str = ( + "http://ip-api.com/batch?fields=status,message,country,countryCode,org,as,query" +) + +#: Maximum IPs per batch request (ip-api.com hard limit is 100). +_BATCH_SIZE: int = 100 + +#: Maximum number of entries kept in the in-process cache before it is +#: flushed completely. A simple eviction strategy — the cache is cheap to +#: rebuild from the persistent store. +_MAX_CACHE_SIZE: int = 50_000 + +#: Timeout for outgoing geo API requests in seconds. +_REQUEST_TIMEOUT: float = 5.0 + +#: How many seconds a failed lookup result is suppressed before the IP is +#: eligible for a new API attempt. Default: 5 minutes. +_NEG_CACHE_TTL: float = 300.0 + +#: Minimum delay in seconds between consecutive batch HTTP requests to +#: ip-api.com. The free tier allows 45 requests/min; 1.5 s ≈ 40 req/min. +_BATCH_DELAY: float = 1.5 + +#: Maximum number of retries for a batch chunk that fails with a +#: transient error (e.g. connection reset due to rate limiting). +_BATCH_MAX_RETRIES: int = 2 + +# --------------------------------------------------------------------------- +# Domain model +# --------------------------------------------------------------------------- + + +@dataclass +class GeoInfo: + """Geographical and network metadata for a single IP address. + + All fields default to ``None`` when the information is unavailable or + the lookup fails gracefully. + """ + + country_code: str | None + """ISO 3166-1 alpha-2 country code, e.g. ``"DE"``.""" + + country_name: str | None + """Human-readable country name, e.g. ``"Germany"``.""" + + asn: str | None + """Autonomous System Number string, e.g. ``"AS3320"``.""" + + org: str | None + """Organisation name associated with the IP, e.g. ``"Deutsche Telekom"``.""" + + +# --------------------------------------------------------------------------- +# Internal cache +# --------------------------------------------------------------------------- + +#: Module-level in-memory cache: ``ip → GeoInfo`` (positive results only). +_cache: dict[str, GeoInfo] = {} + +#: Negative cache: ``ip → epoch timestamp`` of last failed lookup attempt. +#: Entries within :data:`_NEG_CACHE_TTL` seconds are not re-queried. +_neg_cache: dict[str, float] = {} + +#: IPs added to :data:`_cache` but not yet persisted to the database. +#: Consumed and cleared atomically by :func:`flush_dirty`. +_dirty: set[str] = set() + +#: Optional MaxMind GeoLite2 reader initialised by :func:`init_geoip`. +_geoip_reader: geoip2.database.Reader | None = None + + +def clear_cache() -> None: + """Flush both the positive and negative lookup caches. + + Also clears the dirty set so any pending-but-unpersisted entries are + discarded. Useful in tests and when the operator suspects stale data. + """ + _cache.clear() + _neg_cache.clear() + _dirty.clear() + + +def clear_neg_cache() -> None: + """Flush only the negative (failed-lookups) cache. + + Useful when triggering a manual re-resolve so that previously failed + IPs are immediately eligible for a new API attempt. + """ + _neg_cache.clear() + + +def is_cached(ip: str) -> bool: + """Return ``True`` if *ip* has a positive entry in the in-memory cache. + + A positive entry is one with a non-``None`` ``country_code``. This is + useful for skipping IPs that have already been resolved when building + a list for :func:`lookup_batch`. + + Args: + ip: IPv4 or IPv6 address string. + + Returns: + ``True`` when *ip* is in the cache with a known country code. + """ + return ip in _cache and _cache[ip].country_code is not None + + +async def cache_stats(db: aiosqlite.Connection) -> dict[str, int]: + """Return diagnostic counters for the geo cache subsystem. + + Queries the persistent store for the number of unresolved entries and + combines it with in-memory counters. + + Args: + db: Open BanGUI application database connection. + + Returns: + Dict with keys ``cache_size``, ``unresolved``, ``neg_cache_size``, + and ``dirty_size``. + """ + async with db.execute( + "SELECT COUNT(*) FROM geo_cache WHERE country_code IS NULL" + ) as cur: + row = await cur.fetchone() + unresolved: int = int(row[0]) if row else 0 + + return { + "cache_size": len(_cache), + "unresolved": unresolved, + "neg_cache_size": len(_neg_cache), + "dirty_size": len(_dirty), + } + + +def init_geoip(mmdb_path: str | None) -> None: + """Initialise the MaxMind GeoLite2-Country database reader. + + If *mmdb_path* is ``None``, empty, or the file does not exist the + fallback is silently disabled — ip-api.com remains the sole resolver. + + Args: + mmdb_path: Absolute path to a ``GeoLite2-Country.mmdb`` file. + """ + global _geoip_reader # noqa: PLW0603 + if not mmdb_path: + return + from pathlib import Path # noqa: PLC0415 + + import geoip2.database # noqa: PLC0415 + + if not Path(mmdb_path).is_file(): + log.warning("geoip_mmdb_not_found", path=mmdb_path) + return + _geoip_reader = geoip2.database.Reader(mmdb_path) + log.info("geoip_mmdb_loaded", path=mmdb_path) + + +def _geoip_lookup(ip: str) -> GeoInfo | None: + """Attempt a local MaxMind GeoLite2 lookup for *ip*. + + Returns ``None`` when the reader is not initialised, the IP is not in + the database, or any other error occurs. + + Args: + ip: IPv4 or IPv6 address string. + + Returns: + A :class:`GeoInfo` with at least ``country_code`` populated, or + ``None`` when resolution is impossible. + """ + if _geoip_reader is None: + return None + import geoip2.errors # noqa: PLC0415 + + try: + response = _geoip_reader.country(ip) + code: str | None = response.country.iso_code or None + name: str | None = response.country.name or None + if code is None: + return None + return GeoInfo(country_code=code, country_name=name, asn=None, org=None) + except geoip2.errors.AddressNotFoundError: + return None + except Exception as exc: # noqa: BLE001 + log.warning("geoip_lookup_failed", ip=ip, error=str(exc)) + return None + + +# --------------------------------------------------------------------------- +# Persistent cache I/O +# --------------------------------------------------------------------------- + + +async def load_cache_from_db(db: aiosqlite.Connection) -> None: + """Pre-populate the in-memory cache from the ``geo_cache`` table. + + Should be called once during application startup so the service starts + with a warm cache instead of making cold API calls on the first request. + + Args: + db: Open :class:`aiosqlite.Connection` to the BanGUI application + database (not the fail2ban database). + """ + count = 0 + async with db.execute( + "SELECT ip, country_code, country_name, asn, org FROM geo_cache" + ) as cur: + async for row in cur: + ip: str = str(row[0]) + country_code: str | None = row[1] + if country_code is None: + continue + _cache[ip] = GeoInfo( + country_code=country_code, + country_name=row[2], + asn=row[3], + org=row[4], + ) + count += 1 + log.info("geo_cache_loaded_from_db", entries=count) + + +async def _persist_entry( + db: aiosqlite.Connection, + ip: str, + info: GeoInfo, +) -> None: + """Upsert a resolved :class:`GeoInfo` into the ``geo_cache`` table. + + Only called when ``info.country_code`` is not ``None`` so the persistent + store never contains empty placeholder rows. + + Args: + db: BanGUI application database connection. + ip: IP address string. + info: Resolved geo data to persist. + """ + await db.execute( + """ + INSERT INTO geo_cache (ip, country_code, country_name, asn, org) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + country_code = excluded.country_code, + country_name = excluded.country_name, + asn = excluded.asn, + org = excluded.org, + cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + """, + (ip, info.country_code, info.country_name, info.asn, info.org), + ) + + +async def _persist_neg_entry(db: aiosqlite.Connection, ip: str) -> None: + """Record a failed lookup attempt in ``geo_cache`` with all-NULL fields. + + Uses ``INSERT OR IGNORE`` so that an existing *positive* entry (one that + has a ``country_code``) is never overwritten by a later failure. + + Args: + db: BanGUI application database connection. + ip: IP address string whose resolution failed. + """ + await db.execute( + "INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", + (ip,), + ) + + +# --------------------------------------------------------------------------- +# Public API — single lookup +# --------------------------------------------------------------------------- + + +async def lookup( + ip: str, + http_session: aiohttp.ClientSession, + db: aiosqlite.Connection | None = None, +) -> GeoInfo | None: + """Resolve an IP address to country, ASN, and organisation metadata. + + Results are cached in-process. If the cache exceeds ``_MAX_CACHE_SIZE`` + entries it is flushed before the new result is stored. + + Only successful resolutions (``country_code is not None``) are written to + the persistent cache when *db* is provided. Failed lookups are **not** + cached so they are retried on the next call. + + Args: + ip: IPv4 or IPv6 address string. + http_session: Shared :class:`aiohttp.ClientSession` (from + ``app.state.http_session``). + db: Optional BanGUI application database. When provided, successful + lookups are persisted for cross-restart cache warming. + + Returns: + A :class:`GeoInfo` instance, or ``None`` when the lookup fails + in a way that should prevent the caller from caching a bad result + (e.g. network timeout). + """ + if ip in _cache: + return _cache[ip] + + # Negative cache: skip IPs that recently failed to avoid hammering the API. + neg_ts = _neg_cache.get(ip) + if neg_ts is not None and (time.monotonic() - neg_ts) < _NEG_CACHE_TTL: + return GeoInfo(country_code=None, country_name=None, asn=None, org=None) + + url: str = _API_URL.format(ip=ip) + api_ok = False + try: + async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)) as resp: + if resp.status != 200: + log.warning("geo_lookup_non_200", ip=ip, status=resp.status) + else: + data: dict[str, object] = await resp.json(content_type=None) + if data.get("status") == "success": + api_ok = True + result = _parse_single_response(data) + _store(ip, result) + if result.country_code is not None and db is not None: + try: + await _persist_entry(db, ip, result) + await db.commit() + except Exception as exc: # noqa: BLE001 + log.warning("geo_persist_failed", ip=ip, error=str(exc)) + log.debug("geo_lookup_success", ip=ip, country=result.country_code, asn=result.asn) + return result + log.debug( + "geo_lookup_failed", + ip=ip, + message=data.get("message", "unknown"), + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "geo_lookup_request_failed", + ip=ip, + exc_type=type(exc).__name__, + error=repr(exc), + ) + + if not api_ok: + # Try local MaxMind database as fallback. + fallback = _geoip_lookup(ip) + if fallback is not None: + _store(ip, fallback) + if fallback.country_code is not None and db is not None: + try: + await _persist_entry(db, ip, fallback) + await db.commit() + except Exception as exc: # noqa: BLE001 + log.warning("geo_persist_failed", ip=ip, error=str(exc)) + log.debug("geo_geoip_fallback_success", ip=ip, country=fallback.country_code) + return fallback + + # Both resolvers failed — record in negative cache to avoid hammering. + _neg_cache[ip] = time.monotonic() + if db is not None: + try: + await _persist_neg_entry(db, ip) + await db.commit() + except Exception as exc: # noqa: BLE001 + log.warning("geo_persist_neg_failed", ip=ip, error=str(exc)) + + return GeoInfo(country_code=None, country_name=None, asn=None, org=None) + + +# --------------------------------------------------------------------------- +# Public API — batch lookup +# --------------------------------------------------------------------------- + + +def lookup_cached_only( + ips: list[str], +) -> tuple[dict[str, GeoInfo], list[str]]: + """Return cached geo data for *ips* without making any external API calls. + + Used by callers that want to return a fast response using only what is + already in memory, while deferring resolution of uncached IPs to a + background task. + + Args: + ips: IP address strings to look up. + + Returns: + A ``(geo_map, uncached)`` tuple where *geo_map* maps every IP that + was already in the in-memory cache to its :class:`GeoInfo`, and + *uncached* is the list of IPs that were not found in the cache. + Entries in the negative cache (recently failed) are **not** included + in *uncached* so they are not re-queued immediately. + """ + geo_map: dict[str, GeoInfo] = {} + uncached: list[str] = [] + now = time.monotonic() + + for ip in dict.fromkeys(ips): # deduplicate, preserve order + if ip in _cache: + geo_map[ip] = _cache[ip] + elif ip in _neg_cache and (now - _neg_cache[ip]) < _NEG_CACHE_TTL: + # Still within the cool-down window — do not re-queue. + pass + else: + uncached.append(ip) + + return geo_map, uncached + + +async def lookup_batch( + ips: list[str], + http_session: aiohttp.ClientSession, + db: aiosqlite.Connection | None = None, +) -> dict[str, GeoInfo]: + """Resolve multiple IP addresses in bulk using ip-api.com batch endpoint. + + IPs already present in the in-memory cache are returned immediately + without making an HTTP request. Uncached IPs are sent to + ``http://ip-api.com/batch`` in chunks of up to :data:`_BATCH_SIZE`. + + Only successful resolutions (``country_code is not None``) are written to + the persistent cache when *db* is provided. Both positive and negative + entries are written in bulk using ``executemany`` (one round-trip per + chunk) rather than one ``execute`` per IP. + + Args: + ips: List of IP address strings to resolve. Duplicates are ignored. + http_session: Shared :class:`aiohttp.ClientSession`. + db: Optional BanGUI application database for persistent cache writes. + + Returns: + Dict mapping ``ip → GeoInfo`` for every input IP. IPs whose + resolution failed will have a ``GeoInfo`` with all-``None`` fields. + """ + geo_result: dict[str, GeoInfo] = {} + uncached: list[str] = [] + _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None) + + unique_ips = list(dict.fromkeys(ips)) # deduplicate, preserve order + now = time.monotonic() + for ip in unique_ips: + if ip in _cache: + geo_result[ip] = _cache[ip] + elif ip in _neg_cache and (now - _neg_cache[ip]) < _NEG_CACHE_TTL: + # Recently failed — skip API call, return empty result. + geo_result[ip] = _empty + else: + uncached.append(ip) + + if not uncached: + return geo_result + + log.info("geo_batch_lookup_start", total=len(uncached)) + + for batch_idx, chunk_start in enumerate(range(0, len(uncached), _BATCH_SIZE)): + chunk = uncached[chunk_start : chunk_start + _BATCH_SIZE] + + # Throttle: pause between consecutive HTTP calls to stay within the + # ip-api.com free-tier rate limit (45 req/min). + if batch_idx > 0: + await asyncio.sleep(_BATCH_DELAY) + + # Retry transient failures (e.g. connection-reset from rate limit). + chunk_result: dict[str, GeoInfo] | None = None + for attempt in range(_BATCH_MAX_RETRIES + 1): + chunk_result = await _batch_api_call(chunk, http_session) + # If every IP in the chunk came back with country_code=None and the + # batch wasn't tiny, that almost certainly means the whole request + # was rejected (connection reset / 429). Retry after a back-off. + all_failed = all( + info.country_code is None for info in chunk_result.values() + ) + if not all_failed or attempt >= _BATCH_MAX_RETRIES: + break + backoff = _BATCH_DELAY * (2 ** (attempt + 1)) + log.warning( + "geo_batch_retry", + attempt=attempt + 1, + chunk_size=len(chunk), + backoff=backoff, + ) + await asyncio.sleep(backoff) + + assert chunk_result is not None # noqa: S101 + + # Collect bulk-write rows instead of one execute per IP. + pos_rows: list[tuple[str, str | None, str | None, str | None, str | None]] = [] + neg_ips: list[str] = [] + + for ip, info in chunk_result.items(): + if info.country_code is not None: + # Successful API resolution. + _store(ip, info) + geo_result[ip] = info + if db is not None: + pos_rows.append( + (ip, info.country_code, info.country_name, info.asn, info.org) + ) + else: + # API failed — try local GeoIP fallback. + fallback = _geoip_lookup(ip) + if fallback is not None: + _store(ip, fallback) + geo_result[ip] = fallback + if db is not None: + pos_rows.append( + ( + ip, + fallback.country_code, + fallback.country_name, + fallback.asn, + fallback.org, + ) + ) + else: + # Both resolvers failed — record in negative cache. + _neg_cache[ip] = time.monotonic() + geo_result[ip] = _empty + if db is not None: + neg_ips.append(ip) + + if db is not None: + if pos_rows: + try: + await db.executemany( + """ + INSERT INTO geo_cache (ip, country_code, country_name, asn, org) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + country_code = excluded.country_code, + country_name = excluded.country_name, + asn = excluded.asn, + org = excluded.org, + cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + """, + pos_rows, + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "geo_batch_persist_failed", + count=len(pos_rows), + error=str(exc), + ) + if neg_ips: + try: + await db.executemany( + "INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", + [(ip,) for ip in neg_ips], + ) + except Exception as exc: # noqa: BLE001 + log.warning( + "geo_batch_persist_neg_failed", + count=len(neg_ips), + error=str(exc), + ) + + if db is not None: + try: + await db.commit() + except Exception as exc: # noqa: BLE001 + log.warning("geo_batch_commit_failed", error=str(exc)) + + log.info( + "geo_batch_lookup_complete", + requested=len(uncached), + resolved=sum(1 for g in geo_result.values() if g.country_code is not None), + ) + return geo_result + + +async def _batch_api_call( + ips: list[str], + http_session: aiohttp.ClientSession, +) -> dict[str, GeoInfo]: + """Send one batch request to the ip-api.com batch endpoint. + + Args: + ips: Up to :data:`_BATCH_SIZE` IP address strings. + http_session: Shared HTTP session. + + Returns: + Dict mapping ``ip → GeoInfo`` for every IP in *ips*. IPs where the + API returned a failure record or the request raised an exception get + an all-``None`` :class:`GeoInfo`. + """ + empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None) + fallback: dict[str, GeoInfo] = dict.fromkeys(ips, empty) + + payload = [{"query": ip} for ip in ips] + try: + async with http_session.post( + _BATCH_API_URL, + json=payload, + timeout=aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT * 2), + ) as resp: + if resp.status != 200: + log.warning("geo_batch_non_200", status=resp.status, count=len(ips)) + return fallback + data: list[dict[str, object]] = await resp.json(content_type=None) + except Exception as exc: # noqa: BLE001 + log.warning( + "geo_batch_request_failed", + count=len(ips), + exc_type=type(exc).__name__, + error=repr(exc), + ) + return fallback + + out: dict[str, GeoInfo] = {} + for entry in data: + ip_str: str = str(entry.get("query", "")) + if not ip_str: + continue + if entry.get("status") != "success": + out[ip_str] = empty + log.debug( + "geo_batch_entry_failed", + ip=ip_str, + message=entry.get("message", "unknown"), + ) + continue + out[ip_str] = _parse_single_response(entry) + + # Fill any IPs missing from the response. + for ip in ips: + if ip not in out: + out[ip] = empty + + return out + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_single_response(data: dict[str, object]) -> GeoInfo: + """Build a :class:`GeoInfo` from a single ip-api.com response dict. + + Args: + data: A ``status == "success"`` JSON response from ip-api.com. + + Returns: + Populated :class:`GeoInfo`. + """ + country_code: str | None = _str_or_none(data.get("countryCode")) + country_name: str | None = _str_or_none(data.get("country")) + asn_raw: str | None = _str_or_none(data.get("as")) + org_raw: str | None = _str_or_none(data.get("org")) + + # ip-api returns "AS12345 Some Org" in both "as" and "org". + asn: str | None = asn_raw.split()[0] if asn_raw else None + + return GeoInfo( + country_code=country_code, + country_name=country_name, + asn=asn, + org=org_raw, + ) + + +def _str_or_none(value: object) -> str | None: + """Return *value* as a non-empty string, or ``None``. + + Args: + value: Raw JSON value which may be ``None``, empty, or a string. + + Returns: + Stripped string if non-empty, else ``None``. + """ + if value is None: + return None + s = str(value).strip() + return s if s else None + + +def _store(ip: str, info: GeoInfo) -> None: + """Insert *info* into the module-level cache, flushing if over capacity. + + When the IP resolved successfully (``country_code is not None``) it is + also added to the :data:`_dirty` set so :func:`flush_dirty` can persist + it to the database on the next scheduled flush. + + Args: + ip: The IP address key. + info: The :class:`GeoInfo` to store. + """ + if len(_cache) >= _MAX_CACHE_SIZE: + _cache.clear() + _dirty.clear() + log.info("geo_cache_flushed", reason="capacity") + _cache[ip] = info + if info.country_code is not None: + _dirty.add(ip) + + +async def flush_dirty(db: aiosqlite.Connection) -> int: + """Persist all new in-memory geo entries to the ``geo_cache`` table. + + Takes an atomic snapshot of :data:`_dirty`, clears it, then batch-inserts + all entries that are still present in :data:`_cache` using a single + ``executemany`` call and one ``COMMIT``. This is the only place that + writes to the persistent cache during normal operation after startup. + + If the database write fails the entries are re-added to :data:`_dirty` + so they will be retried on the next flush cycle. + + Args: + db: Open :class:`aiosqlite.Connection` to the BanGUI application + database. + + Returns: + The number of rows successfully upserted. + """ + if not _dirty: + return 0 + + # Atomically snapshot and clear in a single-threaded async context. + # No ``await`` between copy and clear ensures no interleaving. + to_flush = _dirty.copy() + _dirty.clear() + + rows = [ + (ip, _cache[ip].country_code, _cache[ip].country_name, _cache[ip].asn, _cache[ip].org) + for ip in to_flush + if ip in _cache + ] + if not rows: + return 0 + + try: + await db.executemany( + """ + INSERT INTO geo_cache (ip, country_code, country_name, asn, org) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + country_code = excluded.country_code, + country_name = excluded.country_name, + asn = excluded.asn, + org = excluded.org, + cached_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + """, + rows, + ) + await db.commit() + except Exception as exc: # noqa: BLE001 + log.warning("geo_flush_dirty_failed", error=str(exc)) + # Re-add to dirty so they are retried on the next flush cycle. + _dirty.update(to_flush) + return 0 + + log.info("geo_flush_dirty_complete", count=len(rows)) + return len(rows) diff --git a/backend/app/services/health_service.py b/backend/app/services/health_service.py new file mode 100644 index 0000000..df9750d --- /dev/null +++ b/backend/app/services/health_service.py @@ -0,0 +1,171 @@ +"""Health service. + +Probes the fail2ban socket to determine whether the daemon is reachable and +collects aggregated server statistics (version, jail count, ban counts). + +The probe is intentionally lightweight — it is meant to be called every 30 +seconds by the background health-check task, not on every HTTP request. +""" + +from __future__ import annotations + +from typing import Any + +import structlog + +from app.models.server import ServerStatus +from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError, Fail2BanProtocolError + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_SOCKET_TIMEOUT: float = 5.0 + + +def _ok(response: Any) -> Any: + """Extract the payload from a fail2ban ``(return_code, data)`` response. + + fail2ban wraps every response in a ``(0, data)`` success tuple or + a ``(1, exception)`` error tuple. This helper returns ``data`` for + successful responses or raises :class:`ValueError` for error responses. + + Args: + response: Raw value returned by :meth:`~Fail2BanClient.send`. + + Returns: + The payload ``data`` portion of the response. + + Raises: + ValueError: If the response indicates an error (return code ≠ 0). + """ + try: + code, data = response + except (TypeError, ValueError) as exc: + raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc + + if code != 0: + raise ValueError(f"fail2ban returned error code {code}: {data!r}") + + return data + + +def _to_dict(pairs: Any) -> dict[str, Any]: + """Convert a list of ``(key, value)`` pairs to a plain dict. + + fail2ban returns structured data as lists of 2-tuples rather than dicts. + This helper converts them safely, ignoring non-pair items. + + Args: + pairs: A list of ``(key, value)`` pairs (or any iterable thereof). + + Returns: + A :class:`dict` with the keys and values from *pairs*. + """ + if not isinstance(pairs, (list, tuple)): + return {} + result: dict[str, Any] = {} + for item in pairs: + try: + k, v = item + result[str(k)] = v + except (TypeError, ValueError): + pass + return result + + +# --------------------------------------------------------------------------- +# Public interface +# --------------------------------------------------------------------------- + + +async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerStatus: + """Probe the fail2ban daemon and return a :class:`~app.models.server.ServerStatus`. + + Sends ``ping``, ``version``, ``status``, and per-jail ``status `` + commands. Any socket or protocol error is caught and results in an + ``online=False`` status so the dashboard can always return a safe default. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + timeout: Per-command socket timeout in seconds. + + Returns: + A :class:`~app.models.server.ServerStatus` snapshot. ``online`` is + ``True`` when the daemon is reachable, ``False`` otherwise. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=timeout) + + try: + # ------------------------------------------------------------------ # + # 1. Connectivity check # + # ------------------------------------------------------------------ # + ping_data = _ok(await client.send(["ping"])) + if ping_data != "pong": + log.warning("fail2ban_unexpected_ping_response", response=ping_data) + return ServerStatus(online=False) + + # ------------------------------------------------------------------ # + # 2. Version # + # ------------------------------------------------------------------ # + try: + version: str | None = str(_ok(await client.send(["version"]))) + except (ValueError, TypeError): + version = None + + # ------------------------------------------------------------------ # + # 3. Global status — jail count and names # + # ------------------------------------------------------------------ # + status_data = _to_dict(_ok(await client.send(["status"]))) + active_jails: int = int(status_data.get("Number of jail", 0) or 0) + jail_list_raw: str = str(status_data.get("Jail list", "") or "").strip() + jail_names: list[str] = ( + [j.strip() for j in jail_list_raw.split(",") if j.strip()] + if jail_list_raw + else [] + ) + + # ------------------------------------------------------------------ # + # 4. Per-jail aggregation # + # ------------------------------------------------------------------ # + total_bans: int = 0 + total_failures: int = 0 + + for jail_name in jail_names: + try: + jail_resp = _to_dict(_ok(await client.send(["status", jail_name]))) + filter_stats = _to_dict(jail_resp.get("Filter") or []) + action_stats = _to_dict(jail_resp.get("Actions") or []) + total_failures += int(filter_stats.get("Currently failed", 0) or 0) + total_bans += int(action_stats.get("Currently banned", 0) or 0) + except (ValueError, TypeError, KeyError) as exc: + log.warning( + "fail2ban_jail_status_parse_error", + jail=jail_name, + error=str(exc), + ) + + log.debug( + "fail2ban_probe_ok", + version=version, + active_jails=active_jails, + total_bans=total_bans, + total_failures=total_failures, + ) + + return ServerStatus( + online=True, + version=version, + active_jails=active_jails, + total_bans=total_bans, + total_failures=total_failures, + ) + + except (Fail2BanConnectionError, Fail2BanProtocolError) as exc: + log.warning("fail2ban_probe_failed", error=str(exc)) + return ServerStatus(online=False) + except ValueError as exc: + log.error("fail2ban_probe_parse_error", error=str(exc)) + return ServerStatus(online=False) diff --git a/backend/app/services/history_service.py b/backend/app/services/history_service.py new file mode 100644 index 0000000..26c2f78 --- /dev/null +++ b/backend/app/services/history_service.py @@ -0,0 +1,269 @@ +"""History service. + +Queries the fail2ban SQLite database for all historical ban records. +Supports filtering by jail, IP, and time range. For per-IP forensics the +service provides a full ban timeline with matched log lines and failure counts. + +All database I/O uses aiosqlite in **read-only** mode so BanGUI never +modifies or locks the fail2ban database. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +import aiosqlite +import structlog + +from app.models.ban import TIME_RANGE_SECONDS, TimeRange +from app.models.history import ( + HistoryBanItem, + HistoryListResponse, + IpDetailResponse, + IpTimelineEvent, +) +from app.services.ban_service import _get_fail2ban_db_path, _parse_data_json, _ts_to_iso + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_PAGE_SIZE: int = 100 +_MAX_PAGE_SIZE: int = 500 + + +def _since_unix(range_: TimeRange) -> int: + """Return the Unix timestamp for the start of the given time window. + + Args: + range_: One of the supported time-range presets. + + Returns: + Unix timestamp (seconds since epoch) equal to *now − range_*. + """ + seconds: int = TIME_RANGE_SECONDS[range_] + return int(datetime.now(tz=UTC).timestamp()) - seconds + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def list_history( + socket_path: str, + *, + range_: TimeRange | None = None, + jail: str | None = None, + ip_filter: str | None = None, + page: int = 1, + page_size: int = _DEFAULT_PAGE_SIZE, + geo_enricher: Any | None = None, +) -> HistoryListResponse: + """Return a paginated list of historical ban records with optional filters. + + Queries the fail2ban ``bans`` table applying the requested filters and + returns a paginated list ordered newest-first. When *geo_enricher* is + supplied, each record is enriched with country and ASN data. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + range_: Time-range preset. ``None`` means all-time (no time filter). + jail: If given, restrict results to bans from this jail. + ip_filter: If given, restrict results to bans for this exact IP + (or a prefix — the query uses ``LIKE ip_filter%``). + page: 1-based page number (default: ``1``). + page_size: Maximum items per page, capped at ``_MAX_PAGE_SIZE``. + geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``. + + Returns: + :class:`~app.models.history.HistoryListResponse` with paginated items + and the total matching count. + """ + effective_page_size: int = min(page_size, _MAX_PAGE_SIZE) + offset: int = (page - 1) * effective_page_size + + # Build WHERE clauses dynamically. + wheres: list[str] = [] + params: list[Any] = [] + + if range_ is not None: + since: int = _since_unix(range_) + wheres.append("timeofban >= ?") + params.append(since) + + if jail is not None: + wheres.append("jail = ?") + params.append(jail) + + if ip_filter is not None: + wheres.append("ip LIKE ?") + params.append(f"{ip_filter}%") + + where_sql: str = ("WHERE " + " AND ".join(wheres)) if wheres else "" + + db_path: str = await _get_fail2ban_db_path(socket_path) + log.info( + "history_service_list", + db_path=db_path, + range=range_, + jail=jail, + ip_filter=ip_filter, + page=page, + ) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + + async with f2b_db.execute( + f"SELECT COUNT(*) FROM bans {where_sql}", # noqa: S608 + params, + ) as cur: + count_row = await cur.fetchone() + total: int = int(count_row[0]) if count_row else 0 + + async with f2b_db.execute( + f"SELECT jail, ip, timeofban, bancount, data " # noqa: S608 + f"FROM bans {where_sql} " + "ORDER BY timeofban DESC " + "LIMIT ? OFFSET ?", + [*params, effective_page_size, offset], + ) as cur: + rows = await cur.fetchall() + + items: list[HistoryBanItem] = [] + for row in rows: + jail_name: str = str(row["jail"]) + ip: str = str(row["ip"]) + banned_at: str = _ts_to_iso(int(row["timeofban"])) + ban_count: int = int(row["bancount"]) + matches, failures = _parse_data_json(row["data"]) + + country_code: str | None = None + country_name: str | None = None + asn: str | None = None + org: str | None = None + + if geo_enricher is not None: + try: + geo = await geo_enricher(ip) + if geo is not None: + country_code = geo.country_code + country_name = geo.country_name + asn = geo.asn + org = geo.org + except Exception: # noqa: BLE001 + log.warning("history_service_geo_lookup_failed", ip=ip) + + items.append( + HistoryBanItem( + ip=ip, + jail=jail_name, + banned_at=banned_at, + ban_count=ban_count, + failures=failures, + matches=matches, + country_code=country_code, + country_name=country_name, + asn=asn, + org=org, + ) + ) + + return HistoryListResponse( + items=items, + total=total, + page=page, + page_size=effective_page_size, + ) + + +async def get_ip_detail( + socket_path: str, + ip: str, + *, + geo_enricher: Any | None = None, +) -> IpDetailResponse | None: + """Return the full historical record for a single IP address. + + Fetches all ban events for *ip* from the fail2ban database, ordered + newest-first. Aggregates total bans, total failures, and the timestamp of + the most recent ban. Optionally enriches with geolocation data. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + ip: The IP address to look up. + geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``. + + Returns: + :class:`~app.models.history.IpDetailResponse` if any records exist + for *ip*, or ``None`` if the IP has no history in the database. + """ + db_path: str = await _get_fail2ban_db_path(socket_path) + log.info("history_service_ip_detail", db_path=db_path, ip=ip) + + async with aiosqlite.connect(f"file:{db_path}?mode=ro", uri=True) as f2b_db: + f2b_db.row_factory = aiosqlite.Row + async with f2b_db.execute( + "SELECT jail, ip, timeofban, bancount, data " + "FROM bans " + "WHERE ip = ? " + "ORDER BY timeofban DESC", + (ip,), + ) as cur: + rows = await cur.fetchall() + + if not rows: + return None + + timeline: list[IpTimelineEvent] = [] + total_failures: int = 0 + + for row in rows: + jail_name: str = str(row["jail"]) + banned_at: str = _ts_to_iso(int(row["timeofban"])) + ban_count: int = int(row["bancount"]) + matches, failures = _parse_data_json(row["data"]) + total_failures += failures + timeline.append( + IpTimelineEvent( + jail=jail_name, + banned_at=banned_at, + ban_count=ban_count, + failures=failures, + matches=matches, + ) + ) + + last_ban_at: str | None = timeline[0].banned_at if timeline else None + + country_code: str | None = None + country_name: str | None = None + asn: str | None = None + org: str | None = None + + if geo_enricher is not None: + try: + geo = await geo_enricher(ip) + if geo is not None: + country_code = geo.country_code + country_name = geo.country_name + asn = geo.asn + org = geo.org + except Exception: # noqa: BLE001 + log.warning("history_service_geo_lookup_failed_detail", ip=ip) + + return IpDetailResponse( + ip=ip, + total_bans=len(timeline), + total_failures=total_failures, + last_ban_at=last_ban_at, + country_code=country_code, + country_name=country_name, + asn=asn, + org=org, + timeline=timeline, + ) diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py new file mode 100644 index 0000000..d89d2d5 --- /dev/null +++ b/backend/app/services/jail_service.py @@ -0,0 +1,1240 @@ +"""Jail management service. + +Provides methods to list, inspect, and control fail2ban jails via the +Unix domain socket. All socket I/O is performed through the async +:class:`~app.utils.fail2ban_client.Fail2BanClient` wrapper. + +Architecture note: this module is a pure service — it contains **no** +HTTP/FastAPI concerns. All results are returned as Pydantic models so +routers can serialise them directly. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import ipaddress +from typing import Any + +import structlog + +from app.models.ban import ActiveBan, ActiveBanListResponse, JailBannedIpsResponse +from app.models.config import BantimeEscalation +from app.models.jail import ( + Jail, + JailDetailResponse, + JailListResponse, + JailStatus, + JailSummary, +) +from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_SOCKET_TIMEOUT: float = 10.0 + +# Guard against concurrent reload_all calls. Overlapping ``reload --all`` +# commands sent to fail2ban's socket produce undefined behaviour and may cause +# jails to be permanently removed from the daemon. Serialising them here +# ensures only one reload stream is in-flight at a time. +_reload_all_lock: asyncio.Lock = asyncio.Lock() + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class JailNotFoundError(Exception): + """Raised when a requested jail name does not exist in fail2ban.""" + + def __init__(self, name: str) -> None: + """Initialise with the jail name that was not found. + + Args: + name: The jail name that could not be located. + """ + self.name: str = name + super().__init__(f"Jail not found: {name!r}") + + +class JailOperationError(Exception): + """Raised when a jail control command fails for a non-auth reason.""" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _ok(response: Any) -> Any: + """Extract the payload from a fail2ban ``(return_code, data)`` response. + + Args: + response: Raw value returned by :meth:`~Fail2BanClient.send`. + + Returns: + The payload ``data`` portion of the response. + + Raises: + ValueError: If the response indicates an error (return code ≠ 0). + """ + try: + code, data = response + except (TypeError, ValueError) as exc: + raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc + + if code != 0: + raise ValueError(f"fail2ban returned error code {code}: {data!r}") + + return data + + +def _to_dict(pairs: Any) -> dict[str, Any]: + """Convert a list of ``(key, value)`` pairs to a plain dict. + + Args: + pairs: A list of ``(key, value)`` pairs (or any iterable thereof). + + Returns: + A :class:`dict` with the keys and values from *pairs*. + """ + if not isinstance(pairs, (list, tuple)): + return {} + result: dict[str, Any] = {} + for item in pairs: + try: + k, v = item + result[str(k)] = v + except (TypeError, ValueError): + pass + return result + + +def _ensure_list(value: Any) -> list[str]: + """Coerce a fail2ban response value to a list of strings. + + Some fail2ban ``get`` responses return ``None`` or a single string + when there is only one entry. This helper normalises the result. + + Args: + value: The raw value from a ``get`` command response. + + Returns: + A list of strings, possibly empty. + """ + if value is None: + return [] + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, (list, tuple)): + return [str(v) for v in value if v is not None] + return [str(value)] + + +def _is_not_found_error(exc: Exception) -> bool: + """Return ``True`` if *exc* indicates a jail does not exist. + + Checks both space-separated (``"unknown jail"``) and concatenated + (``"unknownjail"``) forms because fail2ban serialises + ``UnknownJailException`` without a space when pickled. + + Args: + exc: The exception to inspect. + + Returns: + ``True`` when the exception message signals an unknown jail. + """ + msg = str(exc).lower() + return any( + phrase in msg + for phrase in ( + "unknown jail", + "unknownjail", # covers UnknownJailException serialised by fail2ban + "no jail", + "does not exist", + "not found", + ) + ) + + +async def _safe_get( + client: Fail2BanClient, + command: list[Any], + default: Any = None, +) -> Any: + """Send a ``get`` command and return ``default`` on error. + + Errors during optional detail queries (logpath, regex, etc.) should + not abort the whole request — this helper swallows them gracefully. + + Args: + client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use. + command: The command list to send. + default: Value to return when the command fails. + + Returns: + The response payload, or *default* on any error. + """ + try: + return _ok(await client.send(command)) + except (ValueError, TypeError, Exception): + return default + + +# --------------------------------------------------------------------------- +# Public API — Jail listing & detail +# --------------------------------------------------------------------------- + + +async def list_jails(socket_path: str) -> JailListResponse: + """Return a summary list of all active fail2ban jails. + + Queries the daemon for the global jail list and then fetches status + and key configuration for each jail in parallel. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.jail.JailListResponse` with all active jails. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # 1. Fetch global status to get jail names. + global_status = _to_dict(_ok(await client.send(["status"]))) + jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip() + jail_names: list[str] = ( + [j.strip() for j in jail_list_raw.split(",") if j.strip()] + if jail_list_raw + else [] + ) + + log.info("jail_list_fetched", count=len(jail_names)) + + if not jail_names: + return JailListResponse(jails=[], total=0) + + # 2. Fetch summary data for every jail in parallel. + summaries: list[JailSummary] = await asyncio.gather( + *[_fetch_jail_summary(client, name) for name in jail_names], + return_exceptions=False, + ) + + return JailListResponse(jails=list(summaries), total=len(summaries)) + + +async def _fetch_jail_summary( + client: Fail2BanClient, + name: str, +) -> JailSummary: + """Fetch and build a :class:`~app.models.jail.JailSummary` for one jail. + + Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``, + ``backend``, and ``idle`` commands in parallel. + + Args: + client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`. + name: Jail name. + + Returns: + A :class:`~app.models.jail.JailSummary` populated from the responses. + """ + _r = await asyncio.gather( + client.send(["status", name, "short"]), + client.send(["get", name, "bantime"]), + client.send(["get", name, "findtime"]), + client.send(["get", name, "maxretry"]), + client.send(["get", name, "backend"]), + client.send(["get", name, "idle"]), + return_exceptions=True, + ) + status_raw: Any = _r[0] + bantime_raw: Any = _r[1] + findtime_raw: Any = _r[2] + maxretry_raw: Any = _r[3] + backend_raw: Any = _r[4] + idle_raw: Any = _r[5] + + # Parse jail status (filter + actions). + jail_status: JailStatus | None = None + if not isinstance(status_raw, Exception): + try: + raw = _to_dict(_ok(status_raw)) + filter_stats = _to_dict(raw.get("Filter") or []) + action_stats = _to_dict(raw.get("Actions") or []) + jail_status = JailStatus( + currently_banned=int(action_stats.get("Currently banned", 0) or 0), + total_banned=int(action_stats.get("Total banned", 0) or 0), + currently_failed=int(filter_stats.get("Currently failed", 0) or 0), + total_failed=int(filter_stats.get("Total failed", 0) or 0), + ) + except (ValueError, TypeError) as exc: + log.warning("jail_status_parse_error", jail=name, error=str(exc)) + + def _safe_int(raw: Any, fallback: int) -> int: + if isinstance(raw, Exception): + return fallback + try: + return int(_ok(raw)) + except (ValueError, TypeError): + return fallback + + def _safe_str(raw: Any, fallback: str) -> str: + if isinstance(raw, Exception): + return fallback + try: + return str(_ok(raw)) + except (ValueError, TypeError): + return fallback + + def _safe_bool(raw: Any, fallback: bool = False) -> bool: + if isinstance(raw, Exception): + return fallback + try: + return bool(_ok(raw)) + except (ValueError, TypeError): + return fallback + + return JailSummary( + name=name, + enabled=True, + running=True, + idle=_safe_bool(idle_raw), + backend=_safe_str(backend_raw, "polling"), + find_time=_safe_int(findtime_raw, 600), + ban_time=_safe_int(bantime_raw, 600), + max_retry=_safe_int(maxretry_raw, 5), + status=jail_status, + ) + + +async def get_jail(socket_path: str, name: str) -> JailDetailResponse: + """Return full detail for a single fail2ban jail. + + Sends multiple ``get`` and ``status`` commands in parallel to build + the complete jail snapshot. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + + Returns: + :class:`~app.models.jail.JailDetailResponse` with the full jail. + + Raises: + JailNotFoundError: If *name* is not a known jail. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # Verify the jail exists by sending a status command first. + try: + status_raw = _ok(await client.send(["status", name, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise + + raw = _to_dict(status_raw) + filter_stats = _to_dict(raw.get("Filter") or []) + action_stats = _to_dict(raw.get("Actions") or []) + + jail_status = JailStatus( + currently_banned=int(action_stats.get("Currently banned", 0) or 0), + total_banned=int(action_stats.get("Total banned", 0) or 0), + currently_failed=int(filter_stats.get("Currently failed", 0) or 0), + total_failed=int(filter_stats.get("Total failed", 0) or 0), + ) + + # Fetch all detail fields in parallel. + ( + logpath_raw, + failregex_raw, + ignoreregex_raw, + ignoreip_raw, + datepattern_raw, + logencoding_raw, + bantime_raw, + findtime_raw, + maxretry_raw, + backend_raw, + idle_raw, + actions_raw, + bt_increment_raw, + bt_factor_raw, + bt_formula_raw, + bt_multipliers_raw, + bt_maxtime_raw, + bt_rndtime_raw, + bt_overalljails_raw, + ) = await asyncio.gather( + _safe_get(client, ["get", name, "logpath"], []), + _safe_get(client, ["get", name, "failregex"], []), + _safe_get(client, ["get", name, "ignoreregex"], []), + _safe_get(client, ["get", name, "ignoreip"], []), + _safe_get(client, ["get", name, "datepattern"], None), + _safe_get(client, ["get", name, "logencoding"], "UTF-8"), + _safe_get(client, ["get", name, "bantime"], 600), + _safe_get(client, ["get", name, "findtime"], 600), + _safe_get(client, ["get", name, "maxretry"], 5), + _safe_get(client, ["get", name, "backend"], "polling"), + _safe_get(client, ["get", name, "idle"], False), + _safe_get(client, ["get", name, "actions"], []), + _safe_get(client, ["get", name, "bantime.increment"], False), + _safe_get(client, ["get", name, "bantime.factor"], None), + _safe_get(client, ["get", name, "bantime.formula"], None), + _safe_get(client, ["get", name, "bantime.multipliers"], None), + _safe_get(client, ["get", name, "bantime.maxtime"], None), + _safe_get(client, ["get", name, "bantime.rndtime"], None), + _safe_get(client, ["get", name, "bantime.overalljails"], False), + ) + + bt_increment: bool = bool(bt_increment_raw) + bantime_escalation = BantimeEscalation( + increment=bt_increment, + factor=float(bt_factor_raw) if bt_factor_raw is not None else None, + formula=str(bt_formula_raw) if bt_formula_raw else None, + multipliers=str(bt_multipliers_raw) if bt_multipliers_raw else None, + max_time=int(bt_maxtime_raw) if bt_maxtime_raw is not None else None, + rnd_time=int(bt_rndtime_raw) if bt_rndtime_raw is not None else None, + overall_jails=bool(bt_overalljails_raw), + ) + + jail = Jail( + name=name, + enabled=True, + running=True, + idle=bool(idle_raw), + backend=str(backend_raw or "polling"), + log_paths=_ensure_list(logpath_raw), + fail_regex=_ensure_list(failregex_raw), + ignore_regex=_ensure_list(ignoreregex_raw), + ignore_ips=_ensure_list(ignoreip_raw), + date_pattern=str(datepattern_raw) if datepattern_raw else None, + log_encoding=str(logencoding_raw or "UTF-8"), + find_time=int(findtime_raw or 600), + ban_time=int(bantime_raw or 600), + max_retry=int(maxretry_raw or 5), + bantime_escalation=bantime_escalation, + status=jail_status, + actions=_ensure_list(actions_raw), + ) + + log.info("jail_detail_fetched", jail=name) + return JailDetailResponse(jail=jail) + + +# --------------------------------------------------------------------------- +# Public API — Jail control +# --------------------------------------------------------------------------- + + +async def start_jail(socket_path: str, name: str) -> None: + """Start a stopped fail2ban jail. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name to start. + + Raises: + JailNotFoundError: If *name* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["start", name])) + log.info("jail_started", jail=name) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise JailOperationError(str(exc)) from exc + + +async def stop_jail(socket_path: str, name: str) -> None: + """Stop a running fail2ban jail. + + If the jail is not currently active (already stopped), this is a no-op + and no error is raised — the operation is idempotent. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name to stop. + + Raises: + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["stop", name])) + log.info("jail_stopped", jail=name) + except ValueError as exc: + if _is_not_found_error(exc): + # Jail is already stopped or was never running — treat as a no-op. + log.info("jail_stop_noop", jail=name) + return + raise JailOperationError(str(exc)) from exc + + +async def set_idle(socket_path: str, name: str, *, on: bool) -> None: + """Toggle the idle mode of a fail2ban jail. + + When idle mode is on the jail pauses monitoring without stopping + completely; existing bans remain active. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + on: Pass ``True`` to enable idle, ``False`` to disable it. + + Raises: + JailNotFoundError: If *name* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + state = "on" if on else "off" + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["set", name, "idle", state])) + log.info("jail_idle_toggled", jail=name, idle=on) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise JailOperationError(str(exc)) from exc + + +async def reload_jail(socket_path: str, name: str) -> None: + """Reload a single fail2ban jail to pick up configuration changes. + + The reload protocol requires a non-empty configuration stream. Without + one, fail2ban's end-of-reload phase removes every jail that received no + configuration commands — permanently deleting the jail from the running + daemon. Sending ``['start', name]`` as the minimal stream is sufficient: + ``startJail`` removes the jail from ``reload_state``, which causes the + end phase to *commit* the reload instead of deleting the jail. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name to reload. + + Raises: + JailNotFoundError: If *name* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["reload", name, [], [["start", name]]])) + log.info("jail_reloaded", jail=name) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise JailOperationError(str(exc)) from exc + + +async def reload_all( + socket_path: str, + *, + include_jails: list[str] | None = None, + exclude_jails: list[str] | None = None, +) -> None: + """Reload all fail2ban jails at once. + + Fetches the current jail list first so that a ``['start', name]`` entry + can be included in the config stream for every active jail. Without a + non-empty stream the end-of-reload phase deletes every jail that received + no configuration commands. + + *include_jails* are added to the stream (e.g. a newly activated jail that + is not yet running). *exclude_jails* are removed from the stream (e.g. a + jail that was just deactivated and should not be restarted). + + Args: + socket_path: Path to the fail2ban Unix domain socket. + include_jails: Extra jail names to add to the start stream. + exclude_jails: Jail names to remove from the start stream. + + Raises: + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + async with _reload_all_lock: + try: + # Resolve jail names so we can build the minimal config stream. + status_raw = _ok(await client.send(["status"])) + status_dict = _to_dict(status_raw) + jail_list_raw: str = str(status_dict.get("Jail list", "")) + jail_names = [n.strip() for n in jail_list_raw.split(",") if n.strip()] + + # Merge include/exclude sets so the stream matches the desired state. + names_set: set[str] = set(jail_names) + if include_jails: + names_set.update(include_jails) + if exclude_jails: + names_set -= set(exclude_jails) + + stream: list[list[str]] = [["start", n] for n in sorted(names_set)] + _ok(await client.send(["reload", "--all", [], stream])) + log.info("all_jails_reloaded") + except ValueError as exc: + raise JailOperationError(str(exc)) from exc + + +# --------------------------------------------------------------------------- +# Public API — Ban / Unban +# --------------------------------------------------------------------------- + + +async def ban_ip(socket_path: str, jail: str, ip: str) -> None: + """Ban an IP address in a specific fail2ban jail. + + The IP address is validated with :mod:`ipaddress` before the command + is sent to fail2ban. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + jail: Jail in which to apply the ban. + ip: IP address to ban (IPv4 or IPv6). + + Raises: + ValueError: If *ip* is not a valid IP address. + JailNotFoundError: If *jail* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + # Validate the IP address before sending to avoid injection. + try: + ipaddress.ip_address(ip) + except ValueError as exc: + raise ValueError(f"Invalid IP address: {ip!r}") from exc + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["set", jail, "banip", ip])) + log.info("ip_banned", ip=ip, jail=jail) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(jail) from exc + raise JailOperationError(str(exc)) from exc + + +async def unban_ip( + socket_path: str, + ip: str, + jail: str | None = None, +) -> None: + """Unban an IP address from one or all fail2ban jails. + + If *jail* is ``None``, the IP is unbanned from every jail using the + global ``unban`` command. Otherwise only the specified jail is + targeted. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + ip: IP address to unban. + jail: Jail to unban from. ``None`` means all jails. + + Raises: + ValueError: If *ip* is not a valid IP address. + JailNotFoundError: If *jail* is specified but does not exist. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + try: + ipaddress.ip_address(ip) + except ValueError as exc: + raise ValueError(f"Invalid IP address: {ip!r}") from exc + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + if jail is None: + _ok(await client.send(["unban", ip])) + log.info("ip_unbanned_all_jails", ip=ip) + else: + _ok(await client.send(["set", jail, "unbanip", ip])) + log.info("ip_unbanned", ip=ip, jail=jail) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(jail or "") from exc + raise JailOperationError(str(exc)) from exc + + +async def get_active_bans( + socket_path: str, + geo_enricher: Any | None = None, + http_session: Any | None = None, + app_db: Any | None = None, +) -> ActiveBanListResponse: + """Return all currently banned IPs across every jail. + + For each jail the ``get banip --with-time`` command is used + to retrieve ban start and expiry times alongside the IP address. + + Geo enrichment strategy (highest priority first): + + 1. When *http_session* is provided the entire set of banned IPs is resolved + in a single :func:`~app.services.geo_service.lookup_batch` call (up to + 100 IPs per HTTP request). This is far more efficient than concurrent + per-IP lookups and stays within ip-api.com rate limits. + 2. When only *geo_enricher* is provided (legacy / test path) each IP is + resolved individually via the supplied async callable. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + geo_enricher: Optional async callable ``(ip) → GeoInfo | None`` + used to enrich each ban entry with country and ASN data. + Ignored when *http_session* is provided. + http_session: Optional shared :class:`aiohttp.ClientSession`. When + provided, :func:`~app.services.geo_service.lookup_batch` is used + for efficient bulk geo resolution. + app_db: Optional BanGUI application database connection used to + persist newly resolved geo entries across restarts. Only + meaningful when *http_session* is provided. + + Returns: + :class:`~app.models.ban.ActiveBanListResponse` with all active bans. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + from app.services import geo_service # noqa: PLC0415 + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # Fetch jail names. + global_status = _to_dict(_ok(await client.send(["status"]))) + jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip() + jail_names: list[str] = ( + [j.strip() for j in jail_list_raw.split(",") if j.strip()] + if jail_list_raw + else [] + ) + + if not jail_names: + return ActiveBanListResponse(bans=[], total=0) + + # For each jail, fetch the ban list with time info in parallel. + results: list[Any] = await asyncio.gather( + *[client.send(["get", jn, "banip", "--with-time"]) for jn in jail_names], + return_exceptions=True, + ) + + bans: list[ActiveBan] = [] + for jail_name, raw_result in zip(jail_names, results, strict=False): + if isinstance(raw_result, Exception): + log.warning( + "active_bans_fetch_error", + jail=jail_name, + error=str(raw_result), + ) + continue + + try: + ban_list: list[str] = _ok(raw_result) or [] + except (TypeError, ValueError) as exc: + log.warning( + "active_bans_parse_error", + jail=jail_name, + error=str(exc), + ) + continue + + for entry in ban_list: + ban = _parse_ban_entry(str(entry), jail_name) + if ban is not None: + bans.append(ban) + + # Enrich with geo data — prefer batch lookup over per-IP enricher. + if http_session is not None and bans: + all_ips: list[str] = [ban.ip for ban in bans] + try: + geo_map = await geo_service.lookup_batch(all_ips, http_session, db=app_db) + except Exception: # noqa: BLE001 + log.warning("active_bans_batch_geo_failed") + geo_map = {} + enriched: list[ActiveBan] = [] + for ban in bans: + geo = geo_map.get(ban.ip) + if geo is not None: + enriched.append(ban.model_copy(update={"country": geo.country_code})) + else: + enriched.append(ban) + bans = enriched + elif geo_enricher is not None: + bans = await _enrich_bans(bans, geo_enricher) + + log.info("active_bans_fetched", total=len(bans)) + return ActiveBanListResponse(bans=bans, total=len(bans)) + + +def _parse_ban_entry(entry: str, jail: str) -> ActiveBan | None: + """Parse a ban entry from ``get banip --with-time`` output. + + Expected format:: + + "1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00" + + Args: + entry: Raw ban entry string. + jail: Jail name for the resulting record. + + Returns: + An :class:`~app.models.ban.ActiveBan` or ``None`` if parsing fails. + """ + from datetime import UTC, datetime + + try: + parts = entry.split("\t", 1) + ip = parts[0].strip() + + # Validate IP + ipaddress.ip_address(ip) + + if len(parts) < 2: + # Entry has no time info — return with unknown times. + return ActiveBan( + ip=ip, + jail=jail, + banned_at=None, + expires_at=None, + ban_count=1, + country=None, + ) + + time_part = parts[1].strip() + # Format: "2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00" + # Split at " + " to get banned_at and remainder. + plus_idx = time_part.find(" + ") + if plus_idx == -1: + banned_at_str = time_part.strip() + expires_at_str: str | None = None + else: + banned_at_str = time_part[:plus_idx].strip() + remainder = time_part[plus_idx + 3 :] # skip " + " + eq_idx = remainder.find(" = ") + expires_at_str = remainder[eq_idx + 3 :].strip() if eq_idx != -1 else None + + _date_fmt = "%Y-%m-%d %H:%M:%S" + + def _to_iso(ts: str) -> str: + dt = datetime.strptime(ts, _date_fmt).replace(tzinfo=UTC) + return dt.isoformat() + + banned_at_iso: str | None = None + expires_at_iso: str | None = None + + with contextlib.suppress(ValueError): + banned_at_iso = _to_iso(banned_at_str) + + with contextlib.suppress(ValueError): + if expires_at_str: + expires_at_iso = _to_iso(expires_at_str) + + return ActiveBan( + ip=ip, + jail=jail, + banned_at=banned_at_iso, + expires_at=expires_at_iso, + ban_count=1, + country=None, + ) + except (ValueError, IndexError, AttributeError) as exc: + log.debug("ban_entry_parse_error", entry=entry, jail=jail, error=str(exc)) + return None + + +# --------------------------------------------------------------------------- +# Public API — Jail-specific paginated bans +# --------------------------------------------------------------------------- + +#: Maximum allowed page size for :func:`get_jail_banned_ips`. +_MAX_PAGE_SIZE: int = 100 + + +async def get_jail_banned_ips( + socket_path: str, + jail_name: str, + page: int = 1, + page_size: int = 25, + search: str | None = None, + http_session: Any | None = None, + app_db: Any | None = None, +) -> JailBannedIpsResponse: + """Return a paginated list of currently banned IPs for a single jail. + + Fetches the full ban list from the fail2ban socket, applies an optional + substring search filter on the IP, paginates server-side, and geo-enriches + **only** the current page slice to stay within rate limits. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + jail_name: Name of the jail to query. + page: 1-based page number (default 1). + page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25). + search: Optional case-insensitive substring filter applied to IP addresses. + http_session: Optional shared :class:`aiohttp.ClientSession` for geo + enrichment via :func:`~app.services.geo_service.lookup_batch`. + app_db: Optional BanGUI application database for persistent geo cache. + + Returns: + :class:`~app.models.ban.JailBannedIpsResponse` with the paginated bans. + + Raises: + JailNotFoundError: If *jail_name* is not a known active jail. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is + unreachable. + """ + from app.services import geo_service # noqa: PLC0415 + + # Clamp page_size to the allowed maximum. + page_size = min(page_size, _MAX_PAGE_SIZE) + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + # Verify the jail exists. + try: + _ok(await client.send(["status", jail_name, "short"])) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(jail_name) from exc + raise + + # Fetch the full ban list for this jail. + try: + raw_result = _ok(await client.send(["get", jail_name, "banip", "--with-time"])) + except (ValueError, TypeError): + raw_result = [] + + ban_list: list[str] = raw_result or [] + + # Parse all entries. + all_bans: list[ActiveBan] = [] + for entry in ban_list: + ban = _parse_ban_entry(str(entry), jail_name) + if ban is not None: + all_bans.append(ban) + + # Apply optional substring search filter (case-insensitive). + if search: + search_lower = search.lower() + all_bans = [b for b in all_bans if search_lower in b.ip.lower()] + + total = len(all_bans) + + # Slice the requested page. + start = (page - 1) * page_size + page_bans = all_bans[start : start + page_size] + + # Geo-enrich only the page slice. + if http_session is not None and page_bans: + page_ips = [b.ip for b in page_bans] + try: + geo_map = await geo_service.lookup_batch(page_ips, http_session, db=app_db) + except Exception: # noqa: BLE001 + log.warning("jail_banned_ips_geo_failed", jail=jail_name) + geo_map = {} + enriched_page: list[ActiveBan] = [] + for ban in page_bans: + geo = geo_map.get(ban.ip) + if geo is not None: + enriched_page.append(ban.model_copy(update={"country": geo.country_code})) + else: + enriched_page.append(ban) + page_bans = enriched_page + + log.info( + "jail_banned_ips_fetched", + jail=jail_name, + total=total, + page=page, + page_size=page_size, + ) + return JailBannedIpsResponse( + items=page_bans, + total=total, + page=page, + page_size=page_size, + ) + + +async def _enrich_bans( + bans: list[ActiveBan], + geo_enricher: Any, +) -> list[ActiveBan]: + """Enrich ban records with geo data asynchronously. + + Args: + bans: The list of :class:`~app.models.ban.ActiveBan` records to enrich. + geo_enricher: Async callable ``(ip) → GeoInfo | None``. + + Returns: + The same list with ``country`` fields populated where lookup succeeded. + """ + geo_results: list[Any] = await asyncio.gather( + *[geo_enricher(ban.ip) for ban in bans], + return_exceptions=True, + ) + enriched: list[ActiveBan] = [] + for ban, geo in zip(bans, geo_results, strict=False): + if geo is not None and not isinstance(geo, Exception): + enriched.append(ban.model_copy(update={"country": geo.country_code})) + else: + enriched.append(ban) + return enriched + + +# --------------------------------------------------------------------------- +# Public API — Ignore list (IP whitelist) +# --------------------------------------------------------------------------- + + +async def get_ignore_list(socket_path: str, name: str) -> list[str]: + """Return the ignore list for a fail2ban jail. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + + Returns: + List of IP addresses and CIDR networks on the jail's ignore list. + + Raises: + JailNotFoundError: If *name* is not a known jail. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + raw = _ok(await client.send(["get", name, "ignoreip"])) + return _ensure_list(raw) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise + + +async def add_ignore_ip(socket_path: str, name: str, ip: str) -> None: + """Add an IP address or CIDR network to a jail's ignore list. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + ip: IP address or CIDR network to add. + + Raises: + JailNotFoundError: If *name* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + # Basic format validation. + try: + ipaddress.ip_network(ip, strict=False) + except ValueError as exc: + raise ValueError(f"Invalid IP address or network: {ip!r}") from exc + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["set", name, "addignoreip", ip])) + log.info("ignore_ip_added", jail=name, ip=ip) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise JailOperationError(str(exc)) from exc + + +async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None: + """Remove an IP address or CIDR network from a jail's ignore list. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + ip: IP address or CIDR network to remove. + + Raises: + JailNotFoundError: If *name* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["set", name, "delignoreip", ip])) + log.info("ignore_ip_removed", jail=name, ip=ip) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise JailOperationError(str(exc)) from exc + + +async def get_ignore_self(socket_path: str, name: str) -> bool: + """Return whether a jail ignores the server's own IP addresses. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + + Returns: + ``True`` when ``ignoreself`` is enabled for the jail. + + Raises: + JailNotFoundError: If *name* is not a known jail. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + raw = _ok(await client.send(["get", name, "ignoreself"])) + return bool(raw) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise + + +async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None: + """Toggle the ``ignoreself`` option for a fail2ban jail. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + name: Jail name. + on: ``True`` to enable ignoreself, ``False`` to disable. + + Raises: + JailNotFoundError: If *name* is not a known jail. + JailOperationError: If fail2ban reports the operation failed. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + value = "true" if on else "false" + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + _ok(await client.send(["set", name, "ignoreself", value])) + log.info("ignore_self_toggled", jail=name, on=on) + except ValueError as exc: + if _is_not_found_error(exc): + raise JailNotFoundError(name) from exc + raise JailOperationError(str(exc)) from exc + + +# --------------------------------------------------------------------------- +# Public API — IP lookup +# --------------------------------------------------------------------------- + + +async def lookup_ip( + socket_path: str, + ip: str, + geo_enricher: Any | None = None, +) -> dict[str, Any]: + """Return ban status and history for a single IP address. + + Checks every running jail for whether the IP is currently banned. + Also queries the fail2ban database for historical ban records. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + ip: IP address to look up. + geo_enricher: Optional async callable ``(ip) → GeoInfo | None``. + + Returns: + A dict with keys: + * ``ip`` — the queried IP address + * ``currently_banned_in`` — list of jails where the IP is active + * ``geo`` — ``GeoInfo`` dataclass or ``None`` + + Raises: + ValueError: If *ip* is not a valid IP address. + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + try: + ipaddress.ip_address(ip) + except ValueError as exc: + raise ValueError(f"Invalid IP address: {ip!r}") from exc + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + with contextlib.suppress(ValueError, Fail2BanConnectionError): + # Use fail2ban's "banned " command which checks all jails. + _ok(await client.send(["get", "--all", "banned", ip])) + + # Fetch jail names from status. + global_status = _to_dict(_ok(await client.send(["status"]))) + jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip() + jail_names: list[str] = ( + [j.strip() for j in jail_list_raw.split(",") if j.strip()] + if jail_list_raw + else [] + ) + + # Check ban status per jail in parallel. + ban_results: list[Any] = await asyncio.gather( + *[client.send(["get", jn, "banip"]) for jn in jail_names], + return_exceptions=True, + ) + + currently_banned_in: list[str] = [] + for jail_name, result in zip(jail_names, ban_results, strict=False): + if isinstance(result, Exception): + continue + try: + ban_list: list[str] = _ok(result) or [] + if ip in ban_list: + currently_banned_in.append(jail_name) + except (ValueError, TypeError): + pass + + geo = None + if geo_enricher is not None: + with contextlib.suppress(Exception): # noqa: BLE001 + geo = await geo_enricher(ip) + + log.info("ip_lookup_completed", ip=ip, banned_in_jails=currently_banned_in) + + return { + "ip": ip, + "currently_banned_in": currently_banned_in, + "geo": geo, + } + + +async def unban_all_ips(socket_path: str) -> int: + """Unban every currently banned IP across all fail2ban jails. + + Uses fail2ban's global ``unban --all`` command, which atomically removes + every active ban from every jail in a single socket round-trip. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + The number of IP addresses that were unbanned. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + count: int = int(_ok(await client.send(["unban", "--all"]))) + log.info("all_ips_unbanned", count=count) + return count diff --git a/backend/app/services/server_service.py b/backend/app/services/server_service.py new file mode 100644 index 0000000..6180aaa --- /dev/null +++ b/backend/app/services/server_service.py @@ -0,0 +1,189 @@ +"""Server-level settings service. + +Provides methods to read and update fail2ban server-level settings +(log level, log target, database configuration) via the Unix domain socket. +Also exposes the ``flushlogs`` command for use after log rotation. + +Architecture note: this module is a pure service — it contains **no** +HTTP/FastAPI concerns. +""" + +from __future__ import annotations + +from typing import Any + +import structlog + +from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate +from app.utils.fail2ban_client import Fail2BanClient + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +_SOCKET_TIMEOUT: float = 10.0 + + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class ServerOperationError(Exception): + """Raised when a server-level set command fails.""" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _ok(response: Any) -> Any: + """Extract payload from a fail2ban ``(code, data)`` response. + + Args: + response: Raw value returned by :meth:`~Fail2BanClient.send`. + + Returns: + The payload ``data`` portion of the response. + + Raises: + ValueError: If the return code indicates an error. + """ + try: + code, data = response + except (TypeError, ValueError) as exc: + raise ValueError(f"Unexpected response shape: {response!r}") from exc + if code != 0: + raise ValueError(f"fail2ban error {code}: {data!r}") + return data + + +async def _safe_get( + client: Fail2BanClient, + command: list[Any], + default: Any = None, +) -> Any: + """Send a command and silently return *default* on any error. + + Args: + client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use. + command: Command list to send. + default: Fallback value. + + Returns: + The successful response, or *default*. + """ + try: + return _ok(await client.send(command)) + except Exception: + return default + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def get_settings(socket_path: str) -> ServerSettingsResponse: + """Return current fail2ban server-level settings. + + Fetches log level, log target, syslog socket, database file path, purge + age, and max matches in a single round-trip batch. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.server.ServerSettingsResponse`. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + import asyncio + + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + ( + log_level_raw, + log_target_raw, + syslog_socket_raw, + db_path_raw, + db_purge_age_raw, + db_max_matches_raw, + ) = await asyncio.gather( + _safe_get(client, ["get", "loglevel"], "INFO"), + _safe_get(client, ["get", "logtarget"], "STDOUT"), + _safe_get(client, ["get", "syslogsocket"], None), + _safe_get(client, ["get", "dbfile"], "/var/lib/fail2ban/fail2ban.sqlite3"), + _safe_get(client, ["get", "dbpurgeage"], 86400), + _safe_get(client, ["get", "dbmaxmatches"], 10), + ) + + settings = ServerSettings( + log_level=str(log_level_raw or "INFO").upper(), + log_target=str(log_target_raw or "STDOUT"), + syslog_socket=str(syslog_socket_raw) if syslog_socket_raw else None, + db_path=str(db_path_raw or "/var/lib/fail2ban/fail2ban.sqlite3"), + db_purge_age=int(db_purge_age_raw or 86400), + db_max_matches=int(db_max_matches_raw or 10), + ) + + log.info("server_settings_fetched") + return ServerSettingsResponse(settings=settings) + + +async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None: + """Apply *update* to fail2ban server-level settings. + + Only non-None fields in *update* are sent. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + update: Partial update payload. + + Raises: + ServerOperationError: If any ``set`` command is rejected. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + + async def _set(key: str, value: Any) -> None: + try: + _ok(await client.send(["set", key, value])) + except ValueError as exc: + raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc + + if update.log_level is not None: + await _set("loglevel", update.log_level.upper()) + if update.log_target is not None: + await _set("logtarget", update.log_target) + if update.db_purge_age is not None: + await _set("dbpurgeage", update.db_purge_age) + if update.db_max_matches is not None: + await _set("dbmaxmatches", update.db_max_matches) + + log.info("server_settings_updated") + + +async def flush_logs(socket_path: str) -> str: + """Flush and re-open fail2ban log files. + + Useful after log rotation so the daemon starts writing to the newly + created file rather than the old rotated one. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + The response message from fail2ban (e.g. ``"OK"``) as a string. + + Raises: + ServerOperationError: If the command is rejected. + ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + try: + result = _ok(await client.send(["flushlogs"])) + log.info("logs_flushed", result=result) + return str(result) + except ValueError as exc: + raise ServerOperationError(f"flushlogs failed: {exc}") from exc diff --git a/backend/app/services/setup_service.py b/backend/app/services/setup_service.py new file mode 100644 index 0000000..f29325a --- /dev/null +++ b/backend/app/services/setup_service.py @@ -0,0 +1,201 @@ +"""Setup service. + +Implements the one-time first-run configuration wizard. Responsible for +hashing the master password, persisting all initial settings, and +enforcing the rule that setup can only run once. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import bcrypt +import structlog + +if TYPE_CHECKING: + import aiosqlite + +from app.repositories import settings_repo + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# Keys used in the settings table. +_KEY_PASSWORD_HASH = "master_password_hash" +_KEY_SETUP_DONE = "setup_completed" +_KEY_DATABASE_PATH = "database_path" +_KEY_FAIL2BAN_SOCKET = "fail2ban_socket" +_KEY_TIMEZONE = "timezone" +_KEY_SESSION_DURATION = "session_duration_minutes" +_KEY_MAP_COLOR_THRESHOLD_HIGH = "map_color_threshold_high" +_KEY_MAP_COLOR_THRESHOLD_MEDIUM = "map_color_threshold_medium" +_KEY_MAP_COLOR_THRESHOLD_LOW = "map_color_threshold_low" + + +async def is_setup_complete(db: aiosqlite.Connection) -> bool: + """Return ``True`` if initial setup has already been performed. + + Args: + db: Active aiosqlite connection. + + Returns: + ``True`` when the ``setup_completed`` key exists in settings. + """ + value = await settings_repo.get_setting(db, _KEY_SETUP_DONE) + return value == "1" + + +async def run_setup( + db: aiosqlite.Connection, + *, + master_password: str, + database_path: str, + fail2ban_socket: str, + timezone: str, + session_duration_minutes: int, +) -> None: + """Persist the initial configuration and mark setup as complete. + + Hashes *master_password* with bcrypt before storing. Raises + :class:`RuntimeError` if setup has already been completed. + + Args: + db: Active aiosqlite connection. + master_password: Plain-text master password chosen by the user. + database_path: Filesystem path to the BanGUI SQLite database. + fail2ban_socket: Unix socket path for the fail2ban daemon. + timezone: IANA timezone identifier (e.g. ``"UTC"``). + session_duration_minutes: Session validity period in minutes. + + Raises: + RuntimeError: If setup has already been completed. + """ + if await is_setup_complete(db): + raise RuntimeError("Setup has already been completed.") + + log.info("bangui_setup_started") + + # Hash the master password — bcrypt automatically generates a salt. + # Run in a thread executor so the blocking bcrypt operation does not stall + # the asyncio event loop. + password_bytes = master_password.encode() + loop = asyncio.get_running_loop() + hashed: str = await loop.run_in_executor( + None, lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode() + ) + + await settings_repo.set_setting(db, _KEY_PASSWORD_HASH, hashed) + await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path) + await settings_repo.set_setting(db, _KEY_FAIL2BAN_SOCKET, fail2ban_socket) + await settings_repo.set_setting(db, _KEY_TIMEZONE, timezone) + await settings_repo.set_setting( + db, _KEY_SESSION_DURATION, str(session_duration_minutes) + ) + # Initialize map color thresholds with default values + await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_HIGH, "100") + await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, "50") + await settings_repo.set_setting(db, _KEY_MAP_COLOR_THRESHOLD_LOW, "20") + # Mark setup as complete — must be last so a partial failure leaves + # setup_completed unset and does not lock out the user. + await settings_repo.set_setting(db, _KEY_SETUP_DONE, "1") + + log.info("bangui_setup_completed") + + +async def get_password_hash(db: aiosqlite.Connection) -> str | None: + """Return the stored bcrypt password hash, or ``None`` if not set. + + Args: + db: Active aiosqlite connection. + + Returns: + The bcrypt hash string, or ``None``. + """ + return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH) + + +async def get_timezone(db: aiosqlite.Connection) -> str: + """Return the configured IANA timezone string. + + Falls back to ``"UTC"`` when no timezone has been stored (e.g. before + setup completes or for legacy databases). + + Args: + db: Active aiosqlite connection. + + Returns: + An IANA timezone identifier such as ``"Europe/Berlin"`` or ``"UTC"``. + """ + tz = await settings_repo.get_setting(db, _KEY_TIMEZONE) + return tz if tz else "UTC" + + +async def get_map_color_thresholds( + db: aiosqlite.Connection, +) -> tuple[int, int, int]: + """Return the configured map color thresholds (high, medium, low). + + Falls back to default values (100, 50, 20) if not set. + + Args: + db: Active aiosqlite connection. + + Returns: + A tuple of (threshold_high, threshold_medium, threshold_low). + """ + high = await settings_repo.get_setting( + db, _KEY_MAP_COLOR_THRESHOLD_HIGH + ) + medium = await settings_repo.get_setting( + db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM + ) + low = await settings_repo.get_setting( + db, _KEY_MAP_COLOR_THRESHOLD_LOW + ) + + return ( + int(high) if high else 100, + int(medium) if medium else 50, + int(low) if low else 20, + ) + + +async def set_map_color_thresholds( + db: aiosqlite.Connection, + *, + threshold_high: int, + threshold_medium: int, + threshold_low: int, +) -> None: + """Update the map color threshold configuration. + + Args: + db: Active aiosqlite connection. + threshold_high: Ban count for red coloring. + threshold_medium: Ban count for yellow coloring. + threshold_low: Ban count for green coloring. + + Raises: + ValueError: If thresholds are not positive integers or if + high <= medium <= low. + """ + if threshold_high <= 0 or threshold_medium <= 0 or threshold_low <= 0: + raise ValueError("All thresholds must be positive integers.") + if not (threshold_high > threshold_medium > threshold_low): + raise ValueError("Thresholds must satisfy: high > medium > low.") + + await settings_repo.set_setting( + db, _KEY_MAP_COLOR_THRESHOLD_HIGH, str(threshold_high) + ) + await settings_repo.set_setting( + db, _KEY_MAP_COLOR_THRESHOLD_MEDIUM, str(threshold_medium) + ) + await settings_repo.set_setting( + db, _KEY_MAP_COLOR_THRESHOLD_LOW, str(threshold_low) + ) + log.info( + "map_color_thresholds_updated", + high=threshold_high, + medium=threshold_medium, + low=threshold_low, + ) diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..fa83f32 --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1 @@ +"""APScheduler background tasks package.""" diff --git a/backend/app/tasks/blocklist_import.py b/backend/app/tasks/blocklist_import.py new file mode 100644 index 0000000..80e7246 --- /dev/null +++ b/backend/app/tasks/blocklist_import.py @@ -0,0 +1,153 @@ +"""External blocklist import background task. + +Registers an APScheduler job that downloads all enabled blocklist sources, +validates their entries, and applies bans via fail2ban on a configurable +schedule. The default schedule is daily at 03:00 UTC; it is stored in the +application :class:`~app.models.blocklist.ScheduleConfig` settings and can +be updated at runtime through the blocklist router. + +The scheduler job ID is ``"blocklist_import"`` — using a stable id means +re-registering the job (e.g. after a schedule update) safely replaces the +existing entry without creating duplicates. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import structlog + +from app.models.blocklist import ScheduleFrequency +from app.services import blocklist_service + +if TYPE_CHECKING: + from fastapi import FastAPI + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +#: Stable APScheduler job id so the job can be replaced without duplicates. +JOB_ID: str = "blocklist_import" + + +async def _run_import(app: Any) -> None: + """APScheduler callback that imports all enabled blocklist sources. + + Reads shared resources from ``app.state`` and delegates to + :func:`~app.services.blocklist_service.import_all`. + + Args: + app: The :class:`fastapi.FastAPI` application instance passed via + APScheduler ``kwargs``. + """ + db = app.state.db + http_session = app.state.http_session + socket_path: str = app.state.settings.fail2ban_socket + + log.info("blocklist_import_starting") + try: + result = await blocklist_service.import_all(db, http_session, socket_path) + log.info( + "blocklist_import_finished", + total_imported=result.total_imported, + total_skipped=result.total_skipped, + errors=result.errors_count, + ) + except Exception: + log.exception("blocklist_import_unexpected_error") + + +def register(app: FastAPI) -> None: + """Add (or replace) the blocklist import job in the application scheduler. + + Reads the persisted :class:`~app.models.blocklist.ScheduleConfig` from + the database and translates it into the appropriate APScheduler trigger. + + Should be called inside the lifespan handler after the scheduler and + database have been initialised. + + Args: + app: The :class:`fastapi.FastAPI` application instance whose + ``app.state.scheduler`` will receive the job. + """ + import asyncio # noqa: PLC0415 + + async def _do_register() -> None: + config = await blocklist_service.get_schedule(app.state.db) + _apply_schedule(app, config) + + # APScheduler is synchronous at registration time; use asyncio to read + # the stored schedule from the DB before registering. + try: + loop = asyncio.get_event_loop() + loop.run_until_complete(_do_register()) + except RuntimeError: + # If the current thread already has a running loop (uvicorn), schedule + # the registration as a coroutine. + asyncio.ensure_future(_do_register()) + + +def reschedule(app: FastAPI) -> None: + """Re-register the blocklist import job with the latest schedule config. + + Called by the blocklist router after a schedule update so changes take + effect immediately without a server restart. + + Args: + app: The :class:`fastapi.FastAPI` application instance. + """ + import asyncio # noqa: PLC0415 + + async def _do_reschedule() -> None: + config = await blocklist_service.get_schedule(app.state.db) + _apply_schedule(app, config) + + asyncio.ensure_future(_do_reschedule()) + + +def _apply_schedule(app: FastAPI, config: Any) -> None: + """Add or replace the APScheduler cron/interval job for the given config. + + Args: + app: FastAPI application instance. + config: :class:`~app.models.blocklist.ScheduleConfig` to apply. + """ + scheduler = app.state.scheduler + + kwargs: dict[str, Any] = {"app": app} + trigger_type: str + trigger_kwargs: dict[str, Any] + + if config.frequency == ScheduleFrequency.hourly: + trigger_type = "interval" + trigger_kwargs = {"hours": config.interval_hours} + elif config.frequency == ScheduleFrequency.weekly: + trigger_type = "cron" + trigger_kwargs = { + "day_of_week": config.day_of_week, + "hour": config.hour, + "minute": config.minute, + } + else: # daily (default) + trigger_type = "cron" + trigger_kwargs = { + "hour": config.hour, + "minute": config.minute, + } + + # Remove existing job if it exists, then add new one. + if scheduler.get_job(JOB_ID): + scheduler.remove_job(JOB_ID) + + scheduler.add_job( + _run_import, + trigger=trigger_type, + id=JOB_ID, + kwargs=kwargs, + **trigger_kwargs, + ) + log.info( + "blocklist_import_scheduled", + frequency=config.frequency, + trigger=trigger_type, + trigger_kwargs=trigger_kwargs, + ) diff --git a/backend/app/tasks/geo_cache_flush.py b/backend/app/tasks/geo_cache_flush.py new file mode 100644 index 0000000..b225433 --- /dev/null +++ b/backend/app/tasks/geo_cache_flush.py @@ -0,0 +1,66 @@ +"""Geo cache flush background task. + +Registers an APScheduler job that periodically persists newly resolved IP +geo entries from the in-memory ``_dirty`` set to the ``geo_cache`` table. + +After Task 2 removed geo cache writes from GET requests, newly resolved IPs +are only held in the in-memory cache until this task flushes them. With the +default 60-second interval, at most one minute of new resolution results is +at risk on an unexpected process restart. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import structlog + +from app.services import geo_service + +if TYPE_CHECKING: + from fastapi import FastAPI + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +#: How often the flush job fires (seconds). Configurable tuning constant. +GEO_FLUSH_INTERVAL: int = 60 + +#: Stable APScheduler job ID — ensures re-registration replaces, not duplicates. +JOB_ID: str = "geo_cache_flush" + + +async def _run_flush(app: Any) -> None: + """Flush the geo service dirty set to the application database. + + Reads shared resources from ``app.state`` and delegates to + :func:`~app.services.geo_service.flush_dirty`. + + Args: + app: The :class:`fastapi.FastAPI` application instance passed via + APScheduler ``kwargs``. + """ + db = app.state.db + count = await geo_service.flush_dirty(db) + if count > 0: + log.debug("geo_cache_flush_ran", flushed=count) + + +def register(app: FastAPI) -> None: + """Add (or replace) the geo cache flush job in the application scheduler. + + Must be called after the scheduler has been started (i.e., inside the + lifespan handler, after ``scheduler.start()``). + + Args: + app: The :class:`fastapi.FastAPI` application instance whose + ``app.state.scheduler`` will receive the job. + """ + app.state.scheduler.add_job( + _run_flush, + trigger="interval", + seconds=GEO_FLUSH_INTERVAL, + kwargs={"app": app}, + id=JOB_ID, + replace_existing=True, + ) + log.info("geo_cache_flush_scheduled", interval_seconds=GEO_FLUSH_INTERVAL) diff --git a/backend/app/tasks/geo_re_resolve.py b/backend/app/tasks/geo_re_resolve.py new file mode 100644 index 0000000..b0880e6 --- /dev/null +++ b/backend/app/tasks/geo_re_resolve.py @@ -0,0 +1,103 @@ +"""Geo re-resolve background task. + +Registers an APScheduler job that periodically retries IP addresses in the +``geo_cache`` table whose ``country_code`` is ``NULL``. These are IPs that +previously failed to resolve (e.g. due to ip-api.com rate limiting) and were +recorded as negative entries. + +The task runs every 10 minutes. On each invocation it: + +1. Queries all ``NULL``-country rows from ``geo_cache``. +2. Clears the in-memory negative cache so those IPs are eligible for a fresh + API attempt. +3. Delegates to :func:`~app.services.geo_service.lookup_batch` which already + handles rate-limit throttling and retries. +4. Logs how many IPs were retried and how many resolved successfully. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import structlog + +from app.services import geo_service + +if TYPE_CHECKING: + from fastapi import FastAPI + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +#: How often the re-resolve job fires (seconds). 10 minutes. +GEO_RE_RESOLVE_INTERVAL: int = 600 + +#: Stable APScheduler job ID — ensures re-registration replaces, not duplicates. +JOB_ID: str = "geo_re_resolve" + + +async def _run_re_resolve(app: Any) -> None: + """Query NULL-country IPs from the database and re-resolve them. + + Reads shared resources from ``app.state`` and delegates to + :func:`~app.services.geo_service.lookup_batch`. + + Args: + app: The :class:`fastapi.FastAPI` application instance passed via + APScheduler ``kwargs``. + """ + db = app.state.db + http_session = app.state.http_session + + # Fetch all IPs with NULL country_code from the persistent cache. + unresolved_ips: list[str] = [] + async with db.execute( + "SELECT ip FROM geo_cache WHERE country_code IS NULL" + ) as cursor: + async for row in cursor: + unresolved_ips.append(str(row[0])) + + if not unresolved_ips: + log.debug("geo_re_resolve_skip", reason="no_unresolved_ips") + return + + log.info("geo_re_resolve_start", unresolved=len(unresolved_ips)) + + # Clear the negative cache so these IPs are eligible for fresh API calls. + geo_service.clear_neg_cache() + + # lookup_batch handles throttling, retries, and persistence when db is + # passed. This is a background task so DB writes are allowed. + results = await geo_service.lookup_batch(unresolved_ips, http_session, db=db) + + resolved_count: int = sum( + 1 for info in results.values() if info.country_code is not None + ) + log.info( + "geo_re_resolve_complete", + retried=len(unresolved_ips), + resolved=resolved_count, + ) + + +def register(app: FastAPI) -> None: + """Add (or replace) the geo re-resolve job in the application scheduler. + + Must be called after the scheduler has been started (i.e., inside the + lifespan handler, after ``scheduler.start()``). + + The first invocation is deferred by one full interval so the initial + blocklist prewarm has time to finish before re-resolve kicks in. + + Args: + app: The :class:`fastapi.FastAPI` application instance whose + ``app.state.scheduler`` will receive the job. + """ + app.state.scheduler.add_job( + _run_re_resolve, + trigger="interval", + seconds=GEO_RE_RESOLVE_INTERVAL, + kwargs={"app": app}, + id=JOB_ID, + replace_existing=True, + ) + log.info("geo_re_resolve_scheduled", interval_seconds=GEO_RE_RESOLVE_INTERVAL) diff --git a/backend/app/tasks/health_check.py b/backend/app/tasks/health_check.py new file mode 100644 index 0000000..6e82b69 --- /dev/null +++ b/backend/app/tasks/health_check.py @@ -0,0 +1,156 @@ +"""Health-check background task. + +Registers an APScheduler job that probes the fail2ban socket every 30 seconds +and stores the result on ``app.state.server_status``. The dashboard endpoint +reads from this cache, keeping HTTP responses fast and the daemon connection +decoupled from user-facing requests. + +Crash detection (Task 3) +------------------------ +When a jail activation is performed, the router stores a timestamp on +``app.state.last_activation`` (a ``dict`` with ``jail_name`` and ``at`` +keys). If the health probe subsequently detects an online→offline transition +within 60 seconds of that activation, a +:class:`~app.models.config.PendingRecovery` record is written to +``app.state.pending_recovery`` so the UI can offer a one-click rollback. +""" + +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Any + +import structlog + +from app.models.config import PendingRecovery +from app.models.server import ServerStatus +from app.services import health_service + +if TYPE_CHECKING: # pragma: no cover + from fastapi import FastAPI + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +#: How often the probe fires (seconds). +HEALTH_CHECK_INTERVAL: int = 30 + +#: Maximum seconds since an activation for a subsequent crash to be attributed +#: to that activation. +_ACTIVATION_CRASH_WINDOW: int = 60 + + +async def _run_probe(app: Any) -> None: + """Probe fail2ban and cache the result on *app.state*. + + Detects online/offline state transitions. When fail2ban goes offline + within :data:`_ACTIVATION_CRASH_WINDOW` seconds of the last jail + activation, writes a :class:`~app.models.config.PendingRecovery` record to + ``app.state.pending_recovery``. + + This is the APScheduler job callback. It reads ``fail2ban_socket`` from + ``app.state.settings``, runs the health probe, and writes the result to + ``app.state.server_status``. + + Args: + app: The :class:`fastapi.FastAPI` application instance passed by the + scheduler via the ``kwargs`` mechanism. + """ + socket_path: str = app.state.settings.fail2ban_socket + prev_status: ServerStatus = getattr( + app.state, "server_status", ServerStatus(online=False) + ) + status: ServerStatus = await health_service.probe(socket_path) + app.state.server_status = status + + now = datetime.datetime.now(tz=datetime.UTC) + + # Log transitions between online and offline states. + if status.online and not prev_status.online: + log.info("fail2ban_came_online", version=status.version) + # Clear any pending recovery once fail2ban is back online. + existing: PendingRecovery | None = getattr( + app.state, "pending_recovery", None + ) + if existing is not None and not existing.recovered: + app.state.pending_recovery = PendingRecovery( + jail_name=existing.jail_name, + activated_at=existing.activated_at, + detected_at=existing.detected_at, + recovered=True, + ) + log.info( + "pending_recovery_resolved", + jail=existing.jail_name, + ) + + elif not status.online and prev_status.online: + log.warning("fail2ban_went_offline") + # Check whether this crash happened shortly after a jail activation. + last_activation: dict[str, Any] | None = getattr( + app.state, "last_activation", None + ) + if last_activation is not None: + activated_at: datetime.datetime = last_activation["at"] + seconds_since = (now - activated_at).total_seconds() + if seconds_since <= _ACTIVATION_CRASH_WINDOW: + jail_name: str = last_activation["jail_name"] + # Only create a new record when there is not already an + # unresolved one for the same jail. + current: PendingRecovery | None = getattr( + app.state, "pending_recovery", None + ) + if current is None or current.recovered: + app.state.pending_recovery = PendingRecovery( + jail_name=jail_name, + activated_at=activated_at, + detected_at=now, + ) + log.warning( + "activation_crash_detected", + jail=jail_name, + seconds_since_activation=seconds_since, + ) + + log.debug( + "health_check_complete", + online=status.online, + version=status.version, + active_jails=status.active_jails, + ) + + +def register(app: FastAPI) -> None: + """Add the health-check job to the application scheduler. + + Must be called after the scheduler has been started (i.e., inside the + lifespan handler, after ``scheduler.start()``). + + Args: + app: The :class:`fastapi.FastAPI` application instance whose + ``app.state.scheduler`` will receive the job. + """ + # Initialise the cache with an offline placeholder so the dashboard + # endpoint is always able to return a valid response even before the + # first probe fires. + app.state.server_status = ServerStatus(online=False) + + # Initialise activation tracking state. + app.state.last_activation = None + app.state.pending_recovery = None + + app.state.scheduler.add_job( + _run_probe, + trigger="interval", + seconds=HEALTH_CHECK_INTERVAL, + kwargs={"app": app}, + id="health_check", + replace_existing=True, + # Fire immediately on startup too, so the UI isn't dark for 30 s. + next_run_time=__import__("datetime").datetime.now( + tz=__import__("datetime").timezone.utc + ), + ) + log.info( + "health_check_scheduled", + interval_seconds=HEALTH_CHECK_INTERVAL, + ) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..64995e6 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities, helpers, and constants package.""" diff --git a/backend/app/utils/config_parser.py b/backend/app/utils/config_parser.py new file mode 100644 index 0000000..4202917 --- /dev/null +++ b/backend/app/utils/config_parser.py @@ -0,0 +1,358 @@ +"""Fail2ban INI-style config parser with include and interpolation support. + +Provides a :class:`Fail2BanConfigParser` class that wraps Python's +:class:`configparser.RawConfigParser` with fail2ban-specific behaviour: + +- **Merge order**: ``.conf`` file first, then ``.local`` overlay, then ``*.d/`` + directory overrides — each subsequent layer overwrites earlier values. +- **Include directives**: ``[INCLUDES]`` sections can specify ``before`` and + ``after`` filenames. ``before`` is loaded at lower priority (loaded first), + ``after`` at higher priority (loaded last). Both are resolved relative to + the directory of the including file. Circular includes and runaway recursion + are detected and logged. +- **Variable interpolation**: :meth:`interpolate` resolves ``%(variable)s`` + references using the ``[DEFAULT]`` section, the ``[Init]`` section, and any + caller-supplied variables. Multiple passes handle nested references. +- **Multi-line values**: Handled transparently by ``configparser``; the + :meth:`split_multiline` helper further strips blank lines and ``#`` comments. +- **Comments**: ``configparser`` strips full-line ``#``/``;`` comments; inline + comments inside multi-line values are stripped by :meth:`split_multiline`. + +All methods are synchronous. Call from async contexts via +:func:`asyncio.get_event_loop().run_in_executor`. +""" + +from __future__ import annotations + +import configparser +import re +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from pathlib import Path + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# Compiled pattern that matches fail2ban-style %(variable_name)s references. +_INTERPOLATE_RE: re.Pattern[str] = re.compile(r"%\((\w+)\)s") + +# Guard against infinite interpolation loops. +_MAX_INTERPOLATION_PASSES: int = 10 + + +class Fail2BanConfigParser: + """Parse fail2ban INI config files with include resolution and interpolation. + + Typical usage for a ``filter.d/`` file:: + + parser = Fail2BanConfigParser(config_dir=Path("/etc/fail2ban")) + parser.read_with_overrides(Path("/etc/fail2ban/filter.d/sshd.conf")) + section = parser.section_dict("Definition") + failregex = parser.split_multiline(section.get("failregex", "")) + + Args: + config_dir: Optional fail2ban configuration root directory. Used only + by :meth:`ordered_conf_files`; pass ``None`` if not needed. + max_include_depth: Maximum ``[INCLUDES]`` nesting depth before giving up. + """ + + def __init__( + self, + config_dir: Path | None = None, + max_include_depth: int = 10, + ) -> None: + self._config_dir = config_dir + self._max_include_depth = max_include_depth + self._parser: configparser.RawConfigParser = self._make_parser() + # Tracks resolved absolute paths to detect include cycles. + self._read_paths: set[Path] = set() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _make_parser() -> configparser.RawConfigParser: + """Return a case-sensitive :class:`configparser.RawConfigParser`.""" + parser = configparser.RawConfigParser(interpolation=None, strict=False) + # Keep original key casing (fail2ban is case-sensitive in option names). + parser.optionxform = str # type: ignore[assignment] + return parser + + def _get_include( + self, + include_dir: Path, + tmp_parser: configparser.RawConfigParser, + key: str, + ) -> Path | None: + """Return the resolved path for an include directive, or ``None``.""" + if not tmp_parser.has_section("INCLUDES"): + return None + if not tmp_parser.has_option("INCLUDES", key): + return None + raw = tmp_parser.get("INCLUDES", key).strip() + if not raw: + return None + return include_dir / raw + + # ------------------------------------------------------------------ + # Public interface — reading files + # ------------------------------------------------------------------ + + def read_file(self, path: Path, _depth: int = 0) -> None: + """Read *path*, following ``[INCLUDES]`` ``before``/``after`` directives. + + ``before`` references are loaded before the current file (lower + priority); ``after`` references are loaded after (higher priority). + Circular includes are detected by tracking resolved absolute paths. + + Args: + path: Config file to read. + _depth: Current include nesting depth. Internal parameter. + """ + if _depth > self._max_include_depth: + log.warning( + "include_depth_exceeded", + path=str(path), + max_depth=self._max_include_depth, + ) + return + + resolved = path.resolve() + if resolved in self._read_paths: + log.debug("include_cycle_detected", path=str(path)) + return + + try: + content = path.read_text(encoding="utf-8") + except OSError as exc: + log.warning("config_read_error", path=str(path), error=str(exc)) + return + + # Pre-scan for includes without yet committing to the main parser. + tmp = self._make_parser() + try: + tmp.read_string(content) + except configparser.Error as exc: + log.warning("config_parse_error", path=str(path), error=str(exc)) + return + + include_dir = path.parent + before_path = self._get_include(include_dir, tmp, "before") + after_path = self._get_include(include_dir, tmp, "after") + + # Load ``before`` first (lower priority than current file). + if before_path is not None: + self.read_file(before_path, _depth=_depth + 1) + + # Mark this path visited *before* merging to guard against cycles + # introduced by the ``after`` include referencing the same file. + self._read_paths.add(resolved) + + # Merge current file into the accumulating parser. + try: + self._parser.read_string(content, source=str(path)) + except configparser.Error as exc: + log.warning( + "config_parse_string_error", path=str(path), error=str(exc) + ) + + # Load ``after`` last (highest priority). + if after_path is not None: + self.read_file(after_path, _depth=_depth + 1) + + def read_with_overrides(self, conf_path: Path) -> None: + """Read *conf_path* and its ``.local`` override if it exists. + + The ``.local`` file is read after the ``.conf`` file so its values + take precedence. Include directives inside each file are still honoured. + + Args: + conf_path: Path to the ``.conf`` file. The corresponding + ``.local`` is derived by replacing the suffix with ``.local``. + """ + self.read_file(conf_path) + local_path = conf_path.with_suffix(".local") + if local_path.is_file(): + self.read_file(local_path) + + # ------------------------------------------------------------------ + # Public interface — querying parsed data + # ------------------------------------------------------------------ + + def sections(self) -> list[str]: + """Return all section names (excludes the ``[DEFAULT]`` pseudo-section). + + Returns: + Sorted list of section names present in the parsed files. + """ + return list(self._parser.sections()) + + def has_section(self, section: str) -> bool: + """Return whether *section* exists in the parsed configuration. + + Args: + section: Section name to check. + """ + return self._parser.has_section(section) + + def get(self, section: str, key: str) -> str | None: + """Return the raw value for *key* in *section*, or ``None``. + + Args: + section: Section name. + key: Option name. + + Returns: + Raw option value string, or ``None`` if not present. + """ + if self._parser.has_section(section) and self._parser.has_option( + section, key + ): + return self._parser.get(section, key) + return None + + def section_dict( + self, + section: str, + *, + skip: frozenset[str] | None = None, + ) -> dict[str, str]: + """Return all key-value pairs from *section* as a plain :class:`dict`. + + Keys whose names start with ``__`` (configparser internals from + ``DEFAULT`` inheritance) are always excluded. + + Args: + section: Section name to read. + skip: Additional key names to exclude. + + Returns: + Mapping of option name → raw value. Empty dict if section absent. + """ + if not self._parser.has_section(section): + return {} + drop: frozenset[str] = skip or frozenset() + return { + k: v + for k, v in self._parser.items(section) + if not k.startswith("__") and k not in drop + } + + def defaults(self) -> dict[str, str]: + """Return all ``[DEFAULT]`` section key-value pairs. + + Returns: + Dict of default keys and their values. + """ + return dict(self._parser.defaults()) + + # ------------------------------------------------------------------ + # Public interface — interpolation and helpers + # ------------------------------------------------------------------ + + def interpolate( + self, + value: str, + extra_vars: dict[str, str] | None = None, + ) -> str: + """Resolve ``%(variable)s`` references in *value*. + + Variables are resolved in the following priority order (low → high): + + 1. ``[DEFAULT]`` section values. + 2. ``[Init]`` section values (fail2ban action parameters). + 3. *extra_vars* provided by the caller. + + Multiple passes are performed to handle nested references (up to + :data:`_MAX_INTERPOLATION_PASSES` iterations). Unresolvable references + are left unchanged. + + Args: + value: Raw string possibly containing ``%(name)s`` placeholders. + extra_vars: Optional caller-supplied variables (highest priority). + + Returns: + String with ``%(name)s`` references substituted where possible. + """ + vars_: dict[str, str] = {} + vars_.update(self.defaults()) + vars_.update(self.section_dict("Init")) + if extra_vars: + vars_.update(extra_vars) + + def _sub(m: re.Match[str]) -> str: + return vars_.get(m.group(1), m.group(0)) + + result = value + for _ in range(_MAX_INTERPOLATION_PASSES): + new = _INTERPOLATE_RE.sub(_sub, result) + if new == result: + break + result = new + return result + + @staticmethod + def split_multiline(raw: str) -> list[str]: + """Split a multi-line INI value into individual non-blank lines. + + Each line is stripped of surrounding whitespace. Lines that are empty + or that start with ``#`` (comments) are discarded. + + Used for ``failregex``, ``ignoreregex``, ``action``, and ``logpath`` + values which fail2ban allows to span multiple lines. + + Args: + raw: Raw multi-line string from configparser. + + Returns: + List of stripped, non-empty, non-comment strings. + """ + result: list[str] = [] + for line in raw.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + result.append(stripped) + return result + + # ------------------------------------------------------------------ + # Class-level utility — file ordering + # ------------------------------------------------------------------ + + @classmethod + def ordered_conf_files(cls, config_dir: Path, base_name: str) -> list[Path]: + """Return config files for *base_name* in fail2ban merge order. + + Merge order (ascending priority — later entries override earlier): + + 1. ``{config_dir}/{base_name}.conf`` + 2. ``{config_dir}/{base_name}.local`` + 3. ``{config_dir}/{base_name}.d/*.conf`` (sorted alphabetically) + 4. ``{config_dir}/{base_name}.d/*.local`` (sorted alphabetically) + + Args: + config_dir: Fail2ban configuration root directory. + base_name: Config base name without extension (e.g. ``"jail"``). + + Returns: + List of existing :class:`~pathlib.Path` objects in ascending + priority order (only files that actually exist are included). + """ + files: list[Path] = [] + + conf = config_dir / f"{base_name}.conf" + if conf.is_file(): + files.append(conf) + + local = config_dir / f"{base_name}.local" + if local.is_file(): + files.append(local) + + d_dir = config_dir / f"{base_name}.d" + if d_dir.is_dir(): + files.extend(sorted(d_dir.glob("*.conf"))) + files.extend(sorted(d_dir.glob("*.local"))) + + return files diff --git a/backend/app/utils/config_writer.py b/backend/app/utils/config_writer.py new file mode 100644 index 0000000..112f4e6 --- /dev/null +++ b/backend/app/utils/config_writer.py @@ -0,0 +1,303 @@ +"""Atomic config file writer for fail2ban ``.local`` override files. + +All write operations are atomic: content is first written to a temporary file +in the same directory as the target, then :func:`os.replace` is used to rename +it into place. This guarantees that a crash or power failure during the write +never leaves a partially-written file behind. + +A per-file :class:`threading.Lock` prevents concurrent writes from the same +process from racing. + +Security constraints +-------------------- +- Every write function asserts that the target path **ends in ``.local``**. + This prevents accidentally writing to ``.conf`` files (which belong to the + fail2ban package and should never be modified by BanGUI). + +Public functions +---------------- +- :func:`write_local_override` — create or update keys inside a ``.local`` file. +- :func:`remove_local_key` — remove a single key from a ``.local`` file. +- :func:`delete_local_file` — delete an entire ``.local`` file. +""" + +from __future__ import annotations + +import configparser +import contextlib +import io +import os +import tempfile +import threading +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from pathlib import Path + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Per-file lock registry +# --------------------------------------------------------------------------- + +# Maps resolved absolute path strings → threading.Lock instances. +_locks: dict[str, threading.Lock] = {} +# Guards the _locks dict itself. +_registry_lock: threading.Lock = threading.Lock() + + +def _get_file_lock(path: Path) -> threading.Lock: + """Return the per-file :class:`threading.Lock` for *path*. + + The lock is created on first access and reused on subsequent calls. + + Args: + path: Target file path (need not exist yet). + + Returns: + :class:`threading.Lock` bound to the resolved absolute path of *path*. + """ + key = str(path.resolve()) + with _registry_lock: + if key not in _locks: + _locks[key] = threading.Lock() + return _locks[key] + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _assert_local_file(path: Path) -> None: + """Raise :class:`ValueError` if *path* does not end with ``.local``. + + This is a safety guard against accidentally modifying ``.conf`` files. + + Args: + path: Path to validate. + + Raises: + ValueError: When *path* does not have a ``.local`` suffix. + """ + if path.suffix != ".local": + raise ValueError( + f"Refusing to write to non-.local file: {path!r}. " + "Only .local override files may be modified by BanGUI." + ) + + +def _make_parser() -> configparser.RawConfigParser: + """Return a case-sensitive :class:`configparser.RawConfigParser`.""" + parser = configparser.RawConfigParser(interpolation=None, strict=False) + parser.optionxform = str # type: ignore[assignment] + return parser + + +def _read_or_new_parser(path: Path) -> configparser.RawConfigParser: + """Read *path* into a parser, or return a fresh empty parser. + + If the file does not exist or cannot be read, a fresh parser is returned. + Any parse errors are logged as warnings (not re-raised). + + Args: + path: Path to the ``.local`` file to read. + + Returns: + Populated (or empty) :class:`configparser.RawConfigParser`. + """ + parser = _make_parser() + if path.is_file(): + try: + content = path.read_text(encoding="utf-8") + parser.read_string(content) + except (OSError, configparser.Error) as exc: + log.warning("local_file_read_error", path=str(path), error=str(exc)) + return parser + + +def _write_parser_atomic( + parser: configparser.RawConfigParser, + path: Path, +) -> None: + """Write *parser* contents to *path* atomically. + + Writes to a temporary file in the same directory as *path*, then renames + the temporary file over *path* using :func:`os.replace`. The temporary + file is cleaned up on failure. + + Args: + parser: Populated parser whose contents should be written. + path: Destination ``.local`` file path. + + Raises: + OSError: On filesystem errors (propagated to caller). + """ + buf = io.StringIO() + parser.write(buf) + content = buf.getvalue() + + path.parent.mkdir(parents=True, exist_ok=True) + + fd, tmp_path_str = tempfile.mkstemp( + dir=str(path.parent), + prefix=f".{path.name}.tmp", + suffix="", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path_str, str(path)) + except Exception: + with contextlib.suppress(OSError): + os.unlink(tmp_path_str) + raise + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def write_local_override( + base_path: Path, + section: str, + key_values: dict[str, str], +) -> None: + """Create or update keys in a ``.local`` override file. + + If the file already exists, only the specified *key_values* are written + under *section*; all other sections and keys are preserved. + + If the file does not exist, it is created with the given *section* and + *key_values*. + + The write is **atomic**: a temporary file is written and renamed into place. + + Args: + base_path: Absolute path to the ``.local`` file (e.g. + ``filter.d/sshd.local``). The parent directory is created if it + does not already exist. + section: INI section name (e.g. ``"Definition"``, ``"Init"``). + key_values: Mapping of option name → value to write/update. + + Raises: + ValueError: If *base_path* does not end with ``.local``. + """ + _assert_local_file(base_path) + + lock = _get_file_lock(base_path) + with lock: + parser = _read_or_new_parser(base_path) + + if not parser.has_section(section): + parser.add_section(section) + + for key, value in key_values.items(): + parser.set(section, key, value) + + log.info( + "local_override_written", + path=str(base_path), + section=section, + keys=sorted(key_values), + ) + _write_parser_atomic(parser, base_path) + + +def remove_local_key(base_path: Path, section: str, key: str) -> None: + """Remove a single key from a ``.local`` override file. + + Post-removal cleanup: + + - If the section becomes empty after key removal, the section is also + removed. + - If no sections remain after section removal, the file is deleted. + + This function is a no-op when the file, section, or key does not exist. + + Args: + base_path: Path to the ``.local`` file to update. + section: INI section containing the key. + key: Option name to remove. + + Raises: + ValueError: If *base_path* does not end with ``.local``. + """ + _assert_local_file(base_path) + + if not base_path.is_file(): + return + + lock = _get_file_lock(base_path) + with lock: + parser = _read_or_new_parser(base_path) + + if not parser.has_section(section) or not parser.has_option(section, key): + return # Nothing to remove. + + parser.remove_option(section, key) + + # Remove the section if it has no remaining options. + if not parser.options(section): + parser.remove_section(section) + + # Delete the file entirely if it has no remaining sections. + if not parser.sections(): + with contextlib.suppress(OSError): + base_path.unlink() + log.info("local_file_deleted_empty", path=str(base_path)) + return + + log.info( + "local_key_removed", + path=str(base_path), + section=section, + key=key, + ) + _write_parser_atomic(parser, base_path) + + +def delete_local_file(path: Path, *, allow_orphan: bool = False) -> None: + """Delete a ``.local`` override file. + + By default, refuses to delete a ``.local`` file that has no corresponding + ``.conf`` file (an *orphan* ``.local``), because it may be the only copy of + a user-defined config. Pass ``allow_orphan=True`` to override this guard. + + Args: + path: Path to the ``.local`` file to delete. + allow_orphan: When ``True``, delete even if no corresponding ``.conf`` + exists alongside *path*. + + Raises: + ValueError: If *path* does not end with ``.local``. + FileNotFoundError: If *path* does not exist. + OSError: If no corresponding ``.conf`` exists and *allow_orphan* is + ``False``. + """ + _assert_local_file(path) + + if not path.is_file(): + raise FileNotFoundError(f"Local file not found: {path!r}") + + if not allow_orphan: + conf_path = path.with_suffix(".conf") + if not conf_path.is_file(): + raise OSError( + f"No corresponding .conf file found for {path!r}. " + "Pass allow_orphan=True to delete a local-only file." + ) + + lock = _get_file_lock(path) + with lock: + try: + path.unlink() + log.info("local_file_deleted", path=str(path)) + except OSError as exc: + log.error( + "local_file_delete_failed", path=str(path), error=str(exc) + ) + raise diff --git a/backend/app/utils/constants.py b/backend/app/utils/constants.py new file mode 100644 index 0000000..88626bd --- /dev/null +++ b/backend/app/utils/constants.py @@ -0,0 +1,78 @@ +"""Application-wide constants. + +All magic numbers, default paths, and limit values live here. +Import from this module rather than hard-coding values in business logic. +""" + +from typing import Final + +# --------------------------------------------------------------------------- +# fail2ban integration +# --------------------------------------------------------------------------- + +DEFAULT_FAIL2BAN_SOCKET: Final[str] = "/var/run/fail2ban/fail2ban.sock" +"""Default path to the fail2ban Unix domain socket.""" + +FAIL2BAN_SOCKET_TIMEOUT_SECONDS: Final[float] = 5.0 +"""Maximum seconds to wait for a response from the fail2ban socket.""" + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- + +DEFAULT_DATABASE_PATH: Final[str] = "bangui.db" +"""Default filename for the BanGUI application SQLite database.""" + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + +DEFAULT_SESSION_DURATION_MINUTES: Final[int] = 60 +"""Default session lifetime in minutes.""" + +SESSION_TOKEN_BYTES: Final[int] = 64 +"""Number of random bytes used when generating a session token.""" + +# --------------------------------------------------------------------------- +# Time-range presets (used by dashboard and history endpoints) +# --------------------------------------------------------------------------- + +TIME_RANGE_24H: Final[str] = "24h" +TIME_RANGE_7D: Final[str] = "7d" +TIME_RANGE_30D: Final[str] = "30d" +TIME_RANGE_365D: Final[str] = "365d" + +VALID_TIME_RANGES: Final[frozenset[str]] = frozenset( + {TIME_RANGE_24H, TIME_RANGE_7D, TIME_RANGE_30D, TIME_RANGE_365D} +) + +TIME_RANGE_HOURS: Final[dict[str, int]] = { + TIME_RANGE_24H: 24, + TIME_RANGE_7D: 7 * 24, + TIME_RANGE_30D: 30 * 24, + TIME_RANGE_365D: 365 * 24, +} + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + +DEFAULT_PAGE_SIZE: Final[int] = 50 +MAX_PAGE_SIZE: Final[int] = 500 + +# --------------------------------------------------------------------------- +# Blocklist import +# --------------------------------------------------------------------------- + +BLOCKLIST_IMPORT_DEFAULT_HOUR: Final[int] = 3 +"""Default hour (UTC) for the nightly blocklist import job.""" + +BLOCKLIST_PREVIEW_MAX_LINES: Final[int] = 100 +"""Maximum number of IP lines returned by the blocklist preview endpoint.""" + +# --------------------------------------------------------------------------- +# Health check +# --------------------------------------------------------------------------- + +HEALTH_CHECK_INTERVAL_SECONDS: Final[int] = 30 +"""How often the background health-check task polls fail2ban.""" diff --git a/backend/app/utils/fail2ban_client.py b/backend/app/utils/fail2ban_client.py new file mode 100644 index 0000000..51ebe97 --- /dev/null +++ b/backend/app/utils/fail2ban_client.py @@ -0,0 +1,318 @@ +"""Async wrapper around the fail2ban Unix domain socket protocol. + +fail2ban uses a proprietary binary protocol over a Unix domain socket: +commands are transmitted as pickle-serialised Python lists and responses +are returned the same way. The protocol constants (``END``, ``CLOSE``) +come from ``fail2ban.protocol.CSPROTO``. + +Because the underlying socket is blocking, all I/O is dispatched to a +thread-pool executor so the FastAPI event loop is never blocked. + +Usage:: + + async with Fail2BanClient(socket_path="/var/run/fail2ban/fail2ban.sock") as client: + status = await client.send(["status"]) +""" + +from __future__ import annotations + +import asyncio +import contextlib +import errno +import socket +import time +from pickle import HIGHEST_PROTOCOL, dumps, loads +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from types import TracebackType + +import structlog + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# fail2ban protocol constants — inline to avoid a hard import dependency +# at module load time (the fail2ban-master path may not be on sys.path yet +# in some test environments). +_PROTO_END: bytes = b"" +_PROTO_CLOSE: bytes = b"" +_PROTO_EMPTY: bytes = b"" + +# Default receive buffer size (doubles on each iteration up to max). +_RECV_BUFSIZE_START: int = 1024 +_RECV_BUFSIZE_MAX: int = 32768 + +# OSError errno values that indicate a transient socket condition and may be +# safely retried. ENOENT (socket file missing) is intentionally excluded so +# a missing socket raises immediately without delay. +_RETRYABLE_ERRNOS: frozenset[int] = frozenset( + {errno.EAGAIN, errno.ECONNREFUSED, errno.ENOBUFS} +) + +# Retry policy for _send_command_sync. +_RETRY_MAX_ATTEMPTS: int = 3 +_RETRY_INITIAL_BACKOFF: float = 0.15 # seconds; doubles on each attempt + +# Maximum number of concurrent in-flight socket commands. Operations that +# exceed this cap wait until a slot is available. +_COMMAND_SEMAPHORE_CONCURRENCY: int = 10 +# The semaphore is created lazily on the first send() call so it binds to the +# event loop that is actually running (important for test isolation). +_command_semaphore: asyncio.Semaphore | None = None + + +class Fail2BanConnectionError(Exception): + """Raised when the fail2ban socket is unreachable or returns an error.""" + + def __init__(self, message: str, socket_path: str) -> None: + """Initialise with a human-readable message and the socket path. + + Args: + message: Description of the connection problem. + socket_path: The fail2ban socket path that was targeted. + """ + self.socket_path: str = socket_path + super().__init__(f"{message} (socket: {socket_path})") + + +class Fail2BanProtocolError(Exception): + """Raised when the response from fail2ban cannot be parsed.""" + + +def _send_command_sync( + socket_path: str, + command: list[Any], + timeout: float, +) -> Any: + """Send a command to fail2ban and return the parsed response. + + This is a **synchronous** function intended to be called from within + :func:`asyncio.get_event_loop().run_in_executor` so that the event loop + is not blocked. + + Transient ``OSError`` conditions (``EAGAIN``, ``ECONNREFUSED``, + ``ENOBUFS``) are retried up to :data:`_RETRY_MAX_ATTEMPTS` times with + exponential back-off starting at :data:`_RETRY_INITIAL_BACKOFF` seconds. + All other ``OSError`` variants (including ``ENOENT`` — socket file + missing) and :class:`Fail2BanProtocolError` are raised immediately. + A structured log event ``fail2ban_socket_retry`` is emitted for each + retry attempt. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + command: List of command tokens, e.g. ``["status", "sshd"]``. + timeout: Socket timeout in seconds. + + Returns: + The deserialized Python object returned by fail2ban. + + Raises: + Fail2BanConnectionError: If the socket cannot be reached after all + retry attempts, or immediately for non-retryable errors. + Fail2BanProtocolError: If the response cannot be unpickled. + """ + last_oserror: OSError | None = None + for attempt in range(1, _RETRY_MAX_ATTEMPTS + 1): + sock: socket.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(timeout) + sock.connect(socket_path) + + # Serialise and send the command. + payload: bytes = dumps( + list(map(_coerce_command_token, command)), + HIGHEST_PROTOCOL, + ) + sock.sendall(payload) + sock.sendall(_PROTO_END) + + # Receive until we see the end marker. + raw: bytes = _PROTO_EMPTY + bufsize: int = _RECV_BUFSIZE_START + while raw.rfind(_PROTO_END, -32) == -1: + chunk: bytes = sock.recv(bufsize) + if not chunk: + raise Fail2BanConnectionError( + "Connection closed unexpectedly by fail2ban", + socket_path, + ) + if chunk == _PROTO_END: + break + raw += chunk + if bufsize < _RECV_BUFSIZE_MAX: + bufsize <<= 1 + + try: + return loads(raw) + except Exception as exc: + raise Fail2BanProtocolError( + f"Failed to unpickle fail2ban response: {exc}" + ) from exc + except Fail2BanProtocolError: + # Protocol errors are never transient — raise immediately. + raise + except Fail2BanConnectionError: + # Mid-receive close or empty-chunk error — raise immediately. + raise + except OSError as exc: + is_retryable = exc.errno in _RETRYABLE_ERRNOS + if is_retryable and attempt < _RETRY_MAX_ATTEMPTS: + log.warning( + "fail2ban_socket_retry", + attempt=attempt, + socket_errno=exc.errno, + socket_path=socket_path, + ) + last_oserror = exc + time.sleep(_RETRY_INITIAL_BACKOFF * (2 ** (attempt - 1))) + continue + raise Fail2BanConnectionError(str(exc), socket_path) from exc + finally: + with contextlib.suppress(OSError): + sock.sendall(_PROTO_CLOSE + _PROTO_END) + with contextlib.suppress(OSError): + sock.shutdown(socket.SHUT_RDWR) + sock.close() + + # Exhausted all retry attempts — surface the last transient error. + raise Fail2BanConnectionError( + str(last_oserror), socket_path + ) from last_oserror + + +def _coerce_command_token(token: Any) -> Any: + """Coerce a command token to a type that fail2ban understands. + + fail2ban's ``CSocket.convert`` accepts ``str``, ``bool``, ``int``, + ``float``, ``list``, ``dict``, and ``set``. Any other type is + stringified. + + Args: + token: A single token from the command list. + + Returns: + The token in a type safe for pickle transmission to fail2ban. + """ + if isinstance(token, (str, bool, int, float, list, dict, set)): + return token + return str(token) + + +class Fail2BanClient: + """Async client for communicating with the fail2ban daemon via its socket. + + All blocking socket I/O is offloaded to the default thread-pool executor + so the asyncio event loop remains unblocked. + + The client can be used as an async context manager:: + + async with Fail2BanClient(socket_path) as client: + result = await client.send(["status"]) + + Or instantiated directly and closed manually:: + + client = Fail2BanClient(socket_path) + result = await client.send(["status"]) + """ + + def __init__( + self, + socket_path: str, + timeout: float = 5.0, + ) -> None: + """Initialise the client. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + timeout: Socket I/O timeout in seconds. + """ + self.socket_path: str = socket_path + self.timeout: float = timeout + + async def send(self, command: list[Any]) -> Any: + """Send a command to fail2ban and return the response. + + Acquires the module-level concurrency semaphore before dispatching + so that no more than :data:`_COMMAND_SEMAPHORE_CONCURRENCY` commands + are in-flight at the same time. Commands that exceed the cap are + queued until a slot becomes available. A debug-level log event is + emitted when a command must wait. + + The command is serialised as a pickle list, sent to the socket, and + the response is deserialised before being returned. + + Args: + command: A list of command tokens, e.g. ``["status", "sshd"]``. + + Returns: + The Python object returned by fail2ban (typically a list or dict). + + Raises: + Fail2BanConnectionError: If the socket cannot be reached or the + connection is unexpectedly closed. + Fail2BanProtocolError: If the response cannot be decoded. + """ + global _command_semaphore + if _command_semaphore is None: + _command_semaphore = asyncio.Semaphore(_COMMAND_SEMAPHORE_CONCURRENCY) + + if _command_semaphore.locked(): + log.debug( + "fail2ban_command_waiting_semaphore", + command=command, + concurrency_limit=_COMMAND_SEMAPHORE_CONCURRENCY, + ) + + async with _command_semaphore: + log.debug("fail2ban_sending_command", command=command) + loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + try: + response: Any = await loop.run_in_executor( + None, + _send_command_sync, + self.socket_path, + command, + self.timeout, + ) + except Fail2BanConnectionError: + log.warning( + "fail2ban_connection_error", + socket_path=self.socket_path, + command=command, + ) + raise + except Fail2BanProtocolError: + log.error( + "fail2ban_protocol_error", + socket_path=self.socket_path, + command=command, + ) + raise + log.debug("fail2ban_received_response", command=command) + return response + + async def ping(self) -> bool: + """Return ``True`` if the fail2ban daemon is reachable. + + Sends a ``ping`` command and checks for a ``pong`` response. + + Returns: + ``True`` when the daemon responds correctly, ``False`` otherwise. + """ + try: + response: Any = await self.send(["ping"]) + return bool(response == 1) # fail2ban returns 1 on successful ping + except (Fail2BanConnectionError, Fail2BanProtocolError): + return False + + async def __aenter__(self) -> Fail2BanClient: + """Return self when used as an async context manager.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """No-op exit — each command opens and closes its own socket.""" diff --git a/backend/app/utils/ip_utils.py b/backend/app/utils/ip_utils.py new file mode 100644 index 0000000..11e74fc --- /dev/null +++ b/backend/app/utils/ip_utils.py @@ -0,0 +1,101 @@ +"""IP address and CIDR range validation and normalisation utilities. + +All IP handling in BanGUI goes through these helpers to enforce consistency +and prevent malformed addresses from reaching fail2ban. +""" + +import ipaddress + + +def is_valid_ip(address: str) -> bool: + """Return ``True`` if *address* is a valid IPv4 or IPv6 address. + + Args: + address: The string to validate. + + Returns: + ``True`` if the string represents a valid IP address, ``False`` otherwise. + """ + try: + ipaddress.ip_address(address) + return True + except ValueError: + return False + + +def is_valid_network(cidr: str) -> bool: + """Return ``True`` if *cidr* is a valid IPv4 or IPv6 network in CIDR notation. + + Args: + cidr: The string to validate, e.g. ``"192.168.0.0/24"``. + + Returns: + ``True`` if the string is a valid CIDR network, ``False`` otherwise. + """ + try: + ipaddress.ip_network(cidr, strict=False) + return True + except ValueError: + return False + + +def is_valid_ip_or_network(value: str) -> bool: + """Return ``True`` if *value* is a valid IP address or CIDR network. + + Args: + value: The string to validate. + + Returns: + ``True`` if the string is a valid IP address or CIDR range. + """ + return is_valid_ip(value) or is_valid_network(value) + + +def normalise_ip(address: str) -> str: + """Return a normalised string representation of an IP address. + + IPv6 addresses are compressed to their canonical short form. + IPv4 addresses are returned unchanged. + + Args: + address: A valid IP address string. + + Returns: + Normalised IP address string. + + Raises: + ValueError: If *address* is not a valid IP address. + """ + return str(ipaddress.ip_address(address)) + + +def normalise_network(cidr: str) -> str: + """Return a normalised string representation of a CIDR network. + + Host bits are masked to produce the network address. + + Args: + cidr: A valid CIDR network string, e.g. ``"192.168.1.5/24"``. + + Returns: + Normalised network string, e.g. ``"192.168.1.0/24"``. + + Raises: + ValueError: If *cidr* is not a valid network. + """ + return str(ipaddress.ip_network(cidr, strict=False)) + + +def ip_version(address: str) -> int: + """Return 4 or 6 depending on the IP version of *address*. + + Args: + address: A valid IP address string. + + Returns: + ``4`` for IPv4, ``6`` for IPv6. + + Raises: + ValueError: If *address* is not a valid IP address. + """ + return ipaddress.ip_address(address).version diff --git a/backend/app/utils/time_utils.py b/backend/app/utils/time_utils.py new file mode 100644 index 0000000..fc648d1 --- /dev/null +++ b/backend/app/utils/time_utils.py @@ -0,0 +1,67 @@ +"""Timezone-aware datetime helpers. + +All datetimes in BanGUI are stored and transmitted in UTC. +Conversion to the user's display timezone happens only at the presentation +layer (frontend). These utilities provide a consistent, safe foundation +for working with time throughout the backend. +""" + +import datetime + + +def utc_now() -> datetime.datetime: + """Return the current UTC time as a timezone-aware :class:`datetime.datetime`. + + Returns: + Current UTC datetime with ``tzinfo=datetime.UTC``. + """ + return datetime.datetime.now(datetime.UTC) + + +def utc_from_timestamp(ts: float) -> datetime.datetime: + """Convert a POSIX timestamp to a timezone-aware UTC datetime. + + Args: + ts: POSIX timestamp (seconds since Unix epoch). + + Returns: + Timezone-aware UTC :class:`datetime.datetime`. + """ + return datetime.datetime.fromtimestamp(ts, tz=datetime.UTC) + + +def add_minutes(dt: datetime.datetime, minutes: int) -> datetime.datetime: + """Return a new datetime that is *minutes* ahead of *dt*. + + Args: + dt: The source datetime (must be timezone-aware). + minutes: Number of minutes to add. May be negative. + + Returns: + A new timezone-aware :class:`datetime.datetime`. + """ + return dt + datetime.timedelta(minutes=minutes) + + +def is_expired(expires_at: datetime.datetime) -> bool: + """Return ``True`` if *expires_at* is in the past relative to UTC now. + + Args: + expires_at: The expiry timestamp to check (must be timezone-aware). + + Returns: + ``True`` when the timestamp is past, ``False`` otherwise. + """ + return utc_now() >= expires_at + + +def hours_ago(hours: int) -> datetime.datetime: + """Return a timezone-aware UTC datetime *hours* before now. + + Args: + hours: Number of hours to subtract from the current time. + + Returns: + Timezone-aware UTC :class:`datetime.datetime`. + """ + return utc_now() - datetime.timedelta(hours=hours) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..beab707 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bangui-backend" +version = "0.1.0" +description = "BanGUI backend — fail2ban web management interface" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "pydantic>=2.9.0", + "pydantic-settings>=2.6.0", + "aiosqlite>=0.20.0", + "aiohttp>=3.11.0", + "apscheduler>=3.10,<4.0", + "structlog>=24.4.0", + "bcrypt>=4.2.0", + "geoip2>=4.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.27.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "pytest-cov>=6.0.0", + "pytest-mock>=3.14.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "TCH"] +ignore = ["B008"] # FastAPI uses function calls in default arguments (Depends) + +[tool.ruff.lint.per-file-ignores] +# sys.path manipulation before stdlib imports is intentional in test helpers +# pytest evaluates fixture type annotations at runtime, so TC001/TC002/TC003 are false-positives +"tests/**" = ["E402", "TC001", "TC002", "TC003"] +"app/routers/**" = ["TC001", "TC002"] # FastAPI evaluates Depends() type aliases at runtime via get_type_hints() + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.12" +strict = true +plugins = ["pydantic.mypy"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = [".", "../fail2ban-master"] +testpaths = ["tests"] +addopts = "--cov=app --cov-report=term-missing" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..46816dd --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..44fc64c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,77 @@ +"""Shared pytest fixtures for the BanGUI backend test suite. + +All fixtures are async-compatible via pytest-asyncio. External dependencies +(fail2ban socket, HTTP APIs) are always mocked so tests never touch real +infrastructure. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure the bundled fail2ban package is importable. +_FAIL2BAN_MASTER: Path = Path(__file__).resolve().parents[2] / "fail2ban-master" +if str(_FAIL2BAN_MASTER) not in sys.path: + sys.path.insert(0, str(_FAIL2BAN_MASTER)) + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app + + +@pytest.fixture +def test_settings(tmp_path: Path) -> Settings: + """Return a ``Settings`` instance configured for testing. + + Uses a temporary directory for the database so tests are isolated from + each other and from the development database. + + Args: + tmp_path: Pytest-provided temporary directory (unique per test). + + Returns: + A :class:`~app.config.Settings` instance with overridden paths. + """ + return Settings( + database_path=str(tmp_path / "test_bangui.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-secret-key-do-not-use-in-production", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + + +@pytest.fixture +async def client(test_settings: Settings) -> AsyncClient: # type: ignore[misc] + """Provide an ``AsyncClient`` wired to a test instance of the BanGUI app. + + The client sends requests directly to the ASGI application (no network). + ``app.state.db`` is initialised manually so router tests can use the + database without triggering the full ASGI lifespan. + + Args: + test_settings: Injected test settings fixture. + + Yields: + An :class:`httpx.AsyncClient` with ``base_url="http://test"``. + """ + app = create_app(settings=test_settings) + + # Bootstrap the database on app.state so Depends(get_db) works in tests. + # The ASGI lifespan is not triggered by ASGITransport, so we do this here. + db: aiosqlite.Connection = await aiosqlite.connect(test_settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + + transport: ASGITransport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + await db.close() diff --git a/backend/tests/scripts/__init__.py b/backend/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/scripts/seed_10k_bans.py b/backend/tests/scripts/seed_10k_bans.py new file mode 100644 index 0000000..8d2da6d --- /dev/null +++ b/backend/tests/scripts/seed_10k_bans.py @@ -0,0 +1,213 @@ +"""Seed 10 000 synthetic bans into the fail2ban dev database. + +Usage:: + + cd backend + python tests/scripts/seed_10k_bans.py [--db-path /path/to/fail2ban.sqlite3] + +This script inserts 10 000 synthetic ban rows spread over the last 365 days +into the fail2ban SQLite database and pre-resolves all synthetic IPs into the +BanGUI geo_cache. Run it once to get realistic dashboard and map load times +in the browser without requiring a live fail2ban instance with active traffic. + +.. warning:: + This script **writes** to the fail2ban database. Only use it against the + development database (``Docker/fail2ban-dev-config/fail2ban.sqlite3`` or + equivalent). Never run it against a production database. +""" + +from __future__ import annotations + +import argparse +import logging +import random +import sqlite3 +import sys +import time +from pathlib import Path + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Default paths +# --------------------------------------------------------------------------- + +_DEFAULT_F2B_DB: str = str( + Path(__file__).resolve().parents[3] / "Docker" / "fail2ban-dev-config" / "fail2ban.sqlite3" +) +_DEFAULT_APP_DB: str = str( + Path(__file__).resolve().parents[2] / "bangui.db" +) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BAN_COUNT: int = 10_000 +_YEAR_SECONDS: int = 365 * 24 * 3600 +_JAIL_POOL: list[str] = ["sshd", "nginx", "blocklist-import", "postfix", "dovecot"] +_COUNTRY_POOL: list[tuple[str, str]] = [ + ("DE", "Germany"), + ("US", "United States"), + ("CN", "China"), + ("RU", "Russia"), + ("FR", "France"), + ("BR", "Brazil"), + ("IN", "India"), + ("GB", "United Kingdom"), + ("NL", "Netherlands"), + ("CA", "Canada"), +] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _random_ip() -> str: + """Return a random dotted-decimal IPv4 string in public ranges.""" + return ".".join(str(random.randint(1, 254)) for _ in range(4)) + + +def _seed_bans(f2b_db_path: str) -> list[str]: + """Insert 10 000 synthetic ban rows into the fail2ban SQLite database. + + Uses the synchronous ``sqlite3`` module because fail2ban itself uses + synchronous writes and the schema is straightforward. + + Args: + f2b_db_path: Filesystem path to the fail2ban SQLite database. + + Returns: + List of all IP addresses inserted. + """ + now = int(time.time()) + ips: list[str] = [_random_ip() for _ in range(_BAN_COUNT)] + rows = [ + ( + random.choice(_JAIL_POOL), + ip, + now - random.randint(0, _YEAR_SECONDS), + 3600, + random.randint(1, 10), + None, + ) + for ip in ips + ] + + with sqlite3.connect(f2b_db_path) as con: + # Ensure the bans table exists (for dev environments where fail2ban + # may not have created it yet). + con.execute( + "CREATE TABLE IF NOT EXISTS bans (" + "jail TEXT NOT NULL, " + "ip TEXT, " + "timeofban INTEGER NOT NULL, " + "bantime INTEGER NOT NULL DEFAULT 3600, " + "bancount INTEGER NOT NULL DEFAULT 1, " + "data JSON" + ")" + ) + con.executemany( + "INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) " + "VALUES (?, ?, ?, ?, ?, ?)", + rows, + ) + con.commit() + + log.info("Inserted %d ban rows into %s", _BAN_COUNT, f2b_db_path) + return ips + + +def _seed_geo_cache(app_db_path: str, ips: list[str]) -> None: + """Pre-populate the BanGUI geo_cache table for all inserted IPs. + + Assigns synthetic country data cycling through :data:`_COUNTRY_POOL` so + the world map shows a realistic distribution of countries without making + any real HTTP requests. + + Args: + app_db_path: Filesystem path to the BanGUI application database. + ips: List of IP addresses to pre-cache. + """ + country_cycle = _COUNTRY_POOL * (len(ips) // len(_COUNTRY_POOL) + 1) + rows = [ + (ip, cc, cn, f"AS{1000 + i % 500}", f"Synthetic ISP {i % 50}") + for i, (ip, (cc, cn)) in enumerate(zip(ips, country_cycle, strict=False)) + ] + + with sqlite3.connect(app_db_path) as con: + con.execute( + "CREATE TABLE IF NOT EXISTS geo_cache (" + "ip TEXT PRIMARY KEY, " + "country_code TEXT, " + "country_name TEXT, " + "asn TEXT, " + "org TEXT, " + "cached_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + ")" + ) + con.executemany( + """ + INSERT INTO geo_cache (ip, country_code, country_name, asn, org) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(ip) DO UPDATE SET + country_code = excluded.country_code, + country_name = excluded.country_name, + asn = excluded.asn, + org = excluded.org + """, + rows, + ) + con.commit() + + log.info("Pre-cached geo data for %d IPs in %s", len(ips), app_db_path) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Parse CLI arguments and run the seed operation.""" + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + parser = argparse.ArgumentParser( + description="Seed 10 000 synthetic bans for performance testing." + ) + parser.add_argument( + "--f2b-db", + default=_DEFAULT_F2B_DB, + help=f"Path to the fail2ban SQLite database (default: {_DEFAULT_F2B_DB})", + ) + parser.add_argument( + "--app-db", + default=_DEFAULT_APP_DB, + help=f"Path to the BanGUI application database (default: {_DEFAULT_APP_DB})", + ) + args = parser.parse_args() + + f2b_path = Path(args.f2b_db) + app_path = Path(args.app_db) + + if not f2b_path.parent.exists(): + log.error("fail2ban DB directory does not exist: %s", f2b_path.parent) + sys.exit(1) + + if not app_path.parent.exists(): + log.error("App DB directory does not exist: %s", app_path.parent) + sys.exit(1) + + log.info("Seeding %d bans into: %s", _BAN_COUNT, f2b_path) + ips = _seed_bans(str(f2b_path)) + + log.info("Pre-caching geo data into: %s", app_path) + _seed_geo_cache(str(app_path), ips) + + log.info("Done. Restart the BanGUI backend to load the new geo cache entries.") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_repositories/__init__.py b/backend/tests/test_repositories/__init__.py new file mode 100644 index 0000000..adc074a --- /dev/null +++ b/backend/tests/test_repositories/__init__.py @@ -0,0 +1 @@ +"""Repository test package.""" diff --git a/backend/tests/test_repositories/test_blocklist.py b/backend/tests/test_repositories/test_blocklist.py new file mode 100644 index 0000000..9c09b9a --- /dev/null +++ b/backend/tests/test_repositories/test_blocklist.py @@ -0,0 +1,210 @@ +"""Tests for blocklist_repo and import_log_repo.""" + +from __future__ import annotations + +from pathlib import Path + +import aiosqlite +import pytest + +from app.db import init_db +from app.repositories import blocklist_repo, import_log_repo + + +@pytest.fixture +async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] + """Provide an initialised aiosqlite connection for repository tests.""" + conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "bl_test.db")) + conn.row_factory = aiosqlite.Row + await init_db(conn) + yield conn + await conn.close() + + +# --------------------------------------------------------------------------- +# blocklist_repo tests +# --------------------------------------------------------------------------- + + +class TestBlocklistRepo: + async def test_create_source_returns_int_id(self, db: aiosqlite.Connection) -> None: + """create_source returns a positive integer id.""" + source_id = await blocklist_repo.create_source(db, "Test", "https://example.com/list.txt") + assert isinstance(source_id, int) + assert source_id > 0 + + async def test_get_source_returns_row(self, db: aiosqlite.Connection) -> None: + """get_source returns the correct row after creation.""" + source_id = await blocklist_repo.create_source(db, "Alpha", "https://alpha.test/ips.txt") + row = await blocklist_repo.get_source(db, source_id) + assert row is not None + assert row["name"] == "Alpha" + assert row["url"] == "https://alpha.test/ips.txt" + assert row["enabled"] is True + + async def test_get_source_missing_returns_none(self, db: aiosqlite.Connection) -> None: + """get_source returns None for a non-existent id.""" + result = await blocklist_repo.get_source(db, 9999) + assert result is None + + async def test_list_sources_empty(self, db: aiosqlite.Connection) -> None: + """list_sources returns empty list when no sources exist.""" + rows = await blocklist_repo.list_sources(db) + assert rows == [] + + async def test_list_sources_returns_all(self, db: aiosqlite.Connection) -> None: + """list_sources returns all created sources.""" + await blocklist_repo.create_source(db, "A", "https://a.test/") + await blocklist_repo.create_source(db, "B", "https://b.test/") + rows = await blocklist_repo.list_sources(db) + assert len(rows) == 2 + + async def test_list_enabled_sources_filters(self, db: aiosqlite.Connection) -> None: + """list_enabled_sources only returns rows with enabled=True.""" + await blocklist_repo.create_source(db, "Enabled", "https://on.test/", enabled=True) + id2 = await blocklist_repo.create_source(db, "Disabled", "https://off.test/", enabled=False) + await blocklist_repo.update_source(db, id2, enabled=False) + rows = await blocklist_repo.list_enabled_sources(db) + assert len(rows) == 1 + assert rows[0]["name"] == "Enabled" + + async def test_update_source_name(self, db: aiosqlite.Connection) -> None: + """update_source changes the name field.""" + source_id = await blocklist_repo.create_source(db, "Old", "https://old.test/") + updated = await blocklist_repo.update_source(db, source_id, name="New") + assert updated is True + row = await blocklist_repo.get_source(db, source_id) + assert row is not None + assert row["name"] == "New" + + async def test_update_source_enabled_false(self, db: aiosqlite.Connection) -> None: + """update_source can disable a source.""" + source_id = await blocklist_repo.create_source(db, "On", "https://on.test/") + await blocklist_repo.update_source(db, source_id, enabled=False) + row = await blocklist_repo.get_source(db, source_id) + assert row is not None + assert row["enabled"] is False + + async def test_update_source_missing_returns_false(self, db: aiosqlite.Connection) -> None: + """update_source returns False for a non-existent id.""" + result = await blocklist_repo.update_source(db, 9999, name="Ghost") + assert result is False + + async def test_delete_source_removes_row(self, db: aiosqlite.Connection) -> None: + """delete_source removes the row and returns True.""" + source_id = await blocklist_repo.create_source(db, "Del", "https://del.test/") + deleted = await blocklist_repo.delete_source(db, source_id) + assert deleted is True + assert await blocklist_repo.get_source(db, source_id) is None + + async def test_delete_source_missing_returns_false(self, db: aiosqlite.Connection) -> None: + """delete_source returns False for a non-existent id.""" + result = await blocklist_repo.delete_source(db, 9999) + assert result is False + + +# --------------------------------------------------------------------------- +# import_log_repo tests +# --------------------------------------------------------------------------- + + +class TestImportLogRepo: + async def test_add_log_returns_id(self, db: aiosqlite.Connection) -> None: + """add_log returns a positive integer id.""" + log_id = await import_log_repo.add_log( + db, + source_id=None, + source_url="https://example.com/list.txt", + ips_imported=10, + ips_skipped=2, + errors=None, + ) + assert isinstance(log_id, int) + assert log_id > 0 + + async def test_list_logs_returns_all(self, db: aiosqlite.Connection) -> None: + """list_logs returns all logs when no source_id filter is applied.""" + for i in range(3): + await import_log_repo.add_log( + db, + source_id=None, + source_url=f"https://s{i}.test/", + ips_imported=i * 5, + ips_skipped=0, + errors=None, + ) + items, total = await import_log_repo.list_logs(db) + assert total == 3 + assert len(items) == 3 + + async def test_list_logs_pagination(self, db: aiosqlite.Connection) -> None: + """list_logs respects page and page_size.""" + for i in range(5): + await import_log_repo.add_log( + db, + source_id=None, + source_url=f"https://p{i}.test/", + ips_imported=1, + ips_skipped=0, + errors=None, + ) + items, total = await import_log_repo.list_logs(db, page=2, page_size=2) + assert total == 5 + assert len(items) == 2 + + async def test_list_logs_source_filter(self, db: aiosqlite.Connection) -> None: + """list_logs filters by source_id.""" + source_id = await blocklist_repo.create_source(db, "Src", "https://s.test/") + await import_log_repo.add_log( + db, + source_id=source_id, + source_url="https://s.test/", + ips_imported=5, + ips_skipped=0, + errors=None, + ) + await import_log_repo.add_log( + db, + source_id=None, + source_url="https://other.test/", + ips_imported=3, + ips_skipped=0, + errors=None, + ) + items, total = await import_log_repo.list_logs(db, source_id=source_id) + assert total == 1 + assert items[0]["source_url"] == "https://s.test/" + + async def test_get_last_log_empty(self, db: aiosqlite.Connection) -> None: + """get_last_log returns None when no logs exist.""" + result = await import_log_repo.get_last_log(db) + assert result is None + + async def test_get_last_log_returns_most_recent(self, db: aiosqlite.Connection) -> None: + """get_last_log returns the most recently inserted entry.""" + await import_log_repo.add_log( + db, + source_id=None, + source_url="https://first.test/", + ips_imported=1, + ips_skipped=0, + errors=None, + ) + await import_log_repo.add_log( + db, + source_id=None, + source_url="https://last.test/", + ips_imported=2, + ips_skipped=0, + errors=None, + ) + last = await import_log_repo.get_last_log(db) + assert last is not None + assert last["source_url"] == "https://last.test/" + + async def test_compute_total_pages(self) -> None: + """compute_total_pages returns correct page count.""" + assert import_log_repo.compute_total_pages(0, 10) == 1 + assert import_log_repo.compute_total_pages(10, 10) == 1 + assert import_log_repo.compute_total_pages(11, 10) == 2 + assert import_log_repo.compute_total_pages(20, 5) == 4 diff --git a/backend/tests/test_repositories/test_db_init.py b/backend/tests/test_repositories/test_db_init.py new file mode 100644 index 0000000..fef6ce8 --- /dev/null +++ b/backend/tests/test_repositories/test_db_init.py @@ -0,0 +1,69 @@ +"""Tests for app.db — database schema initialisation.""" + +from pathlib import Path + +import aiosqlite +import pytest + +from app.db import init_db + + +@pytest.mark.asyncio +async def test_init_db_creates_settings_table(tmp_path: Path) -> None: + """``init_db`` must create the ``settings`` table.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + async with db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='settings';" + ) as cursor: + row = await cursor.fetchone() + assert row is not None + + +@pytest.mark.asyncio +async def test_init_db_creates_sessions_table(tmp_path: Path) -> None: + """``init_db`` must create the ``sessions`` table.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + async with db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='sessions';" + ) as cursor: + row = await cursor.fetchone() + assert row is not None + + +@pytest.mark.asyncio +async def test_init_db_creates_blocklist_sources_table(tmp_path: Path) -> None: + """``init_db`` must create the ``blocklist_sources`` table.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + async with db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='blocklist_sources';" + ) as cursor: + row = await cursor.fetchone() + assert row is not None + + +@pytest.mark.asyncio +async def test_init_db_creates_import_log_table(tmp_path: Path) -> None: + """``init_db`` must create the ``import_log`` table.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + async with db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='import_log';" + ) as cursor: + row = await cursor.fetchone() + assert row is not None + + +@pytest.mark.asyncio +async def test_init_db_is_idempotent(tmp_path: Path) -> None: + """Calling ``init_db`` twice on the same database must not raise.""" + db_path = str(tmp_path / "test.db") + async with aiosqlite.connect(db_path) as db: + await init_db(db) + await init_db(db) # Second call must be a no-op. diff --git a/backend/tests/test_repositories/test_settings_and_session.py b/backend/tests/test_repositories/test_settings_and_session.py new file mode 100644 index 0000000..b212d36 --- /dev/null +++ b/backend/tests/test_repositories/test_settings_and_session.py @@ -0,0 +1,118 @@ +"""Tests for settings_repo and session_repo.""" + +from __future__ import annotations + +from pathlib import Path + +import aiosqlite +import pytest + +from app.db import init_db +from app.repositories import session_repo, settings_repo + + +@pytest.fixture +async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] + """Provide an initialised aiosqlite connection.""" + conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "repo_test.db")) + conn.row_factory = aiosqlite.Row + await init_db(conn) + yield conn + await conn.close() + + +class TestSettingsRepo: + async def test_get_missing_key_returns_none( + self, db: aiosqlite.Connection + ) -> None: + """get_setting returns None for a key that does not exist.""" + result = await settings_repo.get_setting(db, "nonexistent") + assert result is None + + async def test_set_and_get_round_trip(self, db: aiosqlite.Connection) -> None: + """set_setting persists a value retrievable by get_setting.""" + await settings_repo.set_setting(db, "my_key", "my_value") + result = await settings_repo.get_setting(db, "my_key") + assert result == "my_value" + + async def test_set_overwrites_existing_value( + self, db: aiosqlite.Connection + ) -> None: + """set_setting overwrites an existing key with the new value.""" + await settings_repo.set_setting(db, "key", "first") + await settings_repo.set_setting(db, "key", "second") + result = await settings_repo.get_setting(db, "key") + assert result == "second" + + async def test_delete_removes_key(self, db: aiosqlite.Connection) -> None: + """delete_setting removes an existing key.""" + await settings_repo.set_setting(db, "to_delete", "value") + await settings_repo.delete_setting(db, "to_delete") + result = await settings_repo.get_setting(db, "to_delete") + assert result is None + + async def test_get_all_settings_returns_dict( + self, db: aiosqlite.Connection + ) -> None: + """get_all_settings returns a dict of all stored key-value pairs.""" + await settings_repo.set_setting(db, "k1", "v1") + await settings_repo.set_setting(db, "k2", "v2") + all_s = await settings_repo.get_all_settings(db) + assert all_s["k1"] == "v1" + assert all_s["k2"] == "v2" + + +class TestSessionRepo: + async def test_create_and_get_session(self, db: aiosqlite.Connection) -> None: + """create_session stores a session retrievable by get_session.""" + session = await session_repo.create_session( + db, + token="abc123", + created_at="2025-01-01T00:00:00+00:00", + expires_at="2025-01-01T01:00:00+00:00", + ) + assert session.token == "abc123" + + stored = await session_repo.get_session(db, "abc123") + assert stored is not None + assert stored.token == "abc123" + + async def test_get_missing_session_returns_none( + self, db: aiosqlite.Connection + ) -> None: + """get_session returns None for a token that does not exist.""" + result = await session_repo.get_session(db, "no_such_token") + assert result is None + + async def test_delete_session_removes_it(self, db: aiosqlite.Connection) -> None: + """delete_session removes the session from the database.""" + await session_repo.create_session( + db, + token="xyz", + created_at="2025-01-01T00:00:00+00:00", + expires_at="2025-01-01T01:00:00+00:00", + ) + await session_repo.delete_session(db, "xyz") + result = await session_repo.get_session(db, "xyz") + assert result is None + + async def test_delete_expired_sessions(self, db: aiosqlite.Connection) -> None: + """delete_expired_sessions removes sessions past their expiry time.""" + await session_repo.create_session( + db, + token="expired", + created_at="2020-01-01T00:00:00+00:00", + expires_at="2020-01-01T01:00:00+00:00", + ) + await session_repo.create_session( + db, + token="valid", + created_at="2099-01-01T00:00:00+00:00", + expires_at="2099-01-01T01:00:00+00:00", + ) + deleted = await session_repo.delete_expired_sessions( + db, "2025-01-01T00:00:00+00:00" + ) + assert deleted == 1 + assert await session_repo.get_session(db, "expired") is None + assert await session_repo.get_session(db, "valid") is not None diff --git a/backend/tests/test_routers/__init__.py b/backend/tests/test_routers/__init__.py new file mode 100644 index 0000000..c00f198 --- /dev/null +++ b/backend/tests/test_routers/__init__.py @@ -0,0 +1 @@ +"""Router test package.""" diff --git a/backend/tests/test_routers/test_auth.py b/backend/tests/test_routers/test_auth.py new file mode 100644 index 0000000..afd59d7 --- /dev/null +++ b/backend/tests/test_routers/test_auth.py @@ -0,0 +1,252 @@ +"""Tests for the auth router (POST /api/auth/login, POST /api/auth/logout).""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from httpx import AsyncClient + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "mysecretpass1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +async def _do_setup(client: AsyncClient) -> None: + """Run the setup wizard so auth endpoints are reachable.""" + resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + +async def _login(client: AsyncClient, password: str = "mysecretpass1") -> str: + """Helper: perform login and return the session token.""" + resp = await client.post("/api/auth/login", json={"password": password}) + assert resp.status_code == 200 + return str(resp.json()["token"]) + + +# --------------------------------------------------------------------------- +# Login +# --------------------------------------------------------------------------- + + +class TestLogin: + """POST /api/auth/login.""" + + async def test_login_succeeds_with_correct_password( + self, client: AsyncClient + ) -> None: + """Login returns 200 and a session token for the correct password.""" + await _do_setup(client) + response = await client.post( + "/api/auth/login", json={"password": "mysecretpass1"} + ) + assert response.status_code == 200 + body = response.json() + assert "token" in body + assert len(body["token"]) > 0 + assert "expires_at" in body + + async def test_login_sets_cookie(self, client: AsyncClient) -> None: + """Login sets the bangui_session HttpOnly cookie.""" + await _do_setup(client) + response = await client.post( + "/api/auth/login", json={"password": "mysecretpass1"} + ) + assert response.status_code == 200 + assert "bangui_session" in response.cookies + + async def test_login_fails_with_wrong_password( + self, client: AsyncClient + ) -> None: + """Login returns 401 for an incorrect password.""" + await _do_setup(client) + response = await client.post( + "/api/auth/login", json={"password": "wrongpassword"} + ) + assert response.status_code == 401 + + async def test_login_rejects_empty_password(self, client: AsyncClient) -> None: + """Login returns 422 when password field is missing.""" + await _do_setup(client) + response = await client.post("/api/auth/login", json={}) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# Logout +# --------------------------------------------------------------------------- + + +class TestLogout: + """POST /api/auth/logout.""" + + async def test_logout_returns_200(self, client: AsyncClient) -> None: + """Logout returns 200 with a confirmation message.""" + await _do_setup(client) + await _login(client) + response = await client.post("/api/auth/logout") + assert response.status_code == 200 + assert "message" in response.json() + + async def test_logout_clears_cookie(self, client: AsyncClient) -> None: + """Logout clears the bangui_session cookie.""" + await _do_setup(client) + await _login(client) # sets cookie on client + response = await client.post("/api/auth/logout") + assert response.status_code == 200 + # Cookie should be set to empty / deleted in the Set-Cookie header. + set_cookie = response.headers.get("set-cookie", "") + assert "bangui_session" in set_cookie + + async def test_logout_is_idempotent(self, client: AsyncClient) -> None: + """Logout succeeds even when called without a session token.""" + await _do_setup(client) + response = await client.post("/api/auth/logout") + assert response.status_code == 200 + + async def test_session_invalid_after_logout( + self, client: AsyncClient + ) -> None: + """A session token is rejected after logout.""" + await _do_setup(client) + token = await _login(client) + + await client.post("/api/auth/logout") + + # Now try to use the invalidated token via Bearer header. The health + # endpoint is unprotected so we validate against a hypothetical + # protected endpoint by inspecting the auth service directly. + # Here we just confirm the token is no longer in the DB by trying + # to re-use it on logout (idempotent — still 200, not an error). + response = await client.post( + "/api/auth/logout", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Auth dependency (protected route guard) +# --------------------------------------------------------------------------- + + +class TestRequireAuth: + """Verify the require_auth dependency rejects unauthenticated requests.""" + + async def test_health_endpoint_requires_no_auth( + self, client: AsyncClient + ) -> None: + """Health endpoint is accessible without authentication.""" + + +# --------------------------------------------------------------------------- +# Session-token cache (Task 4) +# --------------------------------------------------------------------------- + + +class TestRequireAuthSessionCache: + """In-memory session token cache inside ``require_auth``.""" + + @pytest.fixture(autouse=True) + def reset_cache(self) -> None: # type: ignore[misc] + """Flush the session cache before and after every test in this class.""" + from app import dependencies + + dependencies.clear_session_cache() + yield # type: ignore[misc] + dependencies.clear_session_cache() + + async def test_second_request_skips_db(self, client: AsyncClient) -> None: + """Second authenticated request within TTL skips the session DB query. + + The first request populates the in-memory cache via ``require_auth``. + The second request — using the same token before the TTL expires — + must return ``session_repo.get_session`` *without* calling it. + """ + from app.repositories import session_repo + + await _do_setup(client) + token = await _login(client) + + # Ensure cache is empty so the first request definitely hits the DB. + from app import dependencies + + dependencies.clear_session_cache() + + call_count = 0 + original_get_session = session_repo.get_session + + async def _tracking(db, tok): # type: ignore[no-untyped-def] + nonlocal call_count + call_count += 1 + return await original_get_session(db, tok) + + with patch.object(session_repo, "get_session", side_effect=_tracking): + resp1 = await client.get( + "/api/dashboard/status", + headers={"Authorization": f"Bearer {token}"}, + ) + resp2 = await client.get( + "/api/dashboard/status", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + # DB queried exactly once: the first request populates the cache, + # the second request is served entirely from memory. + assert call_count == 1 + + async def test_token_enters_cache_after_first_auth( + self, client: AsyncClient + ) -> None: + """A successful auth request places the token in ``_session_cache``.""" + from app import dependencies + + await _do_setup(client) + token = await _login(client) + + dependencies.clear_session_cache() + assert token not in dependencies._session_cache + + await client.get( + "/api/dashboard/status", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert token in dependencies._session_cache + + async def test_logout_evicts_token_from_cache( + self, client: AsyncClient + ) -> None: + """Logout removes the session token from the in-memory cache immediately.""" + from app import dependencies + + await _do_setup(client) + token = await _login(client) + + # Warm the cache. + await client.get( + "/api/dashboard/status", + headers={"Authorization": f"Bearer {token}"}, + ) + assert token in dependencies._session_cache + + # Logout must evict the entry. + await client.post( + "/api/auth/logout", + headers={"Authorization": f"Bearer {token}"}, + ) + assert token not in dependencies._session_cache + + response = await client.get("/api/health") + assert response.status_code == 200 diff --git a/backend/tests/test_routers/test_bans.py b/backend/tests/test_routers/test_bans.py new file mode 100644 index 0000000..2be5d96 --- /dev/null +++ b/backend/tests/test_routers/test_bans.py @@ -0,0 +1,331 @@ +"""Tests for the bans router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.ban import ActiveBan, ActiveBanListResponse +from app.utils.fail2ban_client import Fail2BanConnectionError + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def bans_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for bans endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "bans_test.db"), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-bans-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.post("/api/setup", json=_SETUP_PAYLOAD) + login = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login.status_code == 200 + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# GET /api/bans/active +# --------------------------------------------------------------------------- + + +class TestGetActiveBans: + """Tests for ``GET /api/bans/active``.""" + + async def test_200_when_authenticated(self, bans_client: AsyncClient) -> None: + """GET /api/bans/active returns 200 with an ActiveBanListResponse.""" + mock_response = ActiveBanListResponse( + bans=[ + ActiveBan( + ip="1.2.3.4", + jail="sshd", + banned_at="2025-01-01T12:00:00+00:00", + expires_at="2025-01-01T13:00:00+00:00", + ban_count=1, + country="DE", + ) + ], + total=1, + ) + with patch( + "app.routers.bans.jail_service.get_active_bans", + AsyncMock(return_value=mock_response), + ): + resp = await bans_client.get("/api/bans/active") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["bans"][0]["ip"] == "1.2.3.4" + assert data["bans"][0]["jail"] == "sshd" + + async def test_401_when_unauthenticated(self, bans_client: AsyncClient) -> None: + """GET /api/bans/active returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/bans/active") + assert resp.status_code == 401 + + async def test_empty_when_no_bans(self, bans_client: AsyncClient) -> None: + """GET /api/bans/active returns empty list when no bans are active.""" + mock_response = ActiveBanListResponse(bans=[], total=0) + with patch( + "app.routers.bans.jail_service.get_active_bans", + AsyncMock(return_value=mock_response), + ): + resp = await bans_client.get("/api/bans/active") + + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + assert resp.json()["bans"] == [] + + async def test_response_shape(self, bans_client: AsyncClient) -> None: + """GET /api/bans/active returns expected fields per ban entry.""" + mock_response = ActiveBanListResponse( + bans=[ + ActiveBan( + ip="10.0.0.1", + jail="nginx", + banned_at=None, + expires_at=None, + ban_count=1, + country=None, + ) + ], + total=1, + ) + with patch( + "app.routers.bans.jail_service.get_active_bans", + AsyncMock(return_value=mock_response), + ): + resp = await bans_client.get("/api/bans/active") + + ban = resp.json()["bans"][0] + assert "ip" in ban + assert "jail" in ban + assert "banned_at" in ban + assert "expires_at" in ban + assert "ban_count" in ban + + +# --------------------------------------------------------------------------- +# POST /api/bans +# --------------------------------------------------------------------------- + + +class TestBanIp: + """Tests for ``POST /api/bans``.""" + + async def test_201_on_success(self, bans_client: AsyncClient) -> None: + """POST /api/bans returns 201 when the IP is banned.""" + with patch( + "app.routers.bans.jail_service.ban_ip", + AsyncMock(return_value=None), + ): + resp = await bans_client.post( + "/api/bans", + json={"ip": "1.2.3.4", "jail": "sshd"}, + ) + + assert resp.status_code == 201 + assert resp.json()["jail"] == "sshd" + + async def test_400_for_invalid_ip(self, bans_client: AsyncClient) -> None: + """POST /api/bans returns 400 for an invalid IP address.""" + with patch( + "app.routers.bans.jail_service.ban_ip", + AsyncMock(side_effect=ValueError("Invalid IP address: 'bad'")), + ): + resp = await bans_client.post( + "/api/bans", + json={"ip": "bad", "jail": "sshd"}, + ) + + assert resp.status_code == 400 + + async def test_404_for_unknown_jail(self, bans_client: AsyncClient) -> None: + """POST /api/bans returns 404 when jail does not exist.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.bans.jail_service.ban_ip", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await bans_client.post( + "/api/bans", + json={"ip": "1.2.3.4", "jail": "ghost"}, + ) + + assert resp.status_code == 404 + + async def test_401_when_unauthenticated(self, bans_client: AsyncClient) -> None: + """POST /api/bans returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/bans", json={"ip": "1.2.3.4", "jail": "sshd"}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# DELETE /api/bans +# --------------------------------------------------------------------------- + + +class TestUnbanIp: + """Tests for ``DELETE /api/bans``.""" + + async def test_200_unban_from_all(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans with unban_all=true unbans from all jails.""" + with patch( + "app.routers.bans.jail_service.unban_ip", + AsyncMock(return_value=None), + ): + resp = await bans_client.request( + "DELETE", + "/api/bans", + json={"ip": "1.2.3.4", "unban_all": True}, + ) + + assert resp.status_code == 200 + assert "all jails" in resp.json()["message"] + + async def test_200_unban_from_specific_jail(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans with a jail unbans from that jail only.""" + with patch( + "app.routers.bans.jail_service.unban_ip", + AsyncMock(return_value=None), + ): + resp = await bans_client.request( + "DELETE", + "/api/bans", + json={"ip": "1.2.3.4", "jail": "sshd"}, + ) + + assert resp.status_code == 200 + assert "sshd" in resp.json()["message"] + + async def test_400_for_invalid_ip(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans returns 400 for an invalid IP.""" + with patch( + "app.routers.bans.jail_service.unban_ip", + AsyncMock(side_effect=ValueError("Invalid IP address: 'bad'")), + ): + resp = await bans_client.request( + "DELETE", + "/api/bans", + json={"ip": "bad", "unban_all": True}, + ) + + assert resp.status_code == 400 + + async def test_404_for_unknown_jail(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans returns 404 when jail does not exist.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.bans.jail_service.unban_ip", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await bans_client.request( + "DELETE", + "/api/bans", + json={"ip": "1.2.3.4", "jail": "ghost"}, + ) + + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /api/bans/all +# --------------------------------------------------------------------------- + + +class TestUnbanAll: + """Tests for ``DELETE /api/bans/all``.""" + + async def test_200_clears_all_bans(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans/all returns 200 with count when successful.""" + with patch( + "app.routers.bans.jail_service.unban_all_ips", + AsyncMock(return_value=3), + ): + resp = await bans_client.request("DELETE", "/api/bans/all") + + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 3 + assert "3" in data["message"] + + async def test_200_with_zero_count(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans/all returns 200 with count=0 when no bans existed.""" + with patch( + "app.routers.bans.jail_service.unban_all_ips", + AsyncMock(return_value=0), + ): + resp = await bans_client.request("DELETE", "/api/bans/all") + + assert resp.status_code == 200 + assert resp.json()["count"] == 0 + + async def test_502_when_fail2ban_unreachable( + self, bans_client: AsyncClient + ) -> None: + """DELETE /api/bans/all returns 502 when fail2ban is unreachable.""" + with patch( + "app.routers.bans.jail_service.unban_all_ips", + AsyncMock( + side_effect=Fail2BanConnectionError( + "cannot connect", + "/var/run/fail2ban/fail2ban.sock", + ) + ), + ): + resp = await bans_client.request("DELETE", "/api/bans/all") + + assert resp.status_code == 502 + + async def test_401_when_unauthenticated(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans/all returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).request("DELETE", "/api/bans/all") + assert resp.status_code == 401 diff --git a/backend/tests/test_routers/test_blocklist.py b/backend/tests/test_routers/test_blocklist.py new file mode 100644 index 0000000..354bfcc --- /dev/null +++ b/backend/tests/test_routers/test_blocklist.py @@ -0,0 +1,472 @@ +"""Tests for the blocklist router (9 endpoints).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.blocklist import ( + BlocklistListResponse, + BlocklistSource, + ImportLogListResponse, + ImportRunResult, + ImportSourceResult, + PreviewResponse, + ScheduleConfig, + ScheduleFrequency, + ScheduleInfo, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +def _make_source(source_id: int = 1) -> BlocklistSource: + return BlocklistSource( + id=source_id, + name="Test Source", + url="https://test.test/ips.txt", + enabled=True, + created_at="2026-01-01T00:00:00Z", + updated_at="2026-01-01T00:00:00Z", + ) + + +def _make_source_list() -> BlocklistListResponse: + return BlocklistListResponse(sources=[_make_source(1), _make_source(2)]) + + +def _make_schedule_info() -> ScheduleInfo: + return ScheduleInfo( + config=ScheduleConfig( + frequency=ScheduleFrequency.daily, + interval_hours=24, + hour=3, + minute=0, + day_of_week=0, + ), + next_run_at="2026-02-01T03:00:00+00:00", + last_run_at=None, + ) + + +def _make_import_result() -> ImportRunResult: + return ImportRunResult( + results=[ + ImportSourceResult( + source_id=1, + source_url="https://test.test/ips.txt", + ips_imported=5, + ips_skipped=1, + error=None, + ) + ], + total_imported=5, + total_skipped=1, + errors_count=0, + ) + + +def _make_log_response() -> ImportLogListResponse: + return ImportLogListResponse( + items=[], total=0, page=1, page_size=50, total_pages=1 + ) + + +def _make_preview() -> PreviewResponse: + return PreviewResponse( + entries=["1.2.3.4", "5.6.7.8"], + total_lines=10, + valid_count=8, + skipped_count=2, + ) + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def bl_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated AsyncClient for blocklist endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "bl_router_test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-bl-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + # Provide a minimal scheduler stub so the router can call .get_job(). + scheduler_stub = MagicMock() + scheduler_stub.get_job = MagicMock(return_value=None) + app.state.scheduler = scheduler_stub + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + login_resp = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login_resp.status_code == 200 + + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# GET /api/blocklists +# --------------------------------------------------------------------------- + + +class TestListBlocklists: + async def test_authenticated_returns_200(self, bl_client: AsyncClient) -> None: + """Authenticated request to list sources returns HTTP 200.""" + with patch( + "app.routers.blocklist.blocklist_service.list_sources", + new=AsyncMock(return_value=_make_source_list().sources), + ): + resp = await bl_client.get("/api/blocklists") + assert resp.status_code == 200 + + async def test_returns_401_unauthenticated(self, client: AsyncClient) -> None: + """Unauthenticated request returns 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + resp = await client.get("/api/blocklists") + assert resp.status_code == 401 + + async def test_response_contains_sources_key(self, bl_client: AsyncClient) -> None: + """Response body has a 'sources' array.""" + with patch( + "app.routers.blocklist.blocklist_service.list_sources", + new=AsyncMock(return_value=[_make_source()]), + ): + resp = await bl_client.get("/api/blocklists") + body = resp.json() + assert "sources" in body + assert isinstance(body["sources"], list) + + +# --------------------------------------------------------------------------- +# POST /api/blocklists +# --------------------------------------------------------------------------- + + +class TestCreateBlocklist: + async def test_create_returns_201(self, bl_client: AsyncClient) -> None: + """POST /api/blocklists creates a source and returns HTTP 201.""" + with patch( + "app.routers.blocklist.blocklist_service.create_source", + new=AsyncMock(return_value=_make_source()), + ): + resp = await bl_client.post( + "/api/blocklists", + json={"name": "Test", "url": "https://test.test/", "enabled": True}, + ) + assert resp.status_code == 201 + + async def test_create_source_id_in_response(self, bl_client: AsyncClient) -> None: + """Created source response includes the id field.""" + with patch( + "app.routers.blocklist.blocklist_service.create_source", + new=AsyncMock(return_value=_make_source(42)), + ): + resp = await bl_client.post( + "/api/blocklists", + json={"name": "Test", "url": "https://test.test/", "enabled": True}, + ) + assert resp.json()["id"] == 42 + + +# --------------------------------------------------------------------------- +# PUT /api/blocklists/{id} +# --------------------------------------------------------------------------- + + +class TestUpdateBlocklist: + async def test_update_returns_200(self, bl_client: AsyncClient) -> None: + """PUT /api/blocklists/1 returns 200 for a found source.""" + updated = _make_source() + updated.enabled = False + with patch( + "app.routers.blocklist.blocklist_service.update_source", + new=AsyncMock(return_value=updated), + ): + resp = await bl_client.put( + "/api/blocklists/1", + json={"enabled": False}, + ) + assert resp.status_code == 200 + + async def test_update_returns_404_for_missing(self, bl_client: AsyncClient) -> None: + """PUT /api/blocklists/999 returns 404 when source does not exist.""" + with patch( + "app.routers.blocklist.blocklist_service.update_source", + new=AsyncMock(return_value=None), + ): + resp = await bl_client.put( + "/api/blocklists/999", + json={"enabled": False}, + ) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /api/blocklists/{id} +# --------------------------------------------------------------------------- + + +class TestDeleteBlocklist: + async def test_delete_returns_204(self, bl_client: AsyncClient) -> None: + """DELETE /api/blocklists/1 returns 204 for a found source.""" + with patch( + "app.routers.blocklist.blocklist_service.delete_source", + new=AsyncMock(return_value=True), + ): + resp = await bl_client.delete("/api/blocklists/1") + assert resp.status_code == 204 + + async def test_delete_returns_404_for_missing(self, bl_client: AsyncClient) -> None: + """DELETE /api/blocklists/999 returns 404 when source does not exist.""" + with patch( + "app.routers.blocklist.blocklist_service.delete_source", + new=AsyncMock(return_value=False), + ): + resp = await bl_client.delete("/api/blocklists/999") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /api/blocklists/{id}/preview +# --------------------------------------------------------------------------- + + +class TestPreviewBlocklist: + async def test_preview_returns_200(self, bl_client: AsyncClient) -> None: + """GET /api/blocklists/1/preview returns 200 for existing source.""" + with patch( + "app.routers.blocklist.blocklist_service.get_source", + new=AsyncMock(return_value=_make_source()), + ), patch( + "app.routers.blocklist.blocklist_service.preview_source", + new=AsyncMock(return_value=_make_preview()), + ): + resp = await bl_client.get("/api/blocklists/1/preview") + assert resp.status_code == 200 + + async def test_preview_returns_404_for_missing(self, bl_client: AsyncClient) -> None: + """GET /api/blocklists/999/preview returns 404 when source not found.""" + with patch( + "app.routers.blocklist.blocklist_service.get_source", + new=AsyncMock(return_value=None), + ): + resp = await bl_client.get("/api/blocklists/999/preview") + assert resp.status_code == 404 + + async def test_preview_returns_502_on_download_error( + self, bl_client: AsyncClient + ) -> None: + """GET /api/blocklists/1/preview returns 502 when URL is unreachable.""" + with patch( + "app.routers.blocklist.blocklist_service.get_source", + new=AsyncMock(return_value=_make_source()), + ), patch( + "app.routers.blocklist.blocklist_service.preview_source", + new=AsyncMock(side_effect=ValueError("Connection refused")), + ): + resp = await bl_client.get("/api/blocklists/1/preview") + assert resp.status_code == 502 + + async def test_preview_response_shape(self, bl_client: AsyncClient) -> None: + """Preview response has entries, valid_count, skipped_count, total_lines.""" + with patch( + "app.routers.blocklist.blocklist_service.get_source", + new=AsyncMock(return_value=_make_source()), + ), patch( + "app.routers.blocklist.blocklist_service.preview_source", + new=AsyncMock(return_value=_make_preview()), + ): + resp = await bl_client.get("/api/blocklists/1/preview") + body = resp.json() + assert "entries" in body + assert "valid_count" in body + assert "skipped_count" in body + assert "total_lines" in body + + +# --------------------------------------------------------------------------- +# POST /api/blocklists/import +# --------------------------------------------------------------------------- + + +class TestRunImport: + async def test_import_returns_200(self, bl_client: AsyncClient) -> None: + """POST /api/blocklists/import returns 200 with aggregated results.""" + with patch( + "app.routers.blocklist.blocklist_service.import_all", + new=AsyncMock(return_value=_make_import_result()), + ): + resp = await bl_client.post("/api/blocklists/import") + assert resp.status_code == 200 + + async def test_import_response_shape(self, bl_client: AsyncClient) -> None: + """Import response has results, total_imported, total_skipped, errors_count.""" + with patch( + "app.routers.blocklist.blocklist_service.import_all", + new=AsyncMock(return_value=_make_import_result()), + ): + resp = await bl_client.post("/api/blocklists/import") + body = resp.json() + assert "total_imported" in body + assert "total_skipped" in body + assert "errors_count" in body + assert "results" in body + + +# --------------------------------------------------------------------------- +# GET /api/blocklists/schedule +# --------------------------------------------------------------------------- + + +class TestGetSchedule: + async def test_schedule_returns_200(self, bl_client: AsyncClient) -> None: + """GET /api/blocklists/schedule returns 200.""" + with patch( + "app.routers.blocklist.blocklist_service.get_schedule_info", + new=AsyncMock(return_value=_make_schedule_info()), + ): + resp = await bl_client.get("/api/blocklists/schedule") + assert resp.status_code == 200 + + async def test_schedule_response_has_config(self, bl_client: AsyncClient) -> None: + """Schedule response includes the config sub-object.""" + with patch( + "app.routers.blocklist.blocklist_service.get_schedule_info", + new=AsyncMock(return_value=_make_schedule_info()), + ): + resp = await bl_client.get("/api/blocklists/schedule") + body = resp.json() + assert "config" in body + assert "next_run_at" in body + assert "last_run_at" in body + + async def test_schedule_response_includes_last_run_errors( + self, bl_client: AsyncClient + ) -> None: + """GET /api/blocklists/schedule includes last_run_errors field.""" + info_with_errors = ScheduleInfo( + config=ScheduleConfig( + frequency=ScheduleFrequency.daily, + interval_hours=24, + hour=3, + minute=0, + day_of_week=0, + ), + next_run_at=None, + last_run_at="2026-03-01T03:00:00+00:00", + last_run_errors=True, + ) + with patch( + "app.routers.blocklist.blocklist_service.get_schedule_info", + new=AsyncMock(return_value=info_with_errors), + ): + resp = await bl_client.get("/api/blocklists/schedule") + body = resp.json() + assert "last_run_errors" in body + assert body["last_run_errors"] is True + + +# --------------------------------------------------------------------------- +# PUT /api/blocklists/schedule +# --------------------------------------------------------------------------- + + +class TestUpdateSchedule: + async def test_update_schedule_returns_200(self, bl_client: AsyncClient) -> None: + """PUT /api/blocklists/schedule persists new config and returns 200.""" + new_info = ScheduleInfo( + config=ScheduleConfig( + frequency=ScheduleFrequency.hourly, + interval_hours=12, + hour=0, + minute=0, + day_of_week=0, + ), + next_run_at=None, + last_run_at=None, + ) + with patch( + "app.routers.blocklist.blocklist_service.set_schedule", + new=AsyncMock(), + ), patch( + "app.routers.blocklist.blocklist_service.get_schedule_info", + new=AsyncMock(return_value=new_info), + ), patch( + "app.routers.blocklist.blocklist_import_task.reschedule", + ): + resp = await bl_client.put( + "/api/blocklists/schedule", + json={ + "frequency": "hourly", + "interval_hours": 12, + "hour": 0, + "minute": 0, + "day_of_week": 0, + }, + ) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# GET /api/blocklists/log +# --------------------------------------------------------------------------- + + +class TestImportLog: + async def test_log_returns_200(self, bl_client: AsyncClient) -> None: + """GET /api/blocklists/log returns 200.""" + resp = await bl_client.get("/api/blocklists/log") + assert resp.status_code == 200 + + async def test_log_response_shape(self, bl_client: AsyncClient) -> None: + """Log response has items, total, page, page_size, total_pages.""" + resp = await bl_client.get("/api/blocklists/log") + body = resp.json() + for key in ("items", "total", "page", "page_size", "total_pages"): + assert key in body + + async def test_log_empty_when_no_runs(self, bl_client: AsyncClient) -> None: + """Log returns empty items list when no import runs have occurred.""" + resp = await bl_client.get("/api/blocklists/log") + body = resp.json() + assert body["total"] == 0 + assert body["items"] == [] diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py new file mode 100644 index 0000000..ae71e04 --- /dev/null +++ b/backend/tests/test_routers/test_config.py @@ -0,0 +1,2140 @@ +"""Tests for the config router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.config import ( + Fail2BanLogResponse, + FilterConfig, + GlobalConfigResponse, + JailConfig, + JailConfigListResponse, + JailConfigResponse, + RegexTestResponse, + ServiceStatusResponse, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for config endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "config_test.db"), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-config-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.post("/api/setup", json=_SETUP_PAYLOAD) + login = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login.status_code == 200 + yield ac + + await db.close() + + +def _make_jail_config(name: str = "sshd") -> JailConfig: + return JailConfig( + name=name, + ban_time=600, + max_retry=5, + find_time=600, + fail_regex=["regex1"], + ignore_regex=[], + log_paths=["/var/log/auth.log"], + date_pattern=None, + log_encoding="UTF-8", + backend="polling", + use_dns="warn", + prefregex="", + actions=["iptables"], + ) + + +# --------------------------------------------------------------------------- +# GET /api/config/jails +# --------------------------------------------------------------------------- + + +class TestGetJailConfigs: + """Tests for ``GET /api/config/jails``.""" + + async def test_200_returns_jail_list(self, config_client: AsyncClient) -> None: + """GET /api/config/jails returns 200 with JailConfigListResponse.""" + mock_response = JailConfigListResponse( + jails=[_make_jail_config("sshd")], total=1 + ) + with patch( + "app.routers.config.config_service.list_jail_configs", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/jails") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["jails"][0]["name"] == "sshd" + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/jails returns 401 without a valid session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/jails") + assert resp.status_code == 401 + + async def test_502_on_connection_error(self, config_client: AsyncClient) -> None: + """GET /api/config/jails returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.config.config_service.list_jail_configs", + AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), + ): + resp = await config_client.get("/api/config/jails") + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# GET /api/config/jails/{name} +# --------------------------------------------------------------------------- + + +class TestGetJailConfig: + """Tests for ``GET /api/config/jails/{name}``.""" + + async def test_200_returns_jail_config(self, config_client: AsyncClient) -> None: + """GET /api/config/jails/sshd returns 200 with JailConfigResponse.""" + mock_response = JailConfigResponse(jail=_make_jail_config("sshd")) + with patch( + "app.routers.config.config_service.get_jail_config", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/jails/sshd") + + assert resp.status_code == 200 + assert resp.json()["jail"]["name"] == "sshd" + assert resp.json()["jail"]["ban_time"] == 600 + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """GET /api/config/jails/missing returns 404.""" + from app.services.config_service import JailNotFoundError + + with patch( + "app.routers.config.config_service.get_jail_config", + AsyncMock(side_effect=JailNotFoundError("missing")), + ): + resp = await config_client.get("/api/config/jails/missing") + + assert resp.status_code == 404 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/jails/sshd returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/jails/sshd") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# PUT /api/config/jails/{name} +# --------------------------------------------------------------------------- + + +class TestUpdateJailConfig: + """Tests for ``PUT /api/config/jails/{name}``.""" + + async def test_204_on_success(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd returns 204 on success.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"ban_time": 3600}, + ) + + assert resp.status_code == 204 + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/missing returns 404.""" + from app.services.config_service import JailNotFoundError + + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(side_effect=JailNotFoundError("missing")), + ): + resp = await config_client.put( + "/api/config/jails/missing", + json={"ban_time": 3600}, + ) + + assert resp.status_code == 404 + + async def test_422_on_invalid_regex(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd returns 422 for invalid regex pattern.""" + from app.services.config_service import ConfigValidationError + + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(side_effect=ConfigValidationError("bad regex")), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"fail_regex": ["[bad"]}, + ) + + assert resp.status_code == 422 + + async def test_400_on_config_operation_error(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd returns 400 when set command fails.""" + from app.services.config_service import ConfigOperationError + + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(side_effect=ConfigOperationError("set failed")), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"ban_time": 3600}, + ) + + assert resp.status_code == 400 + + async def test_204_with_dns_mode(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd accepts dns_mode field.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"dns_mode": "no"}, + ) + + assert resp.status_code == 204 + + async def test_204_with_prefregex(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd accepts prefregex field.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"prefregex": r"^%(__prefix_line)s"}, + ) + + assert resp.status_code == 204 + + async def test_204_with_date_pattern(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd accepts date_pattern field.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"date_pattern": "%Y-%m-%d %H:%M:%S"}, + ) + + assert resp.status_code == 204 + + +# --------------------------------------------------------------------------- +# GET /api/config/global +# --------------------------------------------------------------------------- + + +class TestGetGlobalConfig: + """Tests for ``GET /api/config/global``.""" + + async def test_200_returns_global_config(self, config_client: AsyncClient) -> None: + """GET /api/config/global returns 200 with GlobalConfigResponse.""" + mock_response = GlobalConfigResponse( + log_level="WARNING", + log_target="/var/log/fail2ban.log", + db_purge_age=86400, + db_max_matches=10, + ) + with patch( + "app.routers.config.config_service.get_global_config", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/global") + + assert resp.status_code == 200 + data = resp.json() + assert data["log_level"] == "WARNING" + assert data["db_purge_age"] == 86400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/global returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/global") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# PUT /api/config/global +# --------------------------------------------------------------------------- + + +class TestUpdateGlobalConfig: + """Tests for ``PUT /api/config/global``.""" + + async def test_204_on_success(self, config_client: AsyncClient) -> None: + """PUT /api/config/global returns 204 on success.""" + with patch( + "app.routers.config.config_service.update_global_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/global", + json={"log_level": "DEBUG"}, + ) + + assert resp.status_code == 204 + + async def test_400_on_operation_error(self, config_client: AsyncClient) -> None: + """PUT /api/config/global returns 400 when set command fails.""" + from app.services.config_service import ConfigOperationError + + with patch( + "app.routers.config.config_service.update_global_config", + AsyncMock(side_effect=ConfigOperationError("set failed")), + ): + resp = await config_client.put( + "/api/config/global", + json={"log_level": "INFO"}, + ) + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /api/config/reload +# --------------------------------------------------------------------------- + + +class TestReloadFail2ban: + """Tests for ``POST /api/config/reload``.""" + + async def test_204_on_success(self, config_client: AsyncClient) -> None: + """POST /api/config/reload returns 204 on success.""" + with patch( + "app.routers.config.jail_service.reload_all", + AsyncMock(return_value=None), + ): + resp = await config_client.post("/api/config/reload") + + assert resp.status_code == 204 + + +# --------------------------------------------------------------------------- +# POST /api/config/regex-test +# --------------------------------------------------------------------------- + + +class TestRegexTest: + """Tests for ``POST /api/config/regex-test``.""" + + async def test_200_matched(self, config_client: AsyncClient) -> None: + """POST /api/config/regex-test returns matched=true for a valid match.""" + mock_response = RegexTestResponse(matched=True, groups=["1.2.3.4"], error=None) + with patch( + "app.routers.config.config_service.test_regex", + return_value=mock_response, + ): + resp = await config_client.post( + "/api/config/regex-test", + json={ + "log_line": "fail from 1.2.3.4", + "fail_regex": r"(\d+\.\d+\.\d+\.\d+)", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["matched"] is True + + async def test_200_not_matched(self, config_client: AsyncClient) -> None: + """POST /api/config/regex-test returns matched=false for no match.""" + mock_response = RegexTestResponse(matched=False, groups=[], error=None) + with patch( + "app.routers.config.config_service.test_regex", + return_value=mock_response, + ): + resp = await config_client.post( + "/api/config/regex-test", + json={"log_line": "ok line", "fail_regex": r"FAIL"}, + ) + + assert resp.status_code == 200 + assert resp.json()["matched"] is False + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/regex-test returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post( + "/api/config/regex-test", + json={"log_line": "test", "fail_regex": "test"}, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/config/jails/{name}/logpath +# --------------------------------------------------------------------------- + + +class TestAddLogPath: + """Tests for ``POST /api/config/jails/{name}/logpath``.""" + + async def test_204_on_success(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/logpath returns 204 on success.""" + with patch( + "app.routers.config.config_service.add_log_path", + AsyncMock(return_value=None), + ): + resp = await config_client.post( + "/api/config/jails/sshd/logpath", + json={"log_path": "/var/log/specific.log", "tail": True}, + ) + + assert resp.status_code == 204 + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/missing/logpath returns 404.""" + from app.services.config_service import JailNotFoundError + + with patch( + "app.routers.config.config_service.add_log_path", + AsyncMock(side_effect=JailNotFoundError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/missing/logpath", + json={"log_path": "/var/log/test.log"}, + ) + + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /api/config/preview-log +# --------------------------------------------------------------------------- + + +class TestPreviewLog: + """Tests for ``POST /api/config/preview-log``.""" + + async def test_200_returns_preview(self, config_client: AsyncClient) -> None: + """POST /api/config/preview-log returns 200 with LogPreviewResponse.""" + from app.models.config import LogPreviewLine, LogPreviewResponse + + mock_response = LogPreviewResponse( + lines=[LogPreviewLine(line="fail line", matched=True, groups=[])], + total_lines=1, + matched_count=1, + ) + with patch( + "app.routers.config.config_service.preview_log", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.post( + "/api/config/preview-log", + json={"log_path": "/var/log/test.log", "fail_regex": "fail"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["total_lines"] == 1 + assert data["matched_count"] == 1 + + +# --------------------------------------------------------------------------- +# GET /api/config/map-color-thresholds +# --------------------------------------------------------------------------- + + +class TestGetMapColorThresholds: + """Tests for ``GET /api/config/map-color-thresholds``.""" + + async def test_200_returns_thresholds(self, config_client: AsyncClient) -> None: + """GET /api/config/map-color-thresholds returns 200 with current values.""" + resp = await config_client.get("/api/config/map-color-thresholds") + + assert resp.status_code == 200 + data = resp.json() + assert "threshold_high" in data + assert "threshold_medium" in data + assert "threshold_low" in data + # Should return defaults after setup + assert data["threshold_high"] == 100 + assert data["threshold_medium"] == 50 + assert data["threshold_low"] == 20 + + +# --------------------------------------------------------------------------- +# PUT /api/config/map-color-thresholds +# --------------------------------------------------------------------------- + + +class TestUpdateMapColorThresholds: + """Tests for ``PUT /api/config/map-color-thresholds``.""" + + async def test_200_updates_thresholds(self, config_client: AsyncClient) -> None: + """PUT /api/config/map-color-thresholds returns 200 and updates settings.""" + update_payload = { + "threshold_high": 200, + "threshold_medium": 80, + "threshold_low": 30, + } + resp = await config_client.put( + "/api/config/map-color-thresholds", json=update_payload + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["threshold_high"] == 200 + assert data["threshold_medium"] == 80 + assert data["threshold_low"] == 30 + + # Verify the values persist + get_resp = await config_client.get("/api/config/map-color-thresholds") + assert get_resp.status_code == 200 + get_data = get_resp.json() + assert get_data["threshold_high"] == 200 + assert get_data["threshold_medium"] == 80 + assert get_data["threshold_low"] == 30 + + async def test_400_for_invalid_order(self, config_client: AsyncClient) -> None: + """PUT /api/config/map-color-thresholds returns 400 if thresholds are misordered.""" + invalid_payload = { + "threshold_high": 50, + "threshold_medium": 50, + "threshold_low": 20, + } + resp = await config_client.put( + "/api/config/map-color-thresholds", json=invalid_payload + ) + + assert resp.status_code == 400 + assert "high > medium > low" in resp.json()["detail"] + + async def test_400_for_non_positive_values( + self, config_client: AsyncClient + ) -> None: + """PUT /api/config/map-color-thresholds returns 422 for non-positive values (Pydantic validation).""" + invalid_payload = { + "threshold_high": 100, + "threshold_medium": 50, + "threshold_low": 0, + } + resp = await config_client.put( + "/api/config/map-color-thresholds", json=invalid_payload + ) + + # Pydantic validates ge=1 constraint before our service code runs + assert resp.status_code == 422 + + +# --------------------------------------------------------------------------- +# GET /api/config/jails/inactive +# --------------------------------------------------------------------------- + + +class TestGetInactiveJails: + """Tests for ``GET /api/config/jails/inactive``.""" + + async def test_200_returns_inactive_list(self, config_client: AsyncClient) -> None: + """GET /api/config/jails/inactive returns 200 with InactiveJailListResponse.""" + from app.models.config import InactiveJail, InactiveJailListResponse + + mock_jail = InactiveJail( + name="apache-auth", + filter="apache-auth", + actions=[], + port="http,https", + logpath=["/var/log/apache2/error.log"], + bantime="10m", + findtime="5m", + maxretry=5, + source_file="/etc/fail2ban/jail.conf", + enabled=False, + ) + mock_response = InactiveJailListResponse(jails=[mock_jail], total=1) + + with patch( + "app.routers.config.config_file_service.list_inactive_jails", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/jails/inactive") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["jails"][0]["name"] == "apache-auth" + + async def test_200_empty_list(self, config_client: AsyncClient) -> None: + """GET /api/config/jails/inactive returns 200 with empty list.""" + from app.models.config import InactiveJailListResponse + + with patch( + "app.routers.config.config_file_service.list_inactive_jails", + AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)), + ): + resp = await config_client.get("/api/config/jails/inactive") + + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + assert resp.json()["jails"] == [] + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/jails/inactive returns 401 without a valid session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/jails/inactive") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/config/jails/{name}/activate +# --------------------------------------------------------------------------- + + +class TestActivateJail: + """Tests for ``POST /api/config/jails/{name}/activate``.""" + + async def test_200_activates_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/apache-auth/activate returns 200.""" + from app.models.config import JailActivationResponse + + mock_response = JailActivationResponse( + name="apache-auth", + active=True, + message="Jail 'apache-auth' activated successfully.", + ) + with patch( + "app.routers.config.config_file_service.activate_jail", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.post( + "/api/config/jails/apache-auth/activate", json={} + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["active"] is True + assert data["name"] == "apache-auth" + + async def test_200_with_overrides(self, config_client: AsyncClient) -> None: + """POST .../activate accepts override fields.""" + from app.models.config import JailActivationResponse + + mock_response = JailActivationResponse( + name="apache-auth", active=True, message="Activated." + ) + with patch( + "app.routers.config.config_file_service.activate_jail", + AsyncMock(return_value=mock_response), + ) as mock_activate: + resp = await config_client.post( + "/api/config/jails/apache-auth/activate", + json={"bantime": "1h", "maxretry": 3}, + ) + + assert resp.status_code == 200 + # Verify the override values were passed to the service + called_req = mock_activate.call_args.args[3] + assert called_req.bantime == "1h" + assert called_req.maxretry == 3 + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/missing/activate returns 404.""" + from app.services.config_file_service import JailNotFoundInConfigError + + with patch( + "app.routers.config.config_file_service.activate_jail", + AsyncMock(side_effect=JailNotFoundInConfigError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/missing/activate", json={} + ) + + assert resp.status_code == 404 + + async def test_409_when_already_active(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/activate returns 409 if already active.""" + from app.services.config_file_service import JailAlreadyActiveError + + with patch( + "app.routers.config.config_file_service.activate_jail", + AsyncMock(side_effect=JailAlreadyActiveError("sshd")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/activate", json={} + ) + + assert resp.status_code == 409 + + async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/ with bad name returns 400.""" + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.activate_jail", + AsyncMock(side_effect=JailNameError("bad name")), + ): + resp = await config_client.post( + "/api/config/jails/bad-name/activate", json={} + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/activate returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/activate", json={}) + assert resp.status_code == 401 + + async def test_200_with_active_false_on_missing_logpath(self, config_client: AsyncClient) -> None: + """POST .../activate returns 200 with active=False when the service blocks due to missing logpath.""" + from app.models.config import JailActivationResponse + + blocked_response = JailActivationResponse( + name="airsonic-auth", + active=False, + fail2ban_running=True, + validation_warnings=["logpath: log file '/var/log/airsonic/airsonic.log' not found"], + message="Jail 'airsonic-auth' cannot be activated: log file '/var/log/airsonic/airsonic.log' not found", + ) + with patch( + "app.routers.config.config_file_service.activate_jail", + AsyncMock(return_value=blocked_response), + ): + resp = await config_client.post( + "/api/config/jails/airsonic-auth/activate", json={} + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["active"] is False + assert data["fail2ban_running"] is True + assert "cannot be activated" in data["message"] + assert len(data["validation_warnings"]) == 1 + + +# --------------------------------------------------------------------------- +# POST /api/config/jails/{name}/deactivate +# --------------------------------------------------------------------------- + + +class TestDeactivateJail: + """Tests for ``POST /api/config/jails/{name}/deactivate``.""" + + async def test_200_deactivates_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/deactivate returns 200.""" + from app.models.config import JailActivationResponse + + mock_response = JailActivationResponse( + name="sshd", + active=False, + message="Jail 'sshd' deactivated successfully.", + ) + with patch( + "app.routers.config.config_file_service.deactivate_jail", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.post("/api/config/jails/sshd/deactivate") + + assert resp.status_code == 200 + data = resp.json() + assert data["active"] is False + assert data["name"] == "sshd" + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/missing/deactivate returns 404.""" + from app.services.config_file_service import JailNotFoundInConfigError + + with patch( + "app.routers.config.config_file_service.deactivate_jail", + AsyncMock(side_effect=JailNotFoundInConfigError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/missing/deactivate" + ) + + assert resp.status_code == 404 + + async def test_409_when_already_inactive(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/apache-auth/deactivate returns 409 if already inactive.""" + from app.services.config_file_service import JailAlreadyInactiveError + + with patch( + "app.routers.config.config_file_service.deactivate_jail", + AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")), + ): + resp = await config_client.post( + "/api/config/jails/apache-auth/deactivate" + ) + + assert resp.status_code == 409 + + async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/.../deactivate with bad name returns 400.""" + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.deactivate_jail", + AsyncMock(side_effect=JailNameError("bad")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/deactivate" + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/deactivate returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/deactivate") + assert resp.status_code == 401 + + async def test_deactivate_triggers_health_probe(self, config_client: AsyncClient) -> None: + """POST .../deactivate triggers an immediate health probe after success.""" + from app.models.config import JailActivationResponse + + mock_response = JailActivationResponse( + name="sshd", + active=False, + message="Jail 'sshd' deactivated successfully.", + ) + with ( + patch( + "app.routers.config.config_file_service.deactivate_jail", + AsyncMock(return_value=mock_response), + ), + patch( + "app.routers.config._run_probe", + AsyncMock(), + ) as mock_probe, + ): + resp = await config_client.post("/api/config/jails/sshd/deactivate") + + assert resp.status_code == 200 + mock_probe.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# GET /api/config/filters +# --------------------------------------------------------------------------- + + +def _make_filter_config(name: str, active: bool = False) -> FilterConfig: + return FilterConfig( + name=name, + filename=f"{name}.conf", + before=None, + after=None, + variables={}, + prefregex=None, + failregex=[], + ignoreregex=[], + maxlines=None, + datepattern=None, + journalmatch=None, + active=active, + used_by_jails=[name] if active else [], + source_file=f"/etc/fail2ban/filter.d/{name}.conf", + has_local_override=False, + ) + + +class TestListFilters: + """Tests for ``GET /api/config/filters``.""" + + async def test_200_returns_filter_list(self, config_client: AsyncClient) -> None: + """GET /api/config/filters returns 200 with FilterListResponse.""" + from app.models.config import FilterListResponse + + mock_response = FilterListResponse( + filters=[_make_filter_config("sshd", active=True)], + total=1, + ) + with patch( + "app.routers.config.config_file_service.list_filters", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/filters") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["filters"][0]["name"] == "sshd" + assert data["filters"][0]["active"] is True + + async def test_200_empty_filter_list(self, config_client: AsyncClient) -> None: + """GET /api/config/filters returns 200 with empty list when no filters found.""" + from app.models.config import FilterListResponse + + with patch( + "app.routers.config.config_file_service.list_filters", + AsyncMock(return_value=FilterListResponse(filters=[], total=0)), + ): + resp = await config_client.get("/api/config/filters") + + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + assert resp.json()["filters"] == [] + + async def test_active_filters_sorted_before_inactive( + self, config_client: AsyncClient + ) -> None: + """GET /api/config/filters returns active filters before inactive ones.""" + from app.models.config import FilterListResponse + + mock_response = FilterListResponse( + filters=[ + _make_filter_config("nginx", active=False), + _make_filter_config("sshd", active=True), + ], + total=2, + ) + with patch( + "app.routers.config.config_file_service.list_filters", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/filters") + + data = resp.json() + assert data["filters"][0]["name"] == "sshd" # active first + assert data["filters"][1]["name"] == "nginx" # inactive second + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/filters returns 401 without a valid session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/filters") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/config/filters/{name} +# --------------------------------------------------------------------------- + + +class TestGetFilter: + """Tests for ``GET /api/config/filters/{name}``.""" + + async def test_200_returns_filter(self, config_client: AsyncClient) -> None: + """GET /api/config/filters/sshd returns 200 with FilterConfig.""" + with patch( + "app.routers.config.config_file_service.get_filter", + AsyncMock(return_value=_make_filter_config("sshd")), + ): + resp = await config_client.get("/api/config/filters/sshd") + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "sshd" + assert "failregex" in data + assert "active" in data + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """GET /api/config/filters/missing returns 404.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.get_filter", + AsyncMock(side_effect=FilterNotFoundError("missing")), + ): + resp = await config_client.get("/api/config/filters/missing") + + assert resp.status_code == 404 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/filters/sshd returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/filters/sshd") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# PUT /api/config/filters/{name} (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestUpdateFilter: + """Tests for ``PUT /api/config/filters/{name}``.""" + + async def test_200_returns_updated_filter(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd returns 200 with updated FilterConfig.""" + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(return_value=_make_filter_config("sshd")), + ): + resp = await config_client.put( + "/api/config/filters/sshd", + json={"failregex": [r"^fail from "]}, + ) + + assert resp.status_code == 200 + assert resp.json()["name"] == "sshd" + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/missing returns 404.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(side_effect=FilterNotFoundError("missing")), + ): + resp = await config_client.put( + "/api/config/filters/missing", + json={}, + ) + + assert resp.status_code == 404 + + async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd returns 422 for bad regex.""" + from app.services.config_file_service import FilterInvalidRegexError + + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), + ): + resp = await config_client.put( + "/api/config/filters/sshd", + json={"failregex": ["[bad"]}, + ) + + assert resp.status_code == 422 + + async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/... with bad name returns 400.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.put( + "/api/config/filters/bad", + json={}, + ) + + assert resp.status_code == 400 + + async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd?reload=true passes do_reload=True.""" + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(return_value=_make_filter_config("sshd")), + ) as mock_update: + resp = await config_client.put( + "/api/config/filters/sshd?reload=true", + json={}, + ) + + assert resp.status_code == 200 + assert mock_update.call_args.kwargs.get("do_reload") is True + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).put("/api/config/filters/sshd", json={}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/config/filters (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestCreateFilter: + """Tests for ``POST /api/config/filters``.""" + + async def test_201_creates_filter(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 201 with FilterConfig.""" + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(return_value=_make_filter_config("my-custom")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "my-custom", "failregex": [r"^fail from "]}, + ) + + assert resp.status_code == 201 + assert resp.json()["name"] == "my-custom" + + async def test_409_when_already_exists(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 409 if filter exists.""" + from app.services.config_file_service import FilterAlreadyExistsError + + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(side_effect=FilterAlreadyExistsError("sshd")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "sshd"}, + ) + + assert resp.status_code == 409 + + async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 422 for bad regex.""" + from app.services.config_file_service import FilterInvalidRegexError + + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "test", "failregex": ["[bad"]}, + ) + + assert resp.status_code == 422 + + async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 400 for invalid filter name.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "bad"}, + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/filters", json={"name": "test"}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# DELETE /api/config/filters/{name} (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestDeleteFilter: + """Tests for ``DELETE /api/config/filters/{name}``.""" + + async def test_204_deletes_filter(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/my-custom returns 204.""" + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(return_value=None), + ): + resp = await config_client.delete("/api/config/filters/my-custom") + + assert resp.status_code == 204 + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/missing returns 404.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(side_effect=FilterNotFoundError("missing")), + ): + resp = await config_client.delete("/api/config/filters/missing") + + assert resp.status_code == 404 + + async def test_409_for_readonly_filter(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/sshd returns 409 for shipped conf-only filter.""" + from app.services.config_file_service import FilterReadonlyError + + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(side_effect=FilterReadonlyError("sshd")), + ): + resp = await config_client.delete("/api/config/filters/sshd") + + assert resp.status_code == 409 + + async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/... with bad name returns 400.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.delete("/api/config/filters/bad") + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/sshd returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).delete("/api/config/filters/sshd") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/config/jails/{name}/filter (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestAssignFilterToJail: + """Tests for ``POST /api/config/jails/{name}/filter``.""" + + async def test_204_assigns_filter(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter returns 204 on success.""" + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(return_value=None), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "myfilter"}, + ) + + assert resp.status_code == 204 + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/missing/filter returns 404.""" + from app.services.config_file_service import JailNotFoundInConfigError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=JailNotFoundInConfigError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/missing/filter", + json={"filter_name": "sshd"}, + ) + + assert resp.status_code == 404 + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter returns 404 when filter not found.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=FilterNotFoundError("missing-filter")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "missing-filter"}, + ) + + assert resp.status_code == 404 + + async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/.../filter with bad jail name returns 400.""" + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=JailNameError("bad")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "valid"}, + ) + + assert resp.status_code == 400 + + async def test_400_for_invalid_filter_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter with bad filter name returns 400.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "../evil"}, + ) + + assert resp.status_code == 400 + + async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter?reload=true passes do_reload=True.""" + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(return_value=None), + ) as mock_assign: + resp = await config_client.post( + "/api/config/jails/sshd/filter?reload=true", + json={"filter_name": "sshd"}, + ) + + assert resp.status_code == 204 + assert mock_assign.call_args.kwargs.get("do_reload") is True + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"}) + assert resp.status_code == 401 + + +# =========================================================================== +# Action router tests (Task 3.1 + 3.2) +# =========================================================================== + + +@pytest.mark.asyncio +class TestListActionsRouter: + async def test_200_returns_action_list(self, config_client: AsyncClient) -> None: + from app.models.config import ActionConfig, ActionListResponse + + mock_action = ActionConfig( + name="iptables", + filename="iptables.conf", + actionban="/sbin/iptables -I f2b- 1 -s -j DROP", + ) + mock_response = ActionListResponse(actions=[mock_action], total=1) + + with patch( + "app.routers.config.config_file_service.list_actions", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/actions") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["actions"][0]["name"] == "iptables" + + async def test_active_sorted_first(self, config_client: AsyncClient) -> None: + from app.models.config import ActionConfig, ActionListResponse + + inactive = ActionConfig(name="aaa", filename="aaa.conf", active=False) + active = ActionConfig(name="zzz", filename="zzz.conf", active=True) + mock_response = ActionListResponse(actions=[inactive, active], total=2) + + with patch( + "app.routers.config.config_file_service.list_actions", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/actions") + + data = resp.json() + assert data["actions"][0]["name"] == "zzz" # active comes first + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/actions") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestGetActionRouter: + async def test_200_returns_action(self, config_client: AsyncClient) -> None: + from app.models.config import ActionConfig + + mock_action = ActionConfig( + name="iptables", + filename="iptables.conf", + actionban="/sbin/iptables -I f2b- 1 -s -j DROP", + ) + + with patch( + "app.routers.config.config_file_service.get_action", + AsyncMock(return_value=mock_action), + ): + resp = await config_client.get("/api/config/actions/iptables") + + assert resp.status_code == 200 + assert resp.json()["name"] == "iptables" + + async def test_404_when_not_found(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNotFoundError + + with patch( + "app.routers.config.config_file_service.get_action", + AsyncMock(side_effect=ActionNotFoundError("missing")), + ): + resp = await config_client.get("/api/config/actions/missing") + + assert resp.status_code == 404 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/actions/iptables") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestUpdateActionRouter: + async def test_200_returns_updated_action(self, config_client: AsyncClient) -> None: + from app.models.config import ActionConfig + + updated = ActionConfig( + name="iptables", + filename="iptables.local", + actionban="echo ban", + ) + + with patch( + "app.routers.config.config_file_service.update_action", + AsyncMock(return_value=updated), + ): + resp = await config_client.put( + "/api/config/actions/iptables", + json={"actionban": "echo ban"}, + ) + + assert resp.status_code == 200 + assert resp.json()["actionban"] == "echo ban" + + async def test_404_when_not_found(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNotFoundError + + with patch( + "app.routers.config.config_file_service.update_action", + AsyncMock(side_effect=ActionNotFoundError("missing")), + ): + resp = await config_client.put( + "/api/config/actions/missing", json={} + ) + + assert resp.status_code == 404 + + async def test_400_for_bad_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNameError + + with patch( + "app.routers.config.config_file_service.update_action", + AsyncMock(side_effect=ActionNameError()), + ): + resp = await config_client.put( + "/api/config/actions/badname", json={} + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).put("/api/config/actions/iptables", json={}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestCreateActionRouter: + async def test_201_returns_created_action(self, config_client: AsyncClient) -> None: + from app.models.config import ActionConfig + + created = ActionConfig( + name="custom", + filename="custom.local", + actionban="echo ban", + ) + + with patch( + "app.routers.config.config_file_service.create_action", + AsyncMock(return_value=created), + ): + resp = await config_client.post( + "/api/config/actions", + json={"name": "custom", "actionban": "echo ban"}, + ) + + assert resp.status_code == 201 + assert resp.json()["name"] == "custom" + + async def test_409_when_already_exists(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionAlreadyExistsError + + with patch( + "app.routers.config.config_file_service.create_action", + AsyncMock(side_effect=ActionAlreadyExistsError("iptables")), + ): + resp = await config_client.post( + "/api/config/actions", + json={"name": "iptables"}, + ) + + assert resp.status_code == 409 + + async def test_400_for_bad_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNameError + + with patch( + "app.routers.config.config_file_service.create_action", + AsyncMock(side_effect=ActionNameError()), + ): + resp = await config_client.post( + "/api/config/actions", + json={"name": "badname"}, + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/actions", json={"name": "x"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestDeleteActionRouter: + async def test_204_on_delete(self, config_client: AsyncClient) -> None: + with patch( + "app.routers.config.config_file_service.delete_action", + AsyncMock(return_value=None), + ): + resp = await config_client.delete("/api/config/actions/custom") + + assert resp.status_code == 204 + + async def test_404_when_not_found(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNotFoundError + + with patch( + "app.routers.config.config_file_service.delete_action", + AsyncMock(side_effect=ActionNotFoundError("missing")), + ): + resp = await config_client.delete("/api/config/actions/missing") + + assert resp.status_code == 404 + + async def test_409_when_readonly(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionReadonlyError + + with patch( + "app.routers.config.config_file_service.delete_action", + AsyncMock(side_effect=ActionReadonlyError("iptables")), + ): + resp = await config_client.delete("/api/config/actions/iptables") + + assert resp.status_code == 409 + + async def test_400_for_bad_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNameError + + with patch( + "app.routers.config.config_file_service.delete_action", + AsyncMock(side_effect=ActionNameError()), + ): + resp = await config_client.delete("/api/config/actions/badname") + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).delete("/api/config/actions/iptables") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestAssignActionToJailRouter: + async def test_204_on_success(self, config_client: AsyncClient) -> None: + with patch( + "app.routers.config.config_file_service.assign_action_to_jail", + AsyncMock(return_value=None), + ): + resp = await config_client.post( + "/api/config/jails/sshd/action", + json={"action_name": "iptables"}, + ) + + assert resp.status_code == 204 + + async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import JailNotFoundInConfigError + + with patch( + "app.routers.config.config_file_service.assign_action_to_jail", + AsyncMock(side_effect=JailNotFoundInConfigError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/missing/action", + json={"action_name": "iptables"}, + ) + + assert resp.status_code == 404 + + async def test_404_when_action_not_found(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNotFoundError + + with patch( + "app.routers.config.config_file_service.assign_action_to_jail", + AsyncMock(side_effect=ActionNotFoundError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/action", + json={"action_name": "missing"}, + ) + + assert resp.status_code == 404 + + async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.assign_action_to_jail", + AsyncMock(side_effect=JailNameError()), + ): + resp = await config_client.post( + "/api/config/jails/badjailname/action", + json={"action_name": "iptables"}, + ) + + assert resp.status_code == 400 + + async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNameError + + with patch( + "app.routers.config.config_file_service.assign_action_to_jail", + AsyncMock(side_effect=ActionNameError()), + ): + resp = await config_client.post( + "/api/config/jails/sshd/action", + json={"action_name": "badaction"}, + ) + + assert resp.status_code == 400 + + async def test_reload_param_passed(self, config_client: AsyncClient) -> None: + with patch( + "app.routers.config.config_file_service.assign_action_to_jail", + AsyncMock(return_value=None), + ) as mock_assign: + resp = await config_client.post( + "/api/config/jails/sshd/action?reload=true", + json={"action_name": "iptables"}, + ) + + assert resp.status_code == 204 + assert mock_assign.call_args.kwargs.get("do_reload") is True + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/action", json={"action_name": "iptables"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestRemoveActionFromJailRouter: + async def test_204_on_success(self, config_client: AsyncClient) -> None: + with patch( + "app.routers.config.config_file_service.remove_action_from_jail", + AsyncMock(return_value=None), + ): + resp = await config_client.delete( + "/api/config/jails/sshd/action/iptables" + ) + + assert resp.status_code == 204 + + async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import JailNotFoundInConfigError + + with patch( + "app.routers.config.config_file_service.remove_action_from_jail", + AsyncMock(side_effect=JailNotFoundInConfigError("missing")), + ): + resp = await config_client.delete( + "/api/config/jails/missing/action/iptables" + ) + + assert resp.status_code == 404 + + async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.remove_action_from_jail", + AsyncMock(side_effect=JailNameError()), + ): + resp = await config_client.delete( + "/api/config/jails/badjailname/action/iptables" + ) + + assert resp.status_code == 400 + + async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None: + from app.services.config_file_service import ActionNameError + + with patch( + "app.routers.config.config_file_service.remove_action_from_jail", + AsyncMock(side_effect=ActionNameError()), + ): + resp = await config_client.delete( + "/api/config/jails/sshd/action/badactionname" + ) + + assert resp.status_code == 400 + + async def test_reload_param_passed(self, config_client: AsyncClient) -> None: + with patch( + "app.routers.config.config_file_service.remove_action_from_jail", + AsyncMock(return_value=None), + ) as mock_rm: + resp = await config_client.delete( + "/api/config/jails/sshd/action/iptables?reload=true" + ) + + assert resp.status_code == 204 + assert mock_rm.call_args.kwargs.get("do_reload") is True + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).delete("/api/config/jails/sshd/action/iptables") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/config/fail2ban-log +# --------------------------------------------------------------------------- + + +class TestGetFail2BanLog: + """Tests for ``GET /api/config/fail2ban-log``.""" + + def _mock_log_response(self) -> Fail2BanLogResponse: + return Fail2BanLogResponse( + log_path="/var/log/fail2ban.log", + lines=["2025-01-01 INFO sshd Found 1.2.3.4", "2025-01-01 ERROR oops"], + total_lines=100, + log_level="INFO", + log_target="/var/log/fail2ban.log", + ) + + async def test_200_returns_log_response(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log returns 200 with Fail2BanLogResponse.""" + with patch( + "app.routers.config.config_service.read_fail2ban_log", + AsyncMock(return_value=self._mock_log_response()), + ): + resp = await config_client.get("/api/config/fail2ban-log") + + assert resp.status_code == 200 + data = resp.json() + assert data["log_path"] == "/var/log/fail2ban.log" + assert isinstance(data["lines"], list) + assert data["total_lines"] == 100 + assert data["log_level"] == "INFO" + + async def test_200_passes_lines_query_param(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log passes the lines query param to the service.""" + with patch( + "app.routers.config.config_service.read_fail2ban_log", + AsyncMock(return_value=self._mock_log_response()), + ) as mock_fn: + resp = await config_client.get("/api/config/fail2ban-log?lines=500") + + assert resp.status_code == 200 + _socket, lines_arg, _filter = mock_fn.call_args.args + assert lines_arg == 500 + + async def test_200_passes_filter_query_param(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log passes the filter query param to the service.""" + with patch( + "app.routers.config.config_service.read_fail2ban_log", + AsyncMock(return_value=self._mock_log_response()), + ) as mock_fn: + resp = await config_client.get("/api/config/fail2ban-log?filter=ERROR") + + assert resp.status_code == 200 + _socket, _lines, filter_arg = mock_fn.call_args.args + assert filter_arg == "ERROR" + + async def test_400_when_non_file_target(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log returns 400 when log target is not a file.""" + from app.services.config_service import ConfigOperationError + + with patch( + "app.routers.config.config_service.read_fail2ban_log", + AsyncMock(side_effect=ConfigOperationError("fail2ban is logging to 'STDOUT'")), + ): + resp = await config_client.get("/api/config/fail2ban-log") + + assert resp.status_code == 400 + + async def test_400_when_path_traversal_detected(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log returns 400 when the path is outside safe dirs.""" + from app.services.config_service import ConfigOperationError + + with patch( + "app.routers.config.config_service.read_fail2ban_log", + AsyncMock(side_effect=ConfigOperationError("outside the allowed directory")), + ): + resp = await config_client.get("/api/config/fail2ban-log") + + assert resp.status_code == 400 + + async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log returns 502 when fail2ban is down.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.config.config_service.read_fail2ban_log", + AsyncMock(side_effect=Fail2BanConnectionError("socket error", "/tmp/f.sock")), + ): + resp = await config_client.get("/api/config/fail2ban-log") + + assert resp.status_code == 502 + + async def test_422_for_lines_exceeding_max(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log returns 422 for lines > 2000.""" + resp = await config_client.get("/api/config/fail2ban-log?lines=9999") + assert resp.status_code == 422 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/fail2ban-log requires authentication.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/fail2ban-log") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/config/service-status +# --------------------------------------------------------------------------- + + +class TestGetServiceStatus: + """Tests for ``GET /api/config/service-status``.""" + + def _mock_status(self, online: bool = True) -> ServiceStatusResponse: + return ServiceStatusResponse( + online=online, + version="1.0.0" if online else None, + jail_count=2 if online else 0, + total_bans=10 if online else 0, + total_failures=3 if online else 0, + log_level="INFO" if online else "UNKNOWN", + log_target="/var/log/fail2ban.log" if online else "UNKNOWN", + ) + + async def test_200_when_online(self, config_client: AsyncClient) -> None: + """GET /api/config/service-status returns 200 with full status when online.""" + with patch( + "app.routers.config.config_service.get_service_status", + AsyncMock(return_value=self._mock_status(online=True)), + ): + resp = await config_client.get("/api/config/service-status") + + assert resp.status_code == 200 + data = resp.json() + assert data["online"] is True + assert data["jail_count"] == 2 + assert data["log_level"] == "INFO" + + async def test_200_when_offline(self, config_client: AsyncClient) -> None: + """GET /api/config/service-status returns 200 with offline=False when daemon is down.""" + with patch( + "app.routers.config.config_service.get_service_status", + AsyncMock(return_value=self._mock_status(online=False)), + ): + resp = await config_client.get("/api/config/service-status") + + assert resp.status_code == 200 + data = resp.json() + assert data["online"] is False + assert data["log_level"] == "UNKNOWN" + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/service-status requires authentication.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/service-status") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Task 3 endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestValidateJailEndpoint: + """Tests for ``POST /api/config/jails/{name}/validate``.""" + + async def test_200_valid_config(self, config_client: AsyncClient) -> None: + """Returns 200 with valid=True when the jail config has no issues.""" + from app.models.config import JailValidationResult + + mock_result = JailValidationResult( + jail_name="sshd", valid=True, issues=[] + ) + with patch( + "app.routers.config.config_file_service.validate_jail_config", + AsyncMock(return_value=mock_result), + ): + resp = await config_client.post("/api/config/jails/sshd/validate") + + assert resp.status_code == 200 + data = resp.json() + assert data["valid"] is True + assert data["jail_name"] == "sshd" + assert data["issues"] == [] + + async def test_200_invalid_config(self, config_client: AsyncClient) -> None: + """Returns 200 with valid=False and issues when there are errors.""" + from app.models.config import JailValidationIssue, JailValidationResult + + issue = JailValidationIssue(field="filter", message="Filter file not found: filter.d/bad.conf (or .local)") + mock_result = JailValidationResult( + jail_name="sshd", valid=False, issues=[issue] + ) + with patch( + "app.routers.config.config_file_service.validate_jail_config", + AsyncMock(return_value=mock_result), + ): + resp = await config_client.post("/api/config/jails/sshd/validate") + + assert resp.status_code == 200 + data = resp.json() + assert data["valid"] is False + assert len(data["issues"]) == 1 + assert data["issues"][0]["field"] == "filter" + + async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/bad-name/validate returns 400 on JailNameError.""" + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.validate_jail_config", + AsyncMock(side_effect=JailNameError("bad name")), + ): + resp = await config_client.post("/api/config/jails/bad-name/validate") + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/validate returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/validate") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestPendingRecovery: + """Tests for ``GET /api/config/pending-recovery``.""" + + async def test_returns_null_when_no_pending_recovery( + self, config_client: AsyncClient + ) -> None: + """Returns null body (204-like 200) when pending_recovery is not set.""" + app = config_client._transport.app # type: ignore[attr-defined] + app.state.pending_recovery = None + + resp = await config_client.get("/api/config/pending-recovery") + + assert resp.status_code == 200 + assert resp.json() is None + + async def test_returns_record_when_set(self, config_client: AsyncClient) -> None: + """Returns the PendingRecovery model when one is stored on app.state.""" + import datetime + + from app.models.config import PendingRecovery + + now = datetime.datetime.now(tz=datetime.UTC) + record = PendingRecovery( + jail_name="sshd", + activated_at=now - datetime.timedelta(seconds=20), + detected_at=now, + ) + app = config_client._transport.app # type: ignore[attr-defined] + app.state.pending_recovery = record + + resp = await config_client.get("/api/config/pending-recovery") + + assert resp.status_code == 200 + data = resp.json() + assert data["jail_name"] == "sshd" + assert data["recovered"] is False + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/pending-recovery returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/pending-recovery") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +class TestRollbackEndpoint: + """Tests for ``POST /api/config/jails/{name}/rollback``.""" + + async def test_200_success_clears_pending_recovery( + self, config_client: AsyncClient + ) -> None: + """A successful rollback returns 200 and clears app.state.pending_recovery.""" + import datetime + + from app.models.config import PendingRecovery, RollbackResponse + + # Set up a pending recovery record on the app. + app = config_client._transport.app # type: ignore[attr-defined] + now = datetime.datetime.now(tz=datetime.UTC) + app.state.pending_recovery = PendingRecovery( + jail_name="sshd", + activated_at=now - datetime.timedelta(seconds=10), + detected_at=now, + ) + + mock_result = RollbackResponse( + jail_name="sshd", + disabled=True, + fail2ban_running=True, + active_jails=0, + message="Jail 'sshd' disabled and fail2ban restarted.", + ) + with patch( + "app.routers.config.config_file_service.rollback_jail", + AsyncMock(return_value=mock_result), + ): + resp = await config_client.post("/api/config/jails/sshd/rollback") + + assert resp.status_code == 200 + data = resp.json() + assert data["disabled"] is True + assert data["fail2ban_running"] is True + # Successful rollback must clear the pending record. + assert app.state.pending_recovery is None + + async def test_200_fail_preserves_pending_recovery( + self, config_client: AsyncClient + ) -> None: + """When fail2ban is still down after rollback, pending_recovery is retained.""" + import datetime + + from app.models.config import PendingRecovery, RollbackResponse + + app = config_client._transport.app # type: ignore[attr-defined] + now = datetime.datetime.now(tz=datetime.UTC) + record = PendingRecovery( + jail_name="sshd", + activated_at=now - datetime.timedelta(seconds=10), + detected_at=now, + ) + app.state.pending_recovery = record + + mock_result = RollbackResponse( + jail_name="sshd", + disabled=True, + fail2ban_running=False, + active_jails=0, + message="fail2ban did not come back online.", + ) + with patch( + "app.routers.config.config_file_service.rollback_jail", + AsyncMock(return_value=mock_result), + ): + resp = await config_client.post("/api/config/jails/sshd/rollback") + + assert resp.status_code == 200 + data = resp.json() + assert data["fail2ban_running"] is False + # Pending record should NOT be cleared when rollback didn't fully succeed. + assert app.state.pending_recovery is not None + + async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/bad/rollback returns 400 on JailNameError.""" + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.rollback_jail", + AsyncMock(side_effect=JailNameError("bad")), + ): + resp = await config_client.post("/api/config/jails/bad/rollback") + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/rollback returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/rollback") + assert resp.status_code == 401 + diff --git a/backend/tests/test_routers/test_dashboard.py b/backend/tests/test_routers/test_dashboard.py new file mode 100644 index 0000000..d89c9f6 --- /dev/null +++ b/backend/tests/test_routers/test_dashboard.py @@ -0,0 +1,848 @@ +"""Tests for the dashboard router (GET /api/dashboard/status, GET /api/dashboard/bans).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.ban import ( + DashboardBanItem, + DashboardBanListResponse, +) +from app.models.server import ServerStatus + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` with a pre-seeded server status. + + Unlike the shared ``client`` fixture this one also exposes access to + ``app.state`` via the app instance so we can seed the status cache. + """ + settings = Settings( + database_path=str(tmp_path / "dashboard_test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-dashboard-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + + # Pre-seed a server status so the endpoint has something to return. + app.state.server_status = ServerStatus( + online=True, + version="1.0.2", + active_jails=2, + total_bans=10, + total_failures=5, + ) + # Provide a stub HTTP session so ban/access endpoints can access app.state.http_session. + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + # Complete setup so the middleware doesn't redirect. + resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + # Login to get a session cookie. + login_resp = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login_resp.status_code == 200 + + yield ac + + await db.close() + + +@pytest.fixture +async def offline_dashboard_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Like ``dashboard_client`` but with an offline server status.""" + settings = Settings( + database_path=str(tmp_path / "dashboard_offline_test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-dashboard-offline-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + + app.state.server_status = ServerStatus(online=False) + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + login_resp = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login_resp.status_code == 200 + + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestDashboardStatus: + """GET /api/dashboard/status.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + response = await dashboard_client.get("/api/dashboard/status") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + # Complete setup so the middleware allows the request through. + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/status") + assert response.status_code == 401 + + async def test_response_shape_when_online( + self, dashboard_client: AsyncClient + ) -> None: + """Response contains the expected ``status`` object shape.""" + response = await dashboard_client.get("/api/dashboard/status") + body = response.json() + + assert "status" in body + status = body["status"] + assert "online" in status + assert "version" in status + assert "active_jails" in status + assert "total_bans" in status + assert "total_failures" in status + + async def test_cached_values_returned_when_online( + self, dashboard_client: AsyncClient + ) -> None: + """Endpoint returns the exact values from ``app.state.server_status``.""" + response = await dashboard_client.get("/api/dashboard/status") + status = response.json()["status"] + + assert status["online"] is True + assert status["version"] == "1.0.2" + assert status["active_jails"] == 2 + assert status["total_bans"] == 10 + assert status["total_failures"] == 5 + + async def test_offline_status_returned_correctly( + self, offline_dashboard_client: AsyncClient + ) -> None: + """Endpoint returns online=False when the cache holds an offline snapshot.""" + response = await offline_dashboard_client.get("/api/dashboard/status") + assert response.status_code == 200 + status = response.json()["status"] + + assert status["online"] is False + assert status["version"] is None + assert status["active_jails"] == 0 + assert status["total_bans"] == 0 + assert status["total_failures"] == 0 + + async def test_returns_offline_when_state_not_initialised( + self, client: AsyncClient + ) -> None: + """Endpoint returns online=False as a safe default if the cache is absent.""" + # Setup + login so the endpoint is reachable. + await client.post("/api/setup", json=_SETUP_PAYLOAD) + await client.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + # server_status is not set on app.state in the shared `client` fixture. + response = await client.get("/api/dashboard/status") + assert response.status_code == 200 + status = response.json()["status"] + assert status["online"] is False + + +# --------------------------------------------------------------------------- +# Dashboard bans endpoint +# --------------------------------------------------------------------------- + + +def _make_ban_list_response(n: int = 2) -> DashboardBanListResponse: + """Build a mock DashboardBanListResponse with *n* items.""" + items = [ + DashboardBanItem( + ip=f"1.2.3.{i}", + jail="sshd", + banned_at="2026-03-01T10:00:00+00:00", + service=None, + country_code="DE", + country_name="Germany", + asn="AS3320", + org="Telekom", + ban_count=1, + origin="selfblock", + ) + for i in range(n) + ] + return DashboardBanListResponse(items=items, total=n, page=1, page_size=100) + + +class TestDashboardBans: + """GET /api/dashboard/bans.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.dashboard.ban_service.list_bans", + new=AsyncMock(return_value=_make_ban_list_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/bans") + assert response.status_code == 401 + + async def test_response_contains_items_and_total( + self, dashboard_client: AsyncClient + ) -> None: + """Response body contains ``items`` list and ``total`` count.""" + with patch( + "app.routers.dashboard.ban_service.list_bans", + new=AsyncMock(return_value=_make_ban_list_response(3)), + ): + response = await dashboard_client.get("/api/dashboard/bans") + + body = response.json() + assert "items" in body + assert "total" in body + assert body["total"] == 3 + assert len(body["items"]) == 3 + + async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None: + """If no ``range`` param is provided the default ``24h`` preset is used.""" + mock_list = AsyncMock(return_value=_make_ban_list_response()) + with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): + await dashboard_client.get("/api/dashboard/bans") + + called_range = mock_list.call_args[0][1] + assert called_range == "24h" + + async def test_accepts_time_range_param( + self, dashboard_client: AsyncClient + ) -> None: + """The ``range`` query parameter is forwarded to ban_service.""" + mock_list = AsyncMock(return_value=_make_ban_list_response()) + with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): + await dashboard_client.get("/api/dashboard/bans?range=7d") + + called_range = mock_list.call_args[0][1] + assert called_range == "7d" + + async def test_empty_ban_list_returns_zero_total( + self, dashboard_client: AsyncClient + ) -> None: + """Returns ``total=0`` and empty ``items`` when no bans are in range.""" + empty = DashboardBanListResponse(items=[], total=0, page=1, page_size=100) + with patch( + "app.routers.dashboard.ban_service.list_bans", + new=AsyncMock(return_value=empty), + ): + response = await dashboard_client.get("/api/dashboard/bans") + + body = response.json() + assert body["total"] == 0 + assert body["items"] == [] + + async def test_item_shape_is_correct(self, dashboard_client: AsyncClient) -> None: + """Each item in ``items`` has the expected fields.""" + with patch( + "app.routers.dashboard.ban_service.list_bans", + new=AsyncMock(return_value=_make_ban_list_response(1)), + ): + response = await dashboard_client.get("/api/dashboard/bans") + + item = response.json()["items"][0] + assert "ip" in item + assert "jail" in item + assert "banned_at" in item + assert "ban_count" in item + + +# --------------------------------------------------------------------------- +# Bans by country endpoint +# --------------------------------------------------------------------------- + + +def _make_bans_by_country_response() -> object: + """Build a stub BansByCountryResponse.""" + from app.models.ban import BansByCountryResponse + + items = [ + DashboardBanItem( + ip="1.2.3.4", + jail="sshd", + banned_at="2026-03-01T10:00:00+00:00", + service=None, + country_code="DE", + country_name="Germany", + asn="AS3320", + org="Telekom", + ban_count=1, + origin="selfblock", + ), + DashboardBanItem( + ip="5.6.7.8", + jail="blocklist-import", + banned_at="2026-03-01T10:05:00+00:00", + service=None, + country_code="US", + country_name="United States", + asn="AS15169", + org="Google LLC", + ban_count=2, + origin="blocklist", + ), + ] + return BansByCountryResponse( + countries={"DE": 1, "US": 1}, + country_names={"DE": "Germany", "US": "United States"}, + bans=items, + total=2, + ) + + +@pytest.mark.anyio +class TestBansByCountry: + """GET /api/dashboard/bans/by-country.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_country", + new=AsyncMock(return_value=_make_bans_by_country_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-country") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/bans/by-country") + assert response.status_code == 401 + + async def test_response_shape(self, dashboard_client: AsyncClient) -> None: + """Response body contains countries, country_names, bans, total.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_country", + new=AsyncMock(return_value=_make_bans_by_country_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-country") + + body = response.json() + assert "countries" in body + assert "country_names" in body + assert "bans" in body + assert "total" in body + assert body["total"] == 2 + assert body["countries"]["DE"] == 1 + assert body["countries"]["US"] == 1 + assert body["country_names"]["DE"] == "Germany" + + async def test_accepts_time_range_param( + self, dashboard_client: AsyncClient + ) -> None: + """The range query parameter is forwarded to ban_service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_country_response()) + with patch( + "app.routers.dashboard.ban_service.bans_by_country", new=mock_fn + ): + await dashboard_client.get("/api/dashboard/bans/by-country?range=7d") + + called_range = mock_fn.call_args[0][1] + assert called_range == "7d" + + async def test_empty_window_returns_empty_response( + self, dashboard_client: AsyncClient + ) -> None: + """Empty time range returns empty countries dict and bans list.""" + from app.models.ban import BansByCountryResponse + + empty = BansByCountryResponse( + countries={}, + country_names={}, + bans=[], + total=0, + ) + with patch( + "app.routers.dashboard.ban_service.bans_by_country", + new=AsyncMock(return_value=empty), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-country") + + body = response.json() + assert body["total"] == 0 + assert body["countries"] == {} + assert body["bans"] == [] + + +# --------------------------------------------------------------------------- +# Origin field tests +# --------------------------------------------------------------------------- + + +class TestDashboardBansOriginField: + """Verify that the ``origin`` field is present in API responses.""" + + async def test_origin_present_in_ban_list_items( + self, dashboard_client: AsyncClient + ) -> None: + """Each item in ``/api/dashboard/bans`` carries an ``origin`` field.""" + with patch( + "app.routers.dashboard.ban_service.list_bans", + new=AsyncMock(return_value=_make_ban_list_response(1)), + ): + response = await dashboard_client.get("/api/dashboard/bans") + + item = response.json()["items"][0] + assert "origin" in item + assert item["origin"] in ("blocklist", "selfblock") + + async def test_selfblock_origin_serialised_correctly( + self, dashboard_client: AsyncClient + ) -> None: + """A ban from a non-blocklist jail serialises as ``"selfblock"``.""" + with patch( + "app.routers.dashboard.ban_service.list_bans", + new=AsyncMock(return_value=_make_ban_list_response(1)), + ): + response = await dashboard_client.get("/api/dashboard/bans") + + item = response.json()["items"][0] + assert item["jail"] == "sshd" + assert item["origin"] == "selfblock" + + async def test_origin_present_in_bans_by_country( + self, dashboard_client: AsyncClient + ) -> None: + """Each ban in ``/api/dashboard/bans/by-country`` carries an ``origin``.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_country", + new=AsyncMock(return_value=_make_bans_by_country_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-country") + + bans = response.json()["bans"] + assert all("origin" in ban for ban in bans) + origins = {ban["origin"] for ban in bans} + assert origins == {"blocklist", "selfblock"} + + async def test_blocklist_origin_serialised_correctly( + self, dashboard_client: AsyncClient + ) -> None: + """A ban from the ``blocklist-import`` jail serialises as ``"blocklist"``.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_country", + new=AsyncMock(return_value=_make_bans_by_country_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-country") + + bans = response.json()["bans"] + blocklist_ban = next(b for b in bans if b["jail"] == "blocklist-import") + assert blocklist_ban["origin"] == "blocklist" + + +# --------------------------------------------------------------------------- +# Origin filter query parameter tests +# --------------------------------------------------------------------------- + + +class TestOriginFilterParam: + """Verify that the ``origin`` query parameter is forwarded to the service.""" + + async def test_bans_origin_blocklist_forwarded_to_service( + self, dashboard_client: AsyncClient + ) -> None: + """``?origin=blocklist`` is passed to ``ban_service.list_bans``.""" + mock_list = AsyncMock(return_value=_make_ban_list_response()) + with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): + await dashboard_client.get("/api/dashboard/bans?origin=blocklist") + + _, kwargs = mock_list.call_args + assert kwargs.get("origin") == "blocklist" + + async def test_bans_origin_selfblock_forwarded_to_service( + self, dashboard_client: AsyncClient + ) -> None: + """``?origin=selfblock`` is passed to ``ban_service.list_bans``.""" + mock_list = AsyncMock(return_value=_make_ban_list_response()) + with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): + await dashboard_client.get("/api/dashboard/bans?origin=selfblock") + + _, kwargs = mock_list.call_args + assert kwargs.get("origin") == "selfblock" + + async def test_bans_no_origin_param_defaults_to_none( + self, dashboard_client: AsyncClient + ) -> None: + """Omitting ``origin`` passes ``None`` to the service (no filtering).""" + mock_list = AsyncMock(return_value=_make_ban_list_response()) + with patch("app.routers.dashboard.ban_service.list_bans", new=mock_list): + await dashboard_client.get("/api/dashboard/bans") + + _, kwargs = mock_list.call_args + assert kwargs.get("origin") is None + + async def test_bans_invalid_origin_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid ``origin`` value returns HTTP 422 Unprocessable Entity.""" + response = await dashboard_client.get("/api/dashboard/bans?origin=invalid") + assert response.status_code == 422 + + async def test_by_country_origin_blocklist_forwarded( + self, dashboard_client: AsyncClient + ) -> None: + """``?origin=blocklist`` is passed to ``ban_service.bans_by_country``.""" + mock_fn = AsyncMock(return_value=_make_bans_by_country_response()) + with patch( + "app.routers.dashboard.ban_service.bans_by_country", new=mock_fn + ): + await dashboard_client.get( + "/api/dashboard/bans/by-country?origin=blocklist" + ) + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") == "blocklist" + + async def test_by_country_no_origin_defaults_to_none( + self, dashboard_client: AsyncClient + ) -> None: + """Omitting ``origin`` passes ``None`` to ``bans_by_country``.""" + mock_fn = AsyncMock(return_value=_make_bans_by_country_response()) + with patch( + "app.routers.dashboard.ban_service.bans_by_country", new=mock_fn + ): + await dashboard_client.get("/api/dashboard/bans/by-country") + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") is None + + +# --------------------------------------------------------------------------- +# Ban trend endpoint +# --------------------------------------------------------------------------- + + +def _make_ban_trend_response(n_buckets: int = 24) -> object: + """Build a stub :class:`~app.models.ban.BanTrendResponse`.""" + from app.models.ban import BanTrendBucket, BanTrendResponse + + buckets = [ + BanTrendBucket(timestamp=f"2026-03-01T{i:02d}:00:00+00:00", count=i) + for i in range(n_buckets) + ] + return BanTrendResponse(buckets=buckets, bucket_size="1h") + + +@pytest.mark.anyio +class TestBanTrend: + """GET /api/dashboard/bans/trend.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=_make_ban_trend_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/bans/trend") + assert response.status_code == 401 + + async def test_response_shape(self, dashboard_client: AsyncClient) -> None: + """Response body contains ``buckets`` list and ``bucket_size`` string.""" + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=_make_ban_trend_response(24)), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + + body = response.json() + assert "buckets" in body + assert "bucket_size" in body + assert len(body["buckets"]) == 24 + assert body["bucket_size"] == "1h" + + async def test_each_bucket_has_timestamp_and_count( + self, dashboard_client: AsyncClient + ) -> None: + """Every element of ``buckets`` has ``timestamp`` and ``count``.""" + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=_make_ban_trend_response(3)), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + + for bucket in response.json()["buckets"]: + assert "timestamp" in bucket + assert "count" in bucket + assert isinstance(bucket["count"], int) + + async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None: + """Omitting ``range`` defaults to ``24h``.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response()) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/trend") + + called_range = mock_fn.call_args[0][1] + assert called_range == "24h" + + async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None: + """The ``range`` query parameter is forwarded to the service.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response(28)) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/trend?range=7d") + + called_range = mock_fn.call_args[0][1] + assert called_range == "7d" + + async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None: + """``?origin=blocklist`` is passed as a keyword arg to the service.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response()) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get( + "/api/dashboard/bans/trend?origin=blocklist" + ) + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") == "blocklist" + + async def test_no_origin_defaults_to_none( + self, dashboard_client: AsyncClient + ) -> None: + """Omitting ``origin`` passes ``None`` to the service.""" + mock_fn = AsyncMock(return_value=_make_ban_trend_response()) + with patch("app.routers.dashboard.ban_service.ban_trend", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/trend") + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") is None + + async def test_invalid_range_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid ``range`` value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/trend?range=invalid" + ) + assert response.status_code == 422 + + async def test_empty_buckets_response(self, dashboard_client: AsyncClient) -> None: + """Empty bucket list is serialised correctly.""" + from app.models.ban import BanTrendResponse + + empty = BanTrendResponse(buckets=[], bucket_size="1h") + with patch( + "app.routers.dashboard.ban_service.ban_trend", + new=AsyncMock(return_value=empty), + ): + response = await dashboard_client.get("/api/dashboard/bans/trend") + + body = response.json() + assert body["buckets"] == [] + assert body["bucket_size"] == "1h" + + +# --------------------------------------------------------------------------- +# Bans by jail endpoint +# --------------------------------------------------------------------------- + + +def _make_bans_by_jail_response() -> object: + """Build a stub :class:`~app.models.ban.BansByJailResponse`.""" + from app.models.ban import BansByJailResponse, JailBanCount + + return BansByJailResponse( + jails=[ + JailBanCount(jail="sshd", count=10), + JailBanCount(jail="nginx", count=5), + ], + total=15, + ) + + +@pytest.mark.anyio +class TestBansByJail: + """GET /api/dashboard/bans/by-jail.""" + + async def test_returns_200_when_authenticated( + self, dashboard_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=_make_bans_by_jail_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/dashboard/bans/by-jail") + assert response.status_code == 401 + + async def test_response_shape(self, dashboard_client: AsyncClient) -> None: + """Response body contains ``jails`` list and ``total`` integer.""" + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=_make_bans_by_jail_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + + body = response.json() + assert "jails" in body + assert "total" in body + assert isinstance(body["total"], int) + + async def test_each_jail_has_name_and_count( + self, dashboard_client: AsyncClient + ) -> None: + """Every element of ``jails`` has ``jail`` (string) and ``count`` (int).""" + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=_make_bans_by_jail_response()), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + + for entry in response.json()["jails"]: + assert "jail" in entry + assert "count" in entry + assert isinstance(entry["jail"], str) + assert isinstance(entry["count"], int) + + async def test_default_range_is_24h(self, dashboard_client: AsyncClient) -> None: + """Omitting ``range`` defaults to ``"24h"``.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/by-jail") + + called_range = mock_fn.call_args[0][1] + assert called_range == "24h" + + async def test_accepts_range_param(self, dashboard_client: AsyncClient) -> None: + """The ``range`` query parameter is forwarded to the service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/by-jail?range=7d") + + called_range = mock_fn.call_args[0][1] + assert called_range == "7d" + + async def test_origin_param_forwarded(self, dashboard_client: AsyncClient) -> None: + """``?origin=blocklist`` is passed as a keyword arg to the service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get( + "/api/dashboard/bans/by-jail?origin=blocklist" + ) + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") == "blocklist" + + async def test_no_origin_defaults_to_none( + self, dashboard_client: AsyncClient + ) -> None: + """Omitting ``origin`` passes ``None`` to the service.""" + mock_fn = AsyncMock(return_value=_make_bans_by_jail_response()) + with patch("app.routers.dashboard.ban_service.bans_by_jail", new=mock_fn): + await dashboard_client.get("/api/dashboard/bans/by-jail") + + _, kwargs = mock_fn.call_args + assert kwargs.get("origin") is None + + async def test_invalid_range_returns_422( + self, dashboard_client: AsyncClient + ) -> None: + """An invalid ``range`` value returns HTTP 422.""" + response = await dashboard_client.get( + "/api/dashboard/bans/by-jail?range=invalid" + ) + assert response.status_code == 422 + + async def test_empty_jails_response(self, dashboard_client: AsyncClient) -> None: + """Empty jails list is serialised correctly.""" + from app.models.ban import BansByJailResponse + + empty = BansByJailResponse(jails=[], total=0) + with patch( + "app.routers.dashboard.ban_service.bans_by_jail", + new=AsyncMock(return_value=empty), + ): + response = await dashboard_client.get("/api/dashboard/bans/by-jail") + + body = response.json() + assert body["jails"] == [] + assert body["total"] == 0 + diff --git a/backend/tests/test_routers/test_file_config.py b/backend/tests/test_routers/test_file_config.py new file mode 100644 index 0000000..c788bef --- /dev/null +++ b/backend/tests/test_routers/test_file_config.py @@ -0,0 +1,705 @@ +"""Tests for the file_config router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.config import ( + ActionConfig, + FilterConfig, + JailFileConfig, + JailSectionConfig, +) +from app.models.file_config import ( + ConfFileContent, + ConfFileEntry, + ConfFilesResponse, + JailConfigFile, + JailConfigFileContent, + JailConfigFilesResponse, +) +from app.services.file_config_service import ( + ConfigDirError, + ConfigFileExistsError, + ConfigFileNameError, + ConfigFileNotFoundError, + ConfigFileWriteError, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def file_config_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for file_config endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "file_config_test.db"), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-file-config-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.post("/api/setup", json=_SETUP_PAYLOAD) + login = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login.status_code == 200 + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _jail_files_resp(files: list[JailConfigFile] | None = None) -> JailConfigFilesResponse: + files = files or [JailConfigFile(name="sshd", filename="sshd.conf", enabled=True)] + return JailConfigFilesResponse(files=files, total=len(files)) + + +def _conf_files_resp(files: list[ConfFileEntry] | None = None) -> ConfFilesResponse: + files = files or [ConfFileEntry(name="nginx", filename="nginx.conf")] + return ConfFilesResponse(files=files, total=len(files)) + + +def _conf_file_content(name: str = "nginx") -> ConfFileContent: + return ConfFileContent( + name=name, + filename=f"{name}.conf", + content=f"[Definition]\n# {name} filter\n", + ) + + +# --------------------------------------------------------------------------- +# GET /api/config/jail-files +# --------------------------------------------------------------------------- + + +class TestListJailConfigFiles: + async def test_200_returns_file_list( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.list_jail_config_files", + AsyncMock(return_value=_jail_files_resp()), + ): + resp = await file_config_client.get("/api/config/jail-files") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["files"][0]["filename"] == "sshd.conf" + + async def test_503_on_config_dir_error( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.list_jail_config_files", + AsyncMock(side_effect=ConfigDirError("not found")), + ): + resp = await file_config_client.get("/api/config/jail-files") + + assert resp.status_code == 503 + + async def test_401_unauthenticated(self, file_config_client: AsyncClient) -> None: + resp = await AsyncClient( + transport=ASGITransport(app=file_config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/jail-files") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/config/jail-files/{filename} +# --------------------------------------------------------------------------- + + +class TestGetJailConfigFile: + async def test_200_returns_content( + self, file_config_client: AsyncClient + ) -> None: + content = JailConfigFileContent( + name="sshd", + filename="sshd.conf", + enabled=True, + content="[sshd]\nenabled = true\n", + ) + with patch( + "app.routers.file_config.file_config_service.get_jail_config_file", + AsyncMock(return_value=content), + ): + resp = await file_config_client.get("/api/config/jail-files/sshd.conf") + + assert resp.status_code == 200 + assert resp.json()["content"] == "[sshd]\nenabled = true\n" + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_jail_config_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), + ): + resp = await file_config_client.get("/api/config/jail-files/missing.conf") + + assert resp.status_code == 404 + + async def test_400_invalid_filename( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.get_jail_config_file", + AsyncMock(side_effect=ConfigFileNameError("bad name")), + ): + resp = await file_config_client.get("/api/config/jail-files/bad.txt") + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# PUT /api/config/jail-files/{filename}/enabled +# --------------------------------------------------------------------------- + + +class TestSetJailConfigEnabled: + async def test_204_on_success(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.set_jail_config_enabled", + AsyncMock(return_value=None), + ): + resp = await file_config_client.put( + "/api/config/jail-files/sshd.conf/enabled", + json={"enabled": False}, + ) + + assert resp.status_code == 204 + + async def test_404_file_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.set_jail_config_enabled", + AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), + ): + resp = await file_config_client.put( + "/api/config/jail-files/missing.conf/enabled", + json={"enabled": True}, + ) + + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# GET /api/config/filters/{name}/raw +# --------------------------------------------------------------------------- + + +class TestGetFilterFileRaw: + """Tests for the renamed ``GET /api/config/filters/{name}/raw`` endpoint. + + The simple list (``GET /api/config/filters``) and the structured detail + (``GET /api/config/filters/{name}``) are now served by the config router. + This endpoint returns the raw file content only. + """ + + async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_filter_file", + AsyncMock(return_value=_conf_file_content("nginx")), + ): + resp = await file_config_client.get("/api/config/filters/nginx/raw") + + assert resp.status_code == 200 + assert resp.json()["name"] == "nginx" + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_filter_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.get("/api/config/filters/missing/raw") + + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# PUT /api/config/filters/{name} +# --------------------------------------------------------------------------- + + +class TestUpdateFilterFile: + async def test_204_on_success(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.write_filter_file", + AsyncMock(return_value=None), + ): + resp = await file_config_client.put( + "/api/config/filters/nginx/raw", + json={"content": "[Definition]\nfailregex = test\n"}, + ) + + assert resp.status_code == 204 + + async def test_400_write_error(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.write_filter_file", + AsyncMock(side_effect=ConfigFileWriteError("disk full")), + ): + resp = await file_config_client.put( + "/api/config/filters/nginx/raw", + json={"content": "x"}, + ) + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# POST /api/config/filters +# --------------------------------------------------------------------------- + + +class TestCreateFilterFile: + async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.create_filter_file", + AsyncMock(return_value="myfilter.conf"), + ): + resp = await file_config_client.post( + "/api/config/filters/raw", + json={"name": "myfilter", "content": "[Definition]\n"}, + ) + + assert resp.status_code == 201 + assert resp.json()["filename"] == "myfilter.conf" + + async def test_409_conflict(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.create_filter_file", + AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")), + ): + resp = await file_config_client.post( + "/api/config/filters/raw", + json={"name": "myfilter", "content": "[Definition]\n"}, + ) + + assert resp.status_code == 409 + + async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.create_filter_file", + AsyncMock(side_effect=ConfigFileNameError("bad/../name")), + ): + resp = await file_config_client.post( + "/api/config/filters/raw", + json={"name": "../escape", "content": "[Definition]\n"}, + ) + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# GET /api/config/actions (smoke test — same logic as filters) +# Note: GET /api/config/actions is handled by config.router (registered first); +# file_config.router's "/actions" endpoint is shadowed by it. +# --------------------------------------------------------------------------- + + +class TestListActionFiles: + async def test_200_returns_files(self, file_config_client: AsyncClient) -> None: + from app.models.config import ActionListResponse + + mock_action = ActionConfig( + name="iptables", + filename="iptables.conf", + ) + resp_data = ActionListResponse(actions=[mock_action], total=1) + with patch( + "app.routers.config.config_file_service.list_actions", + AsyncMock(return_value=resp_data), + ): + resp = await file_config_client.get("/api/config/actions") + + assert resp.status_code == 200 + assert resp.json()["actions"][0]["name"] == "iptables" + + +# --------------------------------------------------------------------------- +# POST /api/config/actions +# Note: POST /api/config/actions is also handled by config.router. +# --------------------------------------------------------------------------- + + +class TestCreateActionFile: + async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: + created = ActionConfig( + name="myaction", + filename="myaction.local", + actionban="echo ban ", + ) + with patch( + "app.routers.config.config_file_service.create_action", + AsyncMock(return_value=created), + ): + resp = await file_config_client.post( + "/api/config/actions", + json={"name": "myaction", "actionban": "echo ban "}, + ) + + assert resp.status_code == 201 + assert resp.json()["name"] == "myaction" + + +# --------------------------------------------------------------------------- +# POST /api/config/jail-files +# --------------------------------------------------------------------------- + + +class TestCreateJailConfigFile: + async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.create_jail_config_file", + AsyncMock(return_value="myjail.conf"), + ): + resp = await file_config_client.post( + "/api/config/jail-files", + json={"name": "myjail", "content": "[myjail]\nenabled = true\n"}, + ) + + assert resp.status_code == 201 + assert resp.json()["filename"] == "myjail.conf" + + async def test_409_conflict(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.create_jail_config_file", + AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")), + ): + resp = await file_config_client.post( + "/api/config/jail-files", + json={"name": "myjail", "content": "[myjail]\nenabled = true\n"}, + ) + + assert resp.status_code == 409 + + async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.create_jail_config_file", + AsyncMock(side_effect=ConfigFileNameError("bad/../name")), + ): + resp = await file_config_client.post( + "/api/config/jail-files", + json={"name": "../escape", "content": "[Definition]\n"}, + ) + + assert resp.status_code == 400 + + async def test_503_on_config_dir_error( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.create_jail_config_file", + AsyncMock(side_effect=ConfigDirError("no dir")), + ): + resp = await file_config_client.post( + "/api/config/jail-files", + json={"name": "anyjail", "content": "[anyjail]\nenabled = false\n"}, + ) + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# GET /api/config/filters/{name}/parsed +# --------------------------------------------------------------------------- + + +class TestGetParsedFilter: + async def test_200_returns_parsed_config( + self, file_config_client: AsyncClient + ) -> None: + cfg = FilterConfig(name="nginx", filename="nginx.conf") + with patch( + "app.routers.file_config.file_config_service.get_parsed_filter_file", + AsyncMock(return_value=cfg), + ): + resp = await file_config_client.get("/api/config/filters/nginx/parsed") + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "nginx" + assert data["filename"] == "nginx.conf" + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_parsed_filter_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.get( + "/api/config/filters/missing/parsed" + ) + + assert resp.status_code == 404 + + async def test_503_on_config_dir_error( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.get_parsed_filter_file", + AsyncMock(side_effect=ConfigDirError("no dir")), + ): + resp = await file_config_client.get("/api/config/filters/nginx/parsed") + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# PUT /api/config/filters/{name}/parsed +# --------------------------------------------------------------------------- + + +class TestUpdateParsedFilter: + async def test_204_on_success(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_filter_file", + AsyncMock(return_value=None), + ): + resp = await file_config_client.put( + "/api/config/filters/nginx/parsed", + json={"failregex": ["^ "]}, + ) + + assert resp.status_code == 204 + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_filter_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.put( + "/api/config/filters/missing/parsed", + json={"failregex": []}, + ) + + assert resp.status_code == 404 + + async def test_400_write_error(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_filter_file", + AsyncMock(side_effect=ConfigFileWriteError("disk full")), + ): + resp = await file_config_client.put( + "/api/config/filters/nginx/parsed", + json={"failregex": ["^ "]}, + ) + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# GET /api/config/actions/{name}/parsed +# --------------------------------------------------------------------------- + + +class TestGetParsedAction: + async def test_200_returns_parsed_config( + self, file_config_client: AsyncClient + ) -> None: + cfg = ActionConfig(name="iptables", filename="iptables.conf") + with patch( + "app.routers.file_config.file_config_service.get_parsed_action_file", + AsyncMock(return_value=cfg), + ): + resp = await file_config_client.get( + "/api/config/actions/iptables/parsed" + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "iptables" + assert data["filename"] == "iptables.conf" + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_parsed_action_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.get( + "/api/config/actions/missing/parsed" + ) + + assert resp.status_code == 404 + + async def test_503_on_config_dir_error( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.get_parsed_action_file", + AsyncMock(side_effect=ConfigDirError("no dir")), + ): + resp = await file_config_client.get( + "/api/config/actions/iptables/parsed" + ) + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# PUT /api/config/actions/{name}/parsed +# --------------------------------------------------------------------------- + + +class TestUpdateParsedAction: + async def test_204_on_success(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_action_file", + AsyncMock(return_value=None), + ): + resp = await file_config_client.put( + "/api/config/actions/iptables/parsed", + json={"actionban": "iptables -I INPUT -s -j DROP"}, + ) + + assert resp.status_code == 204 + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_action_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.put( + "/api/config/actions/missing/parsed", + json={"actionban": ""}, + ) + + assert resp.status_code == 404 + + async def test_400_write_error(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_action_file", + AsyncMock(side_effect=ConfigFileWriteError("disk full")), + ): + resp = await file_config_client.put( + "/api/config/actions/iptables/parsed", + json={"actionban": "iptables -I INPUT -s -j DROP"}, + ) + + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# GET /api/config/jail-files/{filename}/parsed +# --------------------------------------------------------------------------- + + +class TestGetParsedJailFile: + async def test_200_returns_parsed_config( + self, file_config_client: AsyncClient + ) -> None: + section = JailSectionConfig(enabled=True, port="ssh") + cfg = JailFileConfig(filename="sshd.conf", jails={"sshd": section}) + with patch( + "app.routers.file_config.file_config_service.get_parsed_jail_file", + AsyncMock(return_value=cfg), + ): + resp = await file_config_client.get( + "/api/config/jail-files/sshd.conf/parsed" + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["filename"] == "sshd.conf" + assert "sshd" in data["jails"] + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_parsed_jail_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), + ): + resp = await file_config_client.get( + "/api/config/jail-files/missing.conf/parsed" + ) + + assert resp.status_code == 404 + + async def test_503_on_config_dir_error( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.get_parsed_jail_file", + AsyncMock(side_effect=ConfigDirError("no dir")), + ): + resp = await file_config_client.get( + "/api/config/jail-files/sshd.conf/parsed" + ) + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# PUT /api/config/jail-files/{filename}/parsed +# --------------------------------------------------------------------------- + + +class TestUpdateParsedJailFile: + async def test_204_on_success(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_jail_file", + AsyncMock(return_value=None), + ): + resp = await file_config_client.put( + "/api/config/jail-files/sshd.conf/parsed", + json={"jails": {"sshd": {"enabled": False}}}, + ) + + assert resp.status_code == 204 + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_jail_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), + ): + resp = await file_config_client.put( + "/api/config/jail-files/missing.conf/parsed", + json={"jails": {}}, + ) + + assert resp.status_code == 404 + + async def test_400_write_error(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.update_parsed_jail_file", + AsyncMock(side_effect=ConfigFileWriteError("disk full")), + ): + resp = await file_config_client.put( + "/api/config/jail-files/sshd.conf/parsed", + json={"jails": {"sshd": {"enabled": True}}}, + ) + + assert resp.status_code == 400 diff --git a/backend/tests/test_routers/test_geo.py b/backend/tests/test_routers/test_geo.py new file mode 100644 index 0000000..c57363e --- /dev/null +++ b/backend/tests/test_routers/test_geo.py @@ -0,0 +1,280 @@ +"""Tests for the geo/IP-lookup router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.services.geo_service import GeoInfo + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def geo_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for geo endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "geo_test.db"), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-geo-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.post("/api/setup", json=_SETUP_PAYLOAD) + login = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login.status_code == 200 + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# GET /api/geo/lookup/{ip} +# --------------------------------------------------------------------------- + + +class TestGeoLookup: + """Tests for ``GET /api/geo/lookup/{ip}``.""" + + async def test_200_with_geo_info(self, geo_client: AsyncClient) -> None: + """GET /api/geo/lookup/{ip} returns 200 with enriched result.""" + geo = GeoInfo(country_code="DE", country_name="Germany", asn="12345", org="Acme") + result = { + "ip": "1.2.3.4", + "currently_banned_in": ["sshd"], + "geo": geo, + } + with patch( + "app.routers.geo.jail_service.lookup_ip", + AsyncMock(return_value=result), + ): + resp = await geo_client.get("/api/geo/lookup/1.2.3.4") + + assert resp.status_code == 200 + data = resp.json() + assert data["ip"] == "1.2.3.4" + assert data["currently_banned_in"] == ["sshd"] + assert data["geo"]["country_code"] == "DE" + assert data["geo"]["country_name"] == "Germany" + assert data["geo"]["asn"] == "12345" + assert data["geo"]["org"] == "Acme" + + async def test_200_when_not_banned(self, geo_client: AsyncClient) -> None: + """GET /api/geo/lookup/{ip} returns empty list when IP is not banned anywhere.""" + result = { + "ip": "8.8.8.8", + "currently_banned_in": [], + "geo": GeoInfo(country_code="US", country_name="United States", asn=None, org=None), + } + with patch( + "app.routers.geo.jail_service.lookup_ip", + AsyncMock(return_value=result), + ): + resp = await geo_client.get("/api/geo/lookup/8.8.8.8") + + assert resp.status_code == 200 + assert resp.json()["currently_banned_in"] == [] + + async def test_200_with_no_geo(self, geo_client: AsyncClient) -> None: + """GET /api/geo/lookup/{ip} returns null geo when enricher fails.""" + result = { + "ip": "1.2.3.4", + "currently_banned_in": [], + "geo": None, + } + with patch( + "app.routers.geo.jail_service.lookup_ip", + AsyncMock(return_value=result), + ): + resp = await geo_client.get("/api/geo/lookup/1.2.3.4") + + assert resp.status_code == 200 + assert resp.json()["geo"] is None + + async def test_400_for_invalid_ip(self, geo_client: AsyncClient) -> None: + """GET /api/geo/lookup/{ip} returns 400 for an invalid IP address.""" + with patch( + "app.routers.geo.jail_service.lookup_ip", + AsyncMock(side_effect=ValueError("Invalid IP address: 'bad_ip'")), + ): + resp = await geo_client.get("/api/geo/lookup/bad_ip") + + assert resp.status_code == 400 + assert "detail" in resp.json() + + async def test_401_when_unauthenticated(self, geo_client: AsyncClient) -> None: + """GET /api/geo/lookup/{ip} returns 401 without a session.""" + app = geo_client._transport.app # type: ignore[attr-defined] + resp = await AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ).get("/api/geo/lookup/1.2.3.4") + assert resp.status_code == 401 + + async def test_ipv6_address(self, geo_client: AsyncClient) -> None: + """GET /api/geo/lookup/{ip} handles IPv6 addresses.""" + result = { + "ip": "2001:db8::1", + "currently_banned_in": [], + "geo": None, + } + with patch( + "app.routers.geo.jail_service.lookup_ip", + AsyncMock(return_value=result), + ): + resp = await geo_client.get("/api/geo/lookup/2001:db8::1") + + assert resp.status_code == 200 + assert resp.json()["ip"] == "2001:db8::1" + + +# --------------------------------------------------------------------------- +# POST /api/geo/re-resolve +# --------------------------------------------------------------------------- + + +class TestReResolve: + """Tests for ``POST /api/geo/re-resolve``.""" + + async def test_returns_200_with_counts(self, geo_client: AsyncClient) -> None: + """POST /api/geo/re-resolve returns 200 with resolved/total counts.""" + with patch( + "app.routers.geo.geo_service.lookup_batch", + AsyncMock(return_value={}), + ): + resp = await geo_client.post("/api/geo/re-resolve") + + assert resp.status_code == 200 + data = resp.json() + assert "resolved" in data + assert "total" in data + + async def test_empty_when_no_unresolved_ips(self, geo_client: AsyncClient) -> None: + """Returns resolved=0, total=0 when geo_cache has no NULL country_code rows.""" + resp = await geo_client.post("/api/geo/re-resolve") + + assert resp.status_code == 200 + assert resp.json() == {"resolved": 0, "total": 0} + + async def test_re_resolves_null_ips(self, geo_client: AsyncClient) -> None: + """IPs with null country_code in geo_cache are re-resolved via lookup_batch.""" + # Insert a NULL entry into geo_cache. + app = geo_client._transport.app # type: ignore[attr-defined] + db: aiosqlite.Connection = app.state.db + await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("5.5.5.5",)) + await db.commit() + + geo_result = {"5.5.5.5": GeoInfo(country_code="FR", country_name="France", asn=None, org=None)} + with patch( + "app.routers.geo.geo_service.lookup_batch", + AsyncMock(return_value=geo_result), + ): + resp = await geo_client.post("/api/geo/re-resolve") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["resolved"] == 1 + + async def test_401_when_unauthenticated(self, geo_client: AsyncClient) -> None: + """POST /api/geo/re-resolve requires authentication.""" + app = geo_client._transport.app # type: ignore[attr-defined] + resp = await AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ).post("/api/geo/re-resolve") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/geo/stats +# --------------------------------------------------------------------------- + + +class TestGeoStats: + """Tests for ``GET /api/geo/stats``.""" + + async def test_returns_200_with_stats(self, geo_client: AsyncClient) -> None: + """GET /api/geo/stats returns 200 with the expected keys.""" + stats = { + "cache_size": 100, + "unresolved": 5, + "neg_cache_size": 2, + "dirty_size": 0, + } + with patch( + "app.routers.geo.geo_service.cache_stats", + AsyncMock(return_value=stats), + ): + resp = await geo_client.get("/api/geo/stats") + + assert resp.status_code == 200 + data = resp.json() + assert data["cache_size"] == 100 + assert data["unresolved"] == 5 + assert data["neg_cache_size"] == 2 + assert data["dirty_size"] == 0 + + async def test_stats_empty_cache(self, geo_client: AsyncClient) -> None: + """GET /api/geo/stats returns all zeros on a fresh database.""" + resp = await geo_client.get("/api/geo/stats") + + assert resp.status_code == 200 + data = resp.json() + assert data["cache_size"] >= 0 + assert data["unresolved"] == 0 + assert data["neg_cache_size"] >= 0 + assert data["dirty_size"] >= 0 + + async def test_stats_counts_unresolved(self, geo_client: AsyncClient) -> None: + """GET /api/geo/stats counts NULL-country rows correctly.""" + app = geo_client._transport.app # type: ignore[attr-defined] + db: aiosqlite.Connection = app.state.db + await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("7.7.7.7",)) + await db.execute("INSERT OR IGNORE INTO geo_cache (ip) VALUES (?)", ("8.8.8.8",)) + await db.commit() + + resp = await geo_client.get("/api/geo/stats") + + assert resp.status_code == 200 + assert resp.json()["unresolved"] >= 2 + + async def test_401_when_unauthenticated(self, geo_client: AsyncClient) -> None: + """GET /api/geo/stats requires authentication.""" + app = geo_client._transport.app # type: ignore[attr-defined] + resp = await AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ).get("/api/geo/stats") + assert resp.status_code == 401 diff --git a/backend/tests/test_routers/test_health.py b/backend/tests/test_routers/test_health.py new file mode 100644 index 0000000..f24e83e --- /dev/null +++ b/backend/tests/test_routers/test_health.py @@ -0,0 +1,27 @@ +"""Tests for the health check router.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_health_check_returns_200(client: AsyncClient) -> None: + """``GET /api/health`` must return HTTP 200.""" + response = await client.get("/api/health") + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_health_check_returns_ok_status(client: AsyncClient) -> None: + """``GET /api/health`` must contain ``status: ok`` and a ``fail2ban`` field.""" + response = await client.get("/api/health") + data: dict[str, str] = response.json() + assert data["status"] == "ok" + assert data["fail2ban"] in ("online", "offline") + + +@pytest.mark.asyncio +async def test_health_check_content_type_is_json(client: AsyncClient) -> None: + """``GET /api/health`` must set the ``Content-Type`` header to JSON.""" + response = await client.get("/api/health") + assert "application/json" in response.headers.get("content-type", "") diff --git a/backend/tests/test_routers/test_history.py b/backend/tests/test_routers/test_history.py new file mode 100644 index 0000000..898a3cf --- /dev/null +++ b/backend/tests/test_routers/test_history.py @@ -0,0 +1,333 @@ +"""Tests for the history router (GET /api/history, GET /api/history/{ip}).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.history import ( + HistoryBanItem, + HistoryListResponse, + IpDetailResponse, + IpTimelineEvent, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +def _make_history_item(ip: str = "1.2.3.4", jail: str = "sshd") -> HistoryBanItem: + """Build a single ``HistoryBanItem`` for use in test responses.""" + return HistoryBanItem( + ip=ip, + jail=jail, + banned_at="2026-03-01T10:00:00+00:00", + ban_count=3, + failures=5, + matches=["Mar 1 10:00:00 host sshd[123]: Failed password for root"], + country_code="DE", + country_name="Germany", + asn="AS3320", + org="Telekom", + ) + + +def _make_history_list(n: int = 2) -> HistoryListResponse: + """Build a mock ``HistoryListResponse`` with *n* items.""" + items = [_make_history_item(ip=f"1.2.3.{i}") for i in range(n)] + return HistoryListResponse(items=items, total=n, page=1, page_size=100) + + +def _make_ip_detail(ip: str = "1.2.3.4") -> IpDetailResponse: + """Build a mock ``IpDetailResponse`` for *ip*.""" + events = [ + IpTimelineEvent( + jail="sshd", + banned_at="2026-03-01T10:00:00+00:00", + ban_count=3, + failures=5, + matches=["Mar 1 10:00:00 host sshd[123]: Failed password for root"], + ), + IpTimelineEvent( + jail="sshd", + banned_at="2026-02-28T08:00:00+00:00", + ban_count=2, + failures=5, + matches=[], + ), + ] + return IpDetailResponse( + ip=ip, + total_bans=2, + total_failures=10, + last_ban_at="2026-03-01T10:00:00+00:00", + country_code="DE", + country_name="Germany", + asn="AS3320", + org="Telekom", + timeline=events, + ) + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def history_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for history endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "history_test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-history-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + login_resp = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login_resp.status_code == 200 + + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# GET /api/history +# --------------------------------------------------------------------------- + + +class TestHistoryList: + """GET /api/history — paginated history list.""" + + async def test_returns_200_when_authenticated( + self, history_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200.""" + with patch( + "app.routers.history.history_service.list_history", + new=AsyncMock(return_value=_make_history_list()), + ): + response = await history_client.get("/api/history") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/history") + assert response.status_code == 401 + + async def test_response_shape(self, history_client: AsyncClient) -> None: + """Response body contains the expected keys.""" + mock_response = _make_history_list(n=1) + with patch( + "app.routers.history.history_service.list_history", + new=AsyncMock(return_value=mock_response), + ): + response = await history_client.get("/api/history") + + body = response.json() + assert "items" in body + assert "total" in body + assert "page" in body + assert "page_size" in body + assert body["total"] == 1 + + item = body["items"][0] + assert "ip" in item + assert "jail" in item + assert "banned_at" in item + assert "ban_count" in item + assert "failures" in item + assert "matches" in item + assert item["country_code"] == "DE" + + async def test_forwards_jail_filter(self, history_client: AsyncClient) -> None: + """The ``jail`` 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?jail=nginx") + + _args, kwargs = mock_fn.call_args + assert kwargs.get("jail") == "nginx" + + async def test_forwards_ip_filter(self, history_client: AsyncClient) -> None: + """The ``ip`` query parameter is forwarded as ``ip_filter`` 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?ip=192.168") + + _args, kwargs = mock_fn.call_args + assert kwargs.get("ip_filter") == "192.168" + + async def test_forwards_time_range(self, history_client: AsyncClient) -> None: + """The ``range`` query parameter is forwarded as ``range_`` 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?range=7d") + + _args, kwargs = mock_fn.call_args + assert kwargs.get("range_") == "7d" + + async def test_empty_result(self, history_client: AsyncClient) -> None: + """An empty history returns items=[] and total=0.""" + with patch( + "app.routers.history.history_service.list_history", + new=AsyncMock( + return_value=HistoryListResponse(items=[], total=0, page=1, page_size=100) + ), + ): + response = await history_client.get("/api/history") + + body = response.json() + assert body["items"] == [] + assert body["total"] == 0 + + +# --------------------------------------------------------------------------- +# GET /api/history/{ip} +# --------------------------------------------------------------------------- + + +class TestIpHistory: + """GET /api/history/{ip} — per-IP detail.""" + + async def test_returns_200_when_authenticated( + self, history_client: AsyncClient + ) -> None: + """Authenticated request returns HTTP 200 for a known IP.""" + with patch( + "app.routers.history.history_service.get_ip_detail", + new=AsyncMock(return_value=_make_ip_detail("1.2.3.4")), + ): + response = await history_client.get("/api/history/1.2.3.4") + assert response.status_code == 200 + + async def test_returns_401_when_unauthenticated( + self, client: AsyncClient + ) -> None: + """Unauthenticated request returns HTTP 401.""" + await client.post("/api/setup", json=_SETUP_PAYLOAD) + response = await client.get("/api/history/1.2.3.4") + assert response.status_code == 401 + + async def test_returns_404_for_unknown_ip( + self, history_client: AsyncClient + ) -> None: + """Returns 404 when the IP has no records in the database.""" + with patch( + "app.routers.history.history_service.get_ip_detail", + new=AsyncMock(return_value=None), + ): + response = await history_client.get("/api/history/9.9.9.9") + assert response.status_code == 404 + + async def test_response_shape(self, history_client: AsyncClient) -> None: + """Response body contains the expected keys and nested timeline.""" + mock_detail = _make_ip_detail("1.2.3.4") + with patch( + "app.routers.history.history_service.get_ip_detail", + new=AsyncMock(return_value=mock_detail), + ): + response = await history_client.get("/api/history/1.2.3.4") + + body = response.json() + assert body["ip"] == "1.2.3.4" + assert body["total_bans"] == 2 + assert body["total_failures"] == 10 + assert body["country_code"] == "DE" + assert "timeline" in body + assert len(body["timeline"]) == 2 + + event = body["timeline"][0] + assert "jail" in event + assert "banned_at" in event + assert "ban_count" in event + assert "failures" in event + assert "matches" in event + + async def test_aggregation_sums_failures( + self, history_client: AsyncClient + ) -> None: + """total_failures reflects the sum across all timeline events.""" + mock_detail = _make_ip_detail("10.0.0.1") + mock_detail = IpDetailResponse( + ip="10.0.0.1", + total_bans=3, + total_failures=15, + last_ban_at="2026-03-01T10:00:00+00:00", + country_code=None, + country_name=None, + asn=None, + org=None, + timeline=[ + IpTimelineEvent( + jail="sshd", + banned_at="2026-03-01T10:00:00+00:00", + ban_count=3, + failures=7, + matches=[], + ), + IpTimelineEvent( + jail="sshd", + banned_at="2026-02-28T08:00:00+00:00", + ban_count=2, + failures=8, + matches=[], + ), + ], + ) + with patch( + "app.routers.history.history_service.get_ip_detail", + new=AsyncMock(return_value=mock_detail), + ): + response = await history_client.get("/api/history/10.0.0.1") + + assert response.status_code == 200 + body = response.json() + assert body["total_failures"] == 15 + assert body["total_bans"] == 3 diff --git a/backend/tests/test_routers/test_jails.py b/backend/tests/test_routers/test_jails.py new file mode 100644 index 0000000..4954e23 --- /dev/null +++ b/backend/tests/test_routers/test_jails.py @@ -0,0 +1,933 @@ +"""Tests for the jails router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.jail import Jail, JailDetailResponse, JailListResponse, JailStatus, JailSummary + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def jails_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for jail endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "jails_test.db"), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-jails-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.post("/api/setup", json=_SETUP_PAYLOAD) + login = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login.status_code == 200 + yield ac + + await db.close() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _summary(name: str = "sshd") -> JailSummary: + return JailSummary( + name=name, + enabled=True, + running=True, + idle=False, + backend="polling", + find_time=600, + ban_time=600, + max_retry=5, + status=JailStatus( + currently_banned=2, + total_banned=10, + currently_failed=1, + total_failed=50, + ), + ) + + +def _detail(name: str = "sshd") -> JailDetailResponse: + return JailDetailResponse( + jail=Jail( + name=name, + enabled=True, + running=True, + idle=False, + backend="polling", + log_paths=["/var/log/auth.log"], + fail_regex=["^.*Failed.*"], + ignore_regex=[], + ignore_ips=["127.0.0.1"], + date_pattern=None, + log_encoding="UTF-8", + find_time=600, + ban_time=600, + max_retry=5, + actions=["iptables-multiport"], + status=JailStatus( + currently_banned=2, + total_banned=10, + currently_failed=1, + total_failed=50, + ), + ) + ) + + +# --------------------------------------------------------------------------- +# GET /api/jails +# --------------------------------------------------------------------------- + + +class TestGetJails: + """Tests for ``GET /api/jails``.""" + + async def test_200_when_authenticated(self, jails_client: AsyncClient) -> None: + """GET /api/jails returns 200 with a JailListResponse.""" + mock_response = JailListResponse(jails=[_summary()], total=1) + with patch( + "app.routers.jails.jail_service.list_jails", + AsyncMock(return_value=mock_response), + ): + resp = await jails_client.get("/api/jails") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["jails"][0]["name"] == "sshd" + + async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None: + """GET /api/jails returns 401 without a session cookie.""" + resp = await AsyncClient( + transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/jails") + assert resp.status_code == 401 + + async def test_response_shape(self, jails_client: AsyncClient) -> None: + """GET /api/jails response contains expected fields.""" + mock_response = JailListResponse(jails=[_summary()], total=1) + with patch( + "app.routers.jails.jail_service.list_jails", + AsyncMock(return_value=mock_response), + ): + resp = await jails_client.get("/api/jails") + + jail = resp.json()["jails"][0] + assert "name" in jail + assert "enabled" in jail + assert "running" in jail + assert "idle" in jail + assert "backend" in jail + assert "status" in jail + + +# --------------------------------------------------------------------------- +# GET /api/jails/{name} +# --------------------------------------------------------------------------- + + +class TestGetJailDetail: + """Tests for ``GET /api/jails/{name}``.""" + + async def test_200_for_existing_jail(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd returns 200 with full jail detail.""" + with patch( + "app.routers.jails.jail_service.get_jail", + AsyncMock(return_value=_detail()), + ): + resp = await jails_client.get("/api/jails/sshd") + + assert resp.status_code == 200 + data = resp.json() + assert data["jail"]["name"] == "sshd" + assert "log_paths" in data["jail"] + assert "fail_regex" in data["jail"] + assert "actions" in data["jail"] + + async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """GET /api/jails/ghost returns 404.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.get_jail", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.get("/api/jails/ghost") + + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# POST /api/jails/{name}/start +# --------------------------------------------------------------------------- + + +class TestStartJail: + """Tests for ``POST /api/jails/{name}/start``.""" + + async def test_200_starts_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/start returns 200 on success.""" + with patch( + "app.routers.jails.jail_service.start_jail", + AsyncMock(return_value=None), + ): + resp = await jails_client.post("/api/jails/sshd/start") + + assert resp.status_code == 200 + assert resp.json()["jail"] == "sshd" + + async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/start returns 404.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.start_jail", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post("/api/jails/ghost/start") + + assert resp.status_code == 404 + + async def test_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/start returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.start_jail", + AsyncMock(side_effect=JailOperationError("already running")), + ): + resp = await jails_client.post("/api/jails/sshd/start") + + assert resp.status_code == 409 + + +# --------------------------------------------------------------------------- +# POST /api/jails/{name}/stop +# --------------------------------------------------------------------------- + + +class TestStopJail: + """Tests for ``POST /api/jails/{name}/stop``.""" + + async def test_200_stops_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/stop returns 200 on success.""" + with patch( + "app.routers.jails.jail_service.stop_jail", + AsyncMock(return_value=None), + ): + resp = await jails_client.post("/api/jails/sshd/stop") + + assert resp.status_code == 200 + + async def test_200_for_already_stopped_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/stop returns 200 even when the jail is already stopped. + + stop_jail is idempotent — service returns None rather than raising + JailNotFoundError when the jail is not present in fail2ban's runtime. + """ + with patch( + "app.routers.jails.jail_service.stop_jail", + AsyncMock(return_value=None), + ): + resp = await jails_client.post("/api/jails/sshd/stop") + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# POST /api/jails/{name}/idle +# --------------------------------------------------------------------------- + + +class TestToggleIdle: + """Tests for ``POST /api/jails/{name}/idle``.""" + + async def test_200_idle_on(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/idle?on=true returns 200.""" + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 200 + + async def test_200_idle_off(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/idle with false turns idle off.""" + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/idle", + content="false", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# POST /api/jails/{name}/reload +# --------------------------------------------------------------------------- + + +class TestReloadJail: + """Tests for ``POST /api/jails/{name}/reload``.""" + + async def test_200_reloads_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/reload returns 200 on success.""" + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(return_value=None), + ): + resp = await jails_client.post("/api/jails/sshd/reload") + + assert resp.status_code == 200 + assert resp.json()["jail"] == "sshd" + + +# --------------------------------------------------------------------------- +# POST /api/jails/reload-all +# --------------------------------------------------------------------------- + + +class TestReloadAll: + """Tests for ``POST /api/jails/reload-all``.""" + + async def test_200_reloads_all(self, jails_client: AsyncClient) -> None: + """POST /api/jails/reload-all returns 200 on success.""" + with patch( + "app.routers.jails.jail_service.reload_all", + AsyncMock(return_value=None), + ): + resp = await jails_client.post("/api/jails/reload-all") + + assert resp.status_code == 200 + assert resp.json()["jail"] == "*" + + +# --------------------------------------------------------------------------- +# GET /api/jails/{name}/ignoreip +# --------------------------------------------------------------------------- + + +class TestIgnoreIpEndpoints: + """Tests for ignore-list management endpoints.""" + + async def test_get_ignore_list(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/ignoreip returns 200 with a list.""" + with patch( + "app.routers.jails.jail_service.get_ignore_list", + AsyncMock(return_value=["127.0.0.1"]), + ): + resp = await jails_client.get("/api/jails/sshd/ignoreip") + + assert resp.status_code == 200 + assert "127.0.0.1" in resp.json() + + async def test_add_ignore_ip_returns_201(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreip returns 201 on success.""" + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreip", + json={"ip": "192.168.1.0/24"}, + ) + + assert resp.status_code == 201 + + async def test_add_invalid_ip_returns_400(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreip returns 400 for invalid IP.""" + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=ValueError("Invalid IP address or network: 'bad'")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreip", + json={"ip": "bad"}, + ) + + assert resp.status_code == 400 + + async def test_delete_ignore_ip(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/sshd/ignoreip returns 200 on success.""" + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(return_value=None), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/sshd/ignoreip", + json={"ip": "127.0.0.1"}, + ) + + assert resp.status_code == 200 + + async def test_get_ignore_list_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """GET /api/jails/ghost/ignoreip returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.get_ignore_list", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.get("/api/jails/ghost/ignoreip") + + assert resp.status_code == 404 + + async def test_get_ignore_list_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/ignoreip returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.get_ignore_list", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.get("/api/jails/sshd/ignoreip") + + assert resp.status_code == 502 + + async def test_add_ignore_ip_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/ignoreip returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post( + "/api/jails/ghost/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 404 + + async def test_add_ignore_ip_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreip returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=JailOperationError("fail2ban rejected")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 409 + + async def test_add_ignore_ip_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreip returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.add_ignore_ip", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 502 + + async def test_delete_ignore_ip_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/ghost/ignoreip returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/ghost/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 404 + + async def test_delete_ignore_ip_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/sshd/ignoreip returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(side_effect=JailOperationError("fail2ban rejected")), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 409 + + async def test_delete_ignore_ip_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """DELETE /api/jails/sshd/ignoreip returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.del_ignore_ip", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.request( + "DELETE", + "/api/jails/sshd/ignoreip", + json={"ip": "1.2.3.4"}, + ) + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# POST /api/jails/{name}/ignoreself +# --------------------------------------------------------------------------- + + +class TestToggleIgnoreSelf: + """Tests for ``POST /api/jails/{name}/ignoreself``.""" + + async def test_200_enables_ignore_self(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself with ``true`` returns 200.""" + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 200 + assert "enabled" in resp.json()["message"] + + async def test_200_disables_ignore_self(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself with ``false`` returns 200.""" + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(return_value=None), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="false", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 200 + assert "disabled" in resp.json()["message"] + + async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/ignoreself returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post( + "/api/jails/ghost/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 404 + + async def test_409_on_operation_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(side_effect=JailOperationError("fail2ban rejected")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 409 + + async def test_502_on_connection_error(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/ignoreself returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.set_ignore_self", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post( + "/api/jails/sshd/ignoreself", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# 502 error paths — Fail2BanConnectionError across remaining endpoints +# --------------------------------------------------------------------------- + + +class TestFail2BanConnectionErrors: + """Tests that every endpoint returns 502 when fail2ban is unreachable.""" + + async def test_get_jails_502(self, jails_client: AsyncClient) -> None: + """GET /api/jails returns 502 when fail2ban socket is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.list_jails", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.get("/api/jails") + + assert resp.status_code == 502 + + async def test_get_jail_502(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.get_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.get("/api/jails/sshd") + + assert resp.status_code == 502 + + async def test_reload_all_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/reload-all returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.reload_all", + AsyncMock(side_effect=JailOperationError("reload failed")), + ): + resp = await jails_client.post("/api/jails/reload-all") + + assert resp.status_code == 409 + + async def test_reload_all_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/reload-all returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.reload_all", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/reload-all") + + assert resp.status_code == 502 + + async def test_start_jail_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/start returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.start_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/sshd/start") + + assert resp.status_code == 502 + + async def test_stop_jail_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/stop returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.stop_jail", + AsyncMock(side_effect=JailOperationError("stop failed")), + ): + resp = await jails_client.post("/api/jails/sshd/stop") + + assert resp.status_code == 409 + + async def test_stop_jail_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/stop returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.stop_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/sshd/stop") + + assert resp.status_code == 502 + + async def test_toggle_idle_404(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/idle returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post( + "/api/jails/ghost/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 404 + + async def test_toggle_idle_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/idle returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(side_effect=JailOperationError("idle failed")), + ): + resp = await jails_client.post( + "/api/jails/sshd/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 409 + + async def test_toggle_idle_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/idle returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.set_idle", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post( + "/api/jails/sshd/idle", + content="true", + headers={"Content-Type": "application/json"}, + ) + + assert resp.status_code == 502 + + async def test_reload_jail_404(self, jails_client: AsyncClient) -> None: + """POST /api/jails/ghost/reload returns 404 for unknown jail.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.post("/api/jails/ghost/reload") + + assert resp.status_code == 404 + + async def test_reload_jail_409(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/reload returns 409 on operation failure.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(side_effect=JailOperationError("reload failed")), + ): + resp = await jails_client.post("/api/jails/sshd/reload") + + assert resp.status_code == 409 + + async def test_reload_jail_502(self, jails_client: AsyncClient) -> None: + """POST /api/jails/sshd/reload returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.reload_jail", + AsyncMock(side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock")), + ): + resp = await jails_client.post("/api/jails/sshd/reload") + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# GET /api/jails/{name}/banned +# --------------------------------------------------------------------------- + + +class TestGetJailBannedIps: + """Tests for ``GET /api/jails/{name}/banned``.""" + + def _mock_response( + self, + *, + items: list[dict] | None = None, + total: int = 2, + page: int = 1, + page_size: int = 25, + ) -> "JailBannedIpsResponse": # type: ignore[name-defined] + from app.models.ban import ActiveBan, JailBannedIpsResponse + + ban_items = ( + [ + ActiveBan( + ip=item.get("ip", "1.2.3.4"), + jail="sshd", + banned_at=item.get("banned_at", "2025-01-01T10:00:00+00:00"), + expires_at=item.get("expires_at", "2025-01-01T10:10:00+00:00"), + ban_count=1, + country=item.get("country", None), + ) + for item in (items or [{"ip": "1.2.3.4"}, {"ip": "5.6.7.8"}]) + ] + ) + return JailBannedIpsResponse( + items=ban_items, total=total, page=page, page_size=page_size + ) + + async def test_200_returns_paginated_bans(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned returns 200 with a JailBannedIpsResponse.""" + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock(return_value=self._mock_response()), + ): + resp = await jails_client.get("/api/jails/sshd/banned") + + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + assert "page" in data + assert "page_size" in data + assert data["total"] == 2 + + async def test_200_with_search_parameter(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?search=1.2.3 passes search to service.""" + mock_fn = AsyncMock(return_value=self._mock_response(items=[{"ip": "1.2.3.4"}], total=1)) + with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn): + resp = await jails_client.get("/api/jails/sshd/banned?search=1.2.3") + + assert resp.status_code == 200 + _args, call_kwargs = mock_fn.call_args + assert call_kwargs.get("search") == "1.2.3" + + async def test_200_with_page_and_page_size(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page=2&page_size=10 passes params to service.""" + mock_fn = AsyncMock( + return_value=self._mock_response(page=2, page_size=10, total=0, items=[]) + ) + with patch("app.routers.jails.jail_service.get_jail_banned_ips", mock_fn): + resp = await jails_client.get("/api/jails/sshd/banned?page=2&page_size=10") + + assert resp.status_code == 200 + _args, call_kwargs = mock_fn.call_args + assert call_kwargs.get("page") == 2 + assert call_kwargs.get("page_size") == 10 + + async def test_400_when_page_is_zero(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page=0 returns 400.""" + resp = await jails_client.get("/api/jails/sshd/banned?page=0") + assert resp.status_code == 400 + + async def test_400_when_page_size_exceeds_max(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page_size=200 returns 400.""" + resp = await jails_client.get("/api/jails/sshd/banned?page_size=200") + assert resp.status_code == 400 + + async def test_400_when_page_size_is_zero(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned?page_size=0 returns 400.""" + resp = await jails_client.get("/api/jails/sshd/banned?page_size=0") + assert resp.status_code == 400 + + async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None: + """GET /api/jails/ghost/banned returns 404 when jail is unknown.""" + from app.services.jail_service import JailNotFoundError + + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock(side_effect=JailNotFoundError("ghost")), + ): + resp = await jails_client.get("/api/jails/ghost/banned") + + assert resp.status_code == 404 + + async def test_502_when_fail2ban_unreachable(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock( + side_effect=Fail2BanConnectionError("socket dead", "/tmp/fake.sock") + ), + ): + resp = await jails_client.get("/api/jails/sshd/banned") + + assert resp.status_code == 502 + + async def test_response_items_have_expected_fields( + self, jails_client: AsyncClient + ) -> None: + """Response items contain ip, jail, banned_at, expires_at, ban_count, country.""" + with patch( + "app.routers.jails.jail_service.get_jail_banned_ips", + AsyncMock(return_value=self._mock_response()), + ): + resp = await jails_client.get("/api/jails/sshd/banned") + + item = resp.json()["items"][0] + assert "ip" in item + assert "jail" in item + assert "banned_at" in item + assert "expires_at" in item + assert "ban_count" in item + assert "country" in item + + async def test_401_when_unauthenticated(self, jails_client: AsyncClient) -> None: + """GET /api/jails/sshd/banned returns 401 without a session cookie.""" + resp = await AsyncClient( + transport=ASGITransport(app=jails_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/jails/sshd/banned") + assert resp.status_code == 401 + diff --git a/backend/tests/test_routers/test_server.py b/backend/tests/test_routers/test_server.py new file mode 100644 index 0000000..359de75 --- /dev/null +++ b/backend/tests/test_routers/test_server.py @@ -0,0 +1,227 @@ +"""Tests for the server settings router endpoints.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app +from app.models.server import ServerSettings, ServerSettingsResponse + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "testpassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +@pytest.fixture +async def server_client(tmp_path: Path) -> AsyncClient: # type: ignore[misc] + """Provide an authenticated ``AsyncClient`` for server endpoint tests.""" + settings = Settings( + database_path=str(tmp_path / "server_test.db"), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-server-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + app.state.http_session = MagicMock() + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.post("/api/setup", json=_SETUP_PAYLOAD) + login = await ac.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + assert login.status_code == 200 + yield ac + + await db.close() + + +def _make_settings() -> ServerSettingsResponse: + return ServerSettingsResponse( + settings=ServerSettings( + log_level="INFO", + log_target="/var/log/fail2ban.log", + syslog_socket=None, + db_path="/var/lib/fail2ban/fail2ban.sqlite3", + db_purge_age=86400, + db_max_matches=10, + ) + ) + + +# --------------------------------------------------------------------------- +# GET /api/server/settings +# --------------------------------------------------------------------------- + + +class TestGetServerSettings: + """Tests for ``GET /api/server/settings``.""" + + async def test_200_returns_settings(self, server_client: AsyncClient) -> None: + """GET /api/server/settings returns 200 with ServerSettingsResponse.""" + mock_response = _make_settings() + with patch( + "app.routers.server.server_service.get_settings", + AsyncMock(return_value=mock_response), + ): + resp = await server_client.get("/api/server/settings") + + assert resp.status_code == 200 + data = resp.json() + assert data["settings"]["log_level"] == "INFO" + assert data["settings"]["db_purge_age"] == 86400 + + async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None: + """GET /api/server/settings returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/server/settings") + assert resp.status_code == 401 + + async def test_502_on_connection_error(self, server_client: AsyncClient) -> None: + """GET /api/server/settings returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.server.server_service.get_settings", + AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), + ): + resp = await server_client.get("/api/server/settings") + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# PUT /api/server/settings +# --------------------------------------------------------------------------- + + +class TestUpdateServerSettings: + """Tests for ``PUT /api/server/settings``.""" + + async def test_204_on_success(self, server_client: AsyncClient) -> None: + """PUT /api/server/settings returns 204 on success.""" + with patch( + "app.routers.server.server_service.update_settings", + AsyncMock(return_value=None), + ): + resp = await server_client.put( + "/api/server/settings", + json={"log_level": "DEBUG"}, + ) + + assert resp.status_code == 204 + + async def test_400_on_operation_error(self, server_client: AsyncClient) -> None: + """PUT /api/server/settings returns 400 when set command fails.""" + from app.services.server_service import ServerOperationError + + with patch( + "app.routers.server.server_service.update_settings", + AsyncMock(side_effect=ServerOperationError("set failed")), + ): + resp = await server_client.put( + "/api/server/settings", + json={"log_level": "DEBUG"}, + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None: + """PUT /api/server/settings returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).put("/api/server/settings", json={"log_level": "DEBUG"}) + assert resp.status_code == 401 + + async def test_502_on_connection_error(self, server_client: AsyncClient) -> None: + """PUT /api/server/settings returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.server.server_service.update_settings", + AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), + ): + resp = await server_client.put( + "/api/server/settings", + json={"log_level": "INFO"}, + ) + + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# POST /api/server/flush-logs +# --------------------------------------------------------------------------- + + +class TestFlushLogs: + """Tests for ``POST /api/server/flush-logs``.""" + + async def test_200_returns_message(self, server_client: AsyncClient) -> None: + """POST /api/server/flush-logs returns 200 with a message.""" + with patch( + "app.routers.server.server_service.flush_logs", + AsyncMock(return_value="OK"), + ): + resp = await server_client.post("/api/server/flush-logs") + + assert resp.status_code == 200 + assert resp.json()["message"] == "OK" + + async def test_400_on_operation_error(self, server_client: AsyncClient) -> None: + """POST /api/server/flush-logs returns 400 when flushlogs fails.""" + from app.services.server_service import ServerOperationError + + with patch( + "app.routers.server.server_service.flush_logs", + AsyncMock(side_effect=ServerOperationError("flushlogs failed")), + ): + resp = await server_client.post("/api/server/flush-logs") + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, server_client: AsyncClient) -> None: + """POST /api/server/flush-logs returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=server_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/server/flush-logs") + assert resp.status_code == 401 + + async def test_502_on_connection_error(self, server_client: AsyncClient) -> None: + """POST /api/server/flush-logs returns 502 when fail2ban is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.server.server_service.flush_logs", + AsyncMock(side_effect=Fail2BanConnectionError("down", "/tmp/fake.sock")), + ): + resp = await server_client.post("/api/server/flush-logs") + + assert resp.status_code == 502 diff --git a/backend/tests/test_routers/test_setup.py b/backend/tests/test_routers/test_setup.py new file mode 100644 index 0000000..e07cef4 --- /dev/null +++ b/backend/tests/test_routers/test_setup.py @@ -0,0 +1,288 @@ +"""Tests for the setup router (POST /api/setup, GET /api/setup, GET /api/setup/timezone).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import aiosqlite +import pytest +from httpx import ASGITransport, AsyncClient + +from app.config import Settings +from app.db import init_db +from app.main import create_app + +# --------------------------------------------------------------------------- +# Shared setup payload +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD: dict[str, object] = { + "master_password": "supersecret123", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +# --------------------------------------------------------------------------- +# Fixture for tests that need direct access to app.state +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def app_and_client(tmp_path: Path) -> tuple[object, AsyncClient]: # type: ignore[misc] + """Yield ``(app, client)`` for tests that inspect ``app.state`` directly. + + Args: + tmp_path: Pytest-provided isolated temporary directory. + + Yields: + A tuple of ``(FastAPI app instance, AsyncClient)``. + """ + settings = Settings( + database_path=str(tmp_path / "setup_cache_test.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-setup-cache-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) + db.row_factory = aiosqlite.Row + await init_db(db) + app.state.db = db + + transport: ASGITransport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield app, ac + + await db.close() + + +class TestGetSetupStatus: + """GET /api/setup — check setup completion state.""" + + async def test_returns_not_completed_on_fresh_db(self, client: AsyncClient) -> None: + """Status endpoint reports setup not done on a fresh database.""" + response = await client.get("/api/setup") + assert response.status_code == 200 + assert response.json() == {"completed": False} + + async def test_returns_completed_after_setup(self, client: AsyncClient) -> None: + """Status endpoint reports setup done after POST /api/setup.""" + await client.post( + "/api/setup", + json={ + "master_password": "supersecret123", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, + }, + ) + response = await client.get("/api/setup") + assert response.status_code == 200 + assert response.json() == {"completed": True} + + +class TestPostSetup: + """POST /api/setup — run the first-run configuration wizard.""" + + async def test_accepts_valid_payload(self, client: AsyncClient) -> None: + """Setup endpoint returns 201 for a valid first-run payload.""" + response = await client.post( + "/api/setup", + json={ + "master_password": "supersecret123", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, + }, + ) + assert response.status_code == 201 + body = response.json() + assert "message" in body + + async def test_rejects_short_password(self, client: AsyncClient) -> None: + """Setup endpoint rejects passwords shorter than 8 characters.""" + response = await client.post( + "/api/setup", + json={"master_password": "short"}, + ) + assert response.status_code == 422 + + async def test_rejects_second_call(self, client: AsyncClient) -> None: + """Setup endpoint returns 409 if setup has already been completed.""" + payload = { + "master_password": "supersecret123", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, + } + first = await client.post("/api/setup", json=payload) + assert first.status_code == 201 + + second = await client.post("/api/setup", json=payload) + assert second.status_code == 409 + + async def test_accepts_defaults_for_optional_fields( + self, client: AsyncClient + ) -> None: + """Setup endpoint uses defaults when optional fields are omitted.""" + response = await client.post( + "/api/setup", + json={"master_password": "supersecret123"}, + ) + assert response.status_code == 201 + + +class TestSetupRedirectMiddleware: + """Verify that the setup-redirect middleware enforces setup-first.""" + + async def test_protected_endpoint_redirects_before_setup( + self, client: AsyncClient + ) -> None: + """Non-setup API requests redirect to /api/setup on a fresh instance.""" + response = await client.get( + "/api/auth/login", + follow_redirects=False, + ) + # Middleware issues 307 redirect to /api/setup + assert response.status_code == 307 + assert response.headers["location"] == "/api/setup" + + async def test_health_always_reachable_before_setup( + self, client: AsyncClient + ) -> None: + """Health endpoint is always reachable even before setup.""" + response = await client.get("/api/health") + assert response.status_code == 200 + + async def test_no_redirect_after_setup(self, client: AsyncClient) -> None: + """Protected endpoints are reachable (no redirect) after setup.""" + await client.post( + "/api/setup", + json={"master_password": "supersecret123"}, + ) + # /api/auth/login should now be reachable (returns 405 GET not allowed, + # not a setup redirect) + response = await client.post( + "/api/auth/login", + json={"password": "wrong"}, + follow_redirects=False, + ) + # 401 wrong password — not a 307 redirect + assert response.status_code == 401 + + +class TestGetTimezone: + """GET /api/setup/timezone — return the configured IANA timezone.""" + + async def test_returns_utc_before_setup(self, client: AsyncClient) -> None: + """Timezone endpoint returns 'UTC' on a fresh database (no setup yet).""" + response = await client.get("/api/setup/timezone") + assert response.status_code == 200 + assert response.json() == {"timezone": "UTC"} + + async def test_returns_configured_timezone(self, client: AsyncClient) -> None: + """Timezone endpoint returns the value set during setup.""" + await client.post( + "/api/setup", + json={ + "master_password": "supersecret123", + "timezone": "Europe/Berlin", + }, + ) + response = await client.get("/api/setup/timezone") + assert response.status_code == 200 + assert response.json() == {"timezone": "Europe/Berlin"} + + async def test_endpoint_always_reachable_before_setup( + self, client: AsyncClient + ) -> None: + """Timezone endpoint is reachable before setup (no redirect).""" + response = await client.get( + "/api/setup/timezone", + follow_redirects=False, + ) + # Should return 200, not a 307 redirect, because /api/setup paths + # are always allowed by the SetupRedirectMiddleware. + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Setup-complete flag caching in SetupRedirectMiddleware (Task 4) +# --------------------------------------------------------------------------- + + +class TestSetupCompleteCaching: + """SetupRedirectMiddleware caches the setup_complete flag in ``app.state``.""" + + async def test_cache_flag_set_after_first_post_setup_request( + self, + app_and_client: tuple[object, AsyncClient], + ) -> None: + """``_setup_complete_cached`` is set to True on the first request after setup. + + The ``/api/setup`` path is in ``_ALWAYS_ALLOWED`` so it bypasses the + middleware check. The first request to a non-exempt endpoint triggers + the DB query and, when setup is complete, populates the cache flag. + """ + from fastapi import FastAPI + + app, client = app_and_client + assert isinstance(app, FastAPI) + + # Complete setup (exempt from middleware, no flag set yet). + resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + # Flag not yet cached — setup was via an exempt path. + assert not getattr(app.state, "_setup_complete_cached", False) + + # First non-exempt request — middleware queries DB and sets the flag. + await client.post("/api/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}) # type: ignore[call-overload] + + assert app.state._setup_complete_cached is True # type: ignore[attr-defined] + + async def test_cached_path_skips_is_setup_complete( + self, + app_and_client: tuple[object, AsyncClient], + ) -> None: + """Subsequent requests do not call ``is_setup_complete`` once flag is cached. + + After the flag is set, the middleware must not touch the database for + any further requests — even if ``is_setup_complete`` would raise. + """ + from fastapi import FastAPI + + app, client = app_and_client + assert isinstance(app, FastAPI) + + # Do setup and warm the cache. + await client.post("/api/setup", json=_SETUP_PAYLOAD) + await client.post("/api/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}) # type: ignore[call-overload] + assert app.state._setup_complete_cached is True # type: ignore[attr-defined] + + call_count = 0 + + async def _counting(db): # type: ignore[no-untyped-def] + nonlocal call_count + call_count += 1 + return True + + with patch("app.services.setup_service.is_setup_complete", side_effect=_counting): + await client.post( + "/api/auth/login", + json={"password": _SETUP_PAYLOAD["master_password"]}, + ) + + # Cache was warm — is_setup_complete must not have been called. + assert call_count == 0 + diff --git a/backend/tests/test_services/__init__.py b/backend/tests/test_services/__init__.py new file mode 100644 index 0000000..00d2578 --- /dev/null +++ b/backend/tests/test_services/__init__.py @@ -0,0 +1 @@ +"""Service test package.""" diff --git a/backend/tests/test_services/test_auth_service.py b/backend/tests/test_services/test_auth_service.py new file mode 100644 index 0000000..d30a8b5 --- /dev/null +++ b/backend/tests/test_services/test_auth_service.py @@ -0,0 +1,159 @@ +"""Tests for auth_service.""" + +from __future__ import annotations + +import asyncio +import inspect +from pathlib import Path + +import aiosqlite +import pytest + +from app.db import init_db +from app.services import auth_service, setup_service + + +@pytest.fixture +async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] + """Provide an initialised DB with setup already complete.""" + conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "auth.db")) + conn.row_factory = aiosqlite.Row + await init_db(conn) + # Pre-run setup so auth operations have a password hash to check. + await setup_service.run_setup( + conn, + master_password="correctpassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="UTC", + session_duration_minutes=60, + ) + yield conn + await conn.close() + + +@pytest.fixture +async def db_no_setup(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] + """Provide an initialised DB with no setup performed.""" + conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "auth_nosetup.db")) + conn.row_factory = aiosqlite.Row + await init_db(conn) + yield conn + await conn.close() + + +class TestCheckPasswordAsync: + async def test_check_password_is_coroutine_function(self) -> None: + """_check_password must be a coroutine function (runs in thread executor).""" + assert inspect.iscoroutinefunction(auth_service._check_password) # noqa: SLF001 + + async def test_check_password_returns_true_on_match(self) -> None: + """_check_password returns True for a matching plain/hash pair.""" + import bcrypt + + hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode() + result = await auth_service._check_password("secret", hashed) # noqa: SLF001 + assert result is True + + async def test_check_password_returns_false_on_mismatch(self) -> None: + """_check_password returns False when the password does not match.""" + import bcrypt + + hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode() + result = await auth_service._check_password("wrong", hashed) # noqa: SLF001 + assert result is False + + async def test_check_password_does_not_block_event_loop(self) -> None: + """_check_password awaits without blocking; event-loop tasks can interleave.""" + import bcrypt + + hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode() + # Running two concurrent checks must complete without deadlock. + results = await asyncio.gather( + auth_service._check_password("secret", hashed), # noqa: SLF001 + auth_service._check_password("wrong", hashed), # noqa: SLF001 + ) + assert results == [True, False] + + +class TestLogin: + async def test_login_returns_session_on_correct_password( + self, db: aiosqlite.Connection + ) -> None: + """login() returns a Session on the correct password.""" + session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60) + assert session.token + assert len(session.token) == 64 # 32 bytes → 64 hex chars + assert session.expires_at > session.created_at + + async def test_login_raises_on_wrong_password( + self, db: aiosqlite.Connection + ) -> None: + """login() raises ValueError for an incorrect password.""" + with pytest.raises(ValueError, match="Incorrect password"): + await auth_service.login(db, password="wrongpassword", session_duration_minutes=60) + + async def test_login_raises_when_no_hash_configured( + self, db_no_setup: aiosqlite.Connection + ) -> None: + """login() raises ValueError when setup has not been run.""" + with pytest.raises(ValueError, match="No password is configured"): + await auth_service.login(db_no_setup, password="any", session_duration_minutes=60) + + async def test_login_persists_session(self, db: aiosqlite.Connection) -> None: + """login() stores the session in the database.""" + from app.repositories import session_repo + + session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60) + stored = await session_repo.get_session(db, session.token) + assert stored is not None + assert stored.token == session.token + + +class TestValidateSession: + async def test_validate_returns_session_for_valid_token( + self, db: aiosqlite.Connection + ) -> None: + """validate_session() returns the session for a valid token.""" + session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60) + validated = await auth_service.validate_session(db, session.token) + assert validated.token == session.token + + async def test_validate_raises_for_unknown_token( + self, db: aiosqlite.Connection + ) -> None: + """validate_session() raises ValueError for a non-existent token.""" + with pytest.raises(ValueError, match="not found"): + await auth_service.validate_session(db, "deadbeef" * 8) + + async def test_validate_raises_for_expired_session( + self, db: aiosqlite.Connection + ) -> None: + """validate_session() raises ValueError and removes an expired session.""" + from app.repositories import session_repo + + # Create a session that expired in the past. + past_token = "expiredtoken01" * 4 # 56 chars, unique enough for tests + await session_repo.create_session( + db, + token=past_token, + created_at="2000-01-01T00:00:00+00:00", + expires_at="2000-01-01T01:00:00+00:00", + ) + + with pytest.raises(ValueError, match="expired"): + await auth_service.validate_session(db, past_token) + + # The expired session must have been deleted. + assert await session_repo.get_session(db, past_token) is None + + +class TestLogout: + async def test_logout_removes_session(self, db: aiosqlite.Connection) -> None: + """logout() deletes the session so it can no longer be validated.""" + from app.repositories import session_repo + + session = await auth_service.login(db, password="correctpassword1", session_duration_minutes=60) + await auth_service.logout(db, session.token) + stored = await session_repo.get_session(db, session.token) + assert stored is None diff --git a/backend/tests/test_services/test_ban_service.py b/backend/tests/test_services/test_ban_service.py new file mode 100644 index 0000000..d0d93b7 --- /dev/null +++ b/backend/tests/test_services/test_ban_service.py @@ -0,0 +1,1042 @@ +"""Tests for ban_service.list_bans().""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, patch + +import aiosqlite +import pytest + +from app.services import ban_service + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +_NOW: int = int(time.time()) +_ONE_HOUR_AGO: int = _NOW - 3600 +_TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600 + + +async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None: + """Create a minimal fail2ban SQLite database with the given ban rows. + + Args: + path: Filesystem path for the new SQLite file. + rows: Sequence of dicts with keys ``jail``, ``ip``, ``timeofban``, + ``bantime``, ``bancount``, and optionally ``data``. + """ + async with aiosqlite.connect(path) as db: + await db.execute( + "CREATE TABLE jails (" + "name TEXT NOT NULL UNIQUE, " + "enabled INTEGER NOT NULL DEFAULT 1" + ")" + ) + await db.execute( + "CREATE TABLE bans (" + "jail TEXT NOT NULL, " + "ip TEXT, " + "timeofban INTEGER NOT NULL, " + "bantime INTEGER NOT NULL, " + "bancount INTEGER NOT NULL DEFAULT 1, " + "data JSON" + ")" + ) + for row in rows: + await db.execute( + "INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + row["jail"], + row["ip"], + row["timeofban"], + row.get("bantime", 3600), + row.get("bancount", 1), + json.dumps(row["data"]) if "data" in row else None, + ), + ) + await db.commit() + + +@pytest.fixture +async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc] + """Return the path to a test fail2ban SQLite database with several bans.""" + path = str(tmp_path / "fail2ban_test.sqlite3") + await _create_f2b_db( + path, + [ + { + "jail": "sshd", + "ip": "1.2.3.4", + "timeofban": _ONE_HOUR_AGO, + "bantime": 3600, + "bancount": 2, + "data": { + "matches": ["Nov 10 10:00 sshd[123]: Failed password for root"], + "failures": 5, + }, + }, + { + "jail": "nginx", + "ip": "5.6.7.8", + "timeofban": _ONE_HOUR_AGO, + "bantime": 7200, + "bancount": 1, + "data": {"matches": ["GET /admin HTTP/1.1"], "failures": 3}, + }, + { + "jail": "sshd", + "ip": "9.10.11.12", + "timeofban": _TWO_DAYS_AGO, + "bantime": 3600, + "bancount": 1, + "data": {"failures": 6}, # no matches + }, + ], + ) + return path + + +@pytest.fixture +async def mixed_origin_db_path(tmp_path: Path) -> str: # type: ignore[misc] + """Return a database with bans from both blocklist-import and organic jails.""" + path = str(tmp_path / "fail2ban_mixed_origin.sqlite3") + await _create_f2b_db( + path, + [ + { + "jail": "blocklist-import", + "ip": "10.0.0.1", + "timeofban": _ONE_HOUR_AGO, + "bantime": -1, + "bancount": 1, + }, + { + "jail": "sshd", + "ip": "10.0.0.2", + "timeofban": _ONE_HOUR_AGO, + "bantime": 3600, + "bancount": 3, + }, + { + "jail": "nginx", + "ip": "10.0.0.3", + "timeofban": _ONE_HOUR_AGO, + "bantime": 7200, + "bancount": 1, + }, + ], + ) + return path + + +@pytest.fixture +async def empty_f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc] + """Return the path to a fail2ban SQLite database with no ban records.""" + path = str(tmp_path / "fail2ban_empty.sqlite3") + await _create_f2b_db(path, []) + return path + + +# --------------------------------------------------------------------------- +# list_bans — happy path +# --------------------------------------------------------------------------- + + +class TestListBansHappyPath: + """Verify ban_service.list_bans() under normal conditions.""" + + async def test_returns_bans_in_range(self, f2b_db_path: str) -> None: + """Only bans within the selected range are returned.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + # Two bans within last 24 h; one is 2 days old and excluded. + assert result.total == 2 + assert len(result.items) == 2 + + async def test_results_sorted_newest_first(self, f2b_db_path: str) -> None: + """Items are ordered by ``banned_at`` descending (newest first).""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + timestamps = [item.banned_at for item in result.items] + assert timestamps == sorted(timestamps, reverse=True) + + async def test_ban_fields_present(self, f2b_db_path: str) -> None: + """Each item contains ip, jail, banned_at, ban_count.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + for item in result.items: + assert item.ip + assert item.jail + assert item.banned_at + assert item.ban_count >= 1 + + async def test_service_extracted_from_first_match(self, f2b_db_path: str) -> None: + """``service`` field is the first element of ``data.matches``.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + sshd_item = next(i for i in result.items if i.jail == "sshd") + assert sshd_item.service is not None + assert "Failed password" in sshd_item.service + + async def test_service_is_none_when_no_matches(self, f2b_db_path: str) -> None: + """``service`` is ``None`` when the ban has no stored matches.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + # Use 7d to include the older ban with no matches. + result = await ban_service.list_bans("/fake/sock", "7d") + + no_match = next(i for i in result.items if i.ip == "9.10.11.12") + assert no_match.service is None + + async def test_empty_db_returns_zero(self, empty_f2b_db_path: str) -> None: + """When no bans exist the result has total=0 and no items.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + assert result.total == 0 + assert result.items == [] + + async def test_365d_range_includes_old_bans(self, f2b_db_path: str) -> None: + """The ``365d`` range includes bans that are 2 days old.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "365d") + + assert result.total == 3 + + +# --------------------------------------------------------------------------- +# list_bans — geo enrichment +# --------------------------------------------------------------------------- + + +class TestListBansGeoEnrichment: + """Verify geo enrichment integration in ban_service.list_bans().""" + + async def test_geo_data_applied_when_enricher_provided( + self, f2b_db_path: str + ) -> None: + """Geo fields are populated when an enricher returns data.""" + from app.services.geo_service import GeoInfo + + async def fake_enricher(ip: str) -> GeoInfo: + return GeoInfo( + country_code="DE", + country_name="Germany", + asn="AS3320", + org="Deutsche Telekom", + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans( + "/fake/sock", "24h", geo_enricher=fake_enricher + ) + + for item in result.items: + assert item.country_code == "DE" + assert item.country_name == "Germany" + assert item.asn == "AS3320" + + async def test_geo_failure_does_not_break_results( + self, f2b_db_path: str + ) -> None: + """A geo enricher that raises still returns ban items (geo fields null).""" + + async def failing_enricher(ip: str) -> None: + raise RuntimeError("geo service down") + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans( + "/fake/sock", "24h", geo_enricher=failing_enricher + ) + + assert result.total == 2 + for item in result.items: + assert item.country_code is None + + +# --------------------------------------------------------------------------- +# list_bans — batch geo enrichment via http_session +# --------------------------------------------------------------------------- + + +class TestListBansBatchGeoEnrichment: + """Verify that list_bans uses lookup_batch when http_session is provided.""" + + async def test_batch_geo_applied_via_http_session( + self, f2b_db_path: str + ) -> None: + """Geo fields are populated via lookup_batch when http_session is given.""" + from unittest.mock import MagicMock + + from app.services.geo_service import GeoInfo + + fake_session = MagicMock() + fake_geo_map = { + "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="Deutsche Telekom"), + "5.6.7.8": GeoInfo(country_code="US", country_name="United States", asn="AS15169", org="Google"), + } + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ), patch( + "app.services.geo_service.lookup_batch", + new=AsyncMock(return_value=fake_geo_map), + ): + result = await ban_service.list_bans( + "/fake/sock", "24h", http_session=fake_session + ) + + assert result.total == 2 + de_item = next(i for i in result.items if i.ip == "1.2.3.4") + us_item = next(i for i in result.items if i.ip == "5.6.7.8") + assert de_item.country_code == "DE" + assert de_item.country_name == "Germany" + assert us_item.country_code == "US" + assert us_item.country_name == "United States" + + async def test_batch_failure_does_not_break_results( + self, f2b_db_path: str + ) -> None: + """A lookup_batch failure still returns items with null geo fields.""" + from unittest.mock import MagicMock + + fake_session = MagicMock() + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ), patch( + "app.services.geo_service.lookup_batch", + new=AsyncMock(side_effect=RuntimeError("batch geo down")), + ): + result = await ban_service.list_bans( + "/fake/sock", "24h", http_session=fake_session + ) + + assert result.total == 2 + for item in result.items: + assert item.country_code is None + + async def test_http_session_takes_priority_over_geo_enricher( + self, f2b_db_path: str + ) -> None: + """When both http_session and geo_enricher are provided, batch wins.""" + from unittest.mock import MagicMock + + from app.services.geo_service import GeoInfo + + fake_session = MagicMock() + fake_geo_map = { + "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None), + "5.6.7.8": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None), + } + + async def enricher_should_not_be_called(ip: str) -> GeoInfo: + raise AssertionError(f"geo_enricher was called for {ip!r} — should not happen") + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ), patch( + "app.services.geo_service.lookup_batch", + new=AsyncMock(return_value=fake_geo_map), + ): + result = await ban_service.list_bans( + "/fake/sock", + "24h", + http_session=fake_session, + geo_enricher=enricher_should_not_be_called, + ) + + assert result.total == 2 + for item in result.items: + assert item.country_code == "DE" + + +# --------------------------------------------------------------------------- +# list_bans — pagination +# --------------------------------------------------------------------------- + + +class TestListBansPagination: + """Verify pagination parameters in list_bans().""" + + async def test_page_size_respected(self, f2b_db_path: str) -> None: + """``page_size=1`` returns at most one item.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "7d", page_size=1) + + assert len(result.items) == 1 + assert result.page_size == 1 + + async def test_page_2_returns_remaining_items(self, f2b_db_path: str) -> None: + """The second page returns items not on the first page.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + page1 = await ban_service.list_bans("/fake/sock", "7d", page=1, page_size=1) + page2 = await ban_service.list_bans("/fake/sock", "7d", page=2, page_size=1) + + # Different IPs should appear on different pages. + assert page1.items[0].ip != page2.items[0].ip + + async def test_total_reflects_full_count_not_page_count( + self, f2b_db_path: str + ) -> None: + """``total`` reports all matching records regardless of pagination.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "7d", page_size=1) + + assert result.total == 3 # All three bans are within 7d. + + +# --------------------------------------------------------------------------- +# list_bans / bans_by_country — origin derivation +# --------------------------------------------------------------------------- + + +class TestBanOriginDerivation: + """Verify that ban_service correctly derives ``origin`` from jail names.""" + + async def test_blocklist_import_jail_yields_blocklist_origin( + self, mixed_origin_db_path: str + ) -> None: + """Bans from ``blocklist-import`` jail carry ``origin == "blocklist"``.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + blocklist_items = [i for i in result.items if i.jail == "blocklist-import"] + assert len(blocklist_items) == 1 + assert blocklist_items[0].origin == "blocklist" + + async def test_organic_jail_yields_selfblock_origin( + self, mixed_origin_db_path: str + ) -> None: + """Bans from organic jails (sshd, nginx, …) carry ``origin == "selfblock"``.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + organic_items = [i for i in result.items if i.jail != "blocklist-import"] + assert len(organic_items) == 2 + for item in organic_items: + assert item.origin == "selfblock" + + async def test_all_items_carry_origin_field( + self, mixed_origin_db_path: str + ) -> None: + """Every returned item has an ``origin`` field with a valid value.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h") + + for item in result.items: + assert item.origin in ("blocklist", "selfblock") + + async def test_bans_by_country_blocklist_origin( + self, mixed_origin_db_path: str + ) -> None: + """``bans_by_country`` also derives origin correctly for blocklist bans.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_country("/fake/sock", "24h") + + blocklist_bans = [b for b in result.bans if b.jail == "blocklist-import"] + assert len(blocklist_bans) == 1 + assert blocklist_bans[0].origin == "blocklist" + + async def test_bans_by_country_selfblock_origin( + self, mixed_origin_db_path: str + ) -> None: + """``bans_by_country`` derives origin correctly for organic jails.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_country("/fake/sock", "24h") + + organic_bans = [b for b in result.bans if b.jail != "blocklist-import"] + assert len(organic_bans) == 2 + for ban in organic_bans: + assert ban.origin == "selfblock" + + +# --------------------------------------------------------------------------- +# list_bans / bans_by_country — origin filter parameter +# --------------------------------------------------------------------------- + + +class TestOriginFilter: + """Verify that the origin filter correctly restricts results.""" + + async def test_list_bans_blocklist_filter_returns_only_blocklist( + self, mixed_origin_db_path: str + ) -> None: + """``origin='blocklist'`` returns only blocklist-import jail bans.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.list_bans( + "/fake/sock", "24h", origin="blocklist" + ) + + assert result.total == 1 + assert len(result.items) == 1 + assert result.items[0].jail == "blocklist-import" + assert result.items[0].origin == "blocklist" + + async def test_list_bans_selfblock_filter_excludes_blocklist( + self, mixed_origin_db_path: str + ) -> None: + """``origin='selfblock'`` excludes the blocklist-import jail.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.list_bans( + "/fake/sock", "24h", origin="selfblock" + ) + + assert result.total == 2 + assert len(result.items) == 2 + for item in result.items: + assert item.jail != "blocklist-import" + assert item.origin == "selfblock" + + async def test_list_bans_no_filter_returns_all( + self, mixed_origin_db_path: str + ) -> None: + """``origin=None`` applies no jail restriction — all bans returned.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.list_bans("/fake/sock", "24h", origin=None) + + assert result.total == 3 + + async def test_bans_by_country_blocklist_filter( + self, mixed_origin_db_path: str + ) -> None: + """``bans_by_country`` with ``origin='blocklist'`` counts only blocklist bans.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_country( + "/fake/sock", "24h", origin="blocklist" + ) + + assert result.total == 1 + assert all(b.jail == "blocklist-import" for b in result.bans) + + async def test_bans_by_country_selfblock_filter( + self, mixed_origin_db_path: str + ) -> None: + """``bans_by_country`` with ``origin='selfblock'`` excludes blocklist jails.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_country( + "/fake/sock", "24h", origin="selfblock" + ) + + assert result.total == 2 + assert all(b.jail != "blocklist-import" for b in result.bans) + + async def test_bans_by_country_no_filter_returns_all( + self, mixed_origin_db_path: str + ) -> None: + """``bans_by_country`` with ``origin=None`` returns all bans.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_country( + "/fake/sock", "24h", origin=None + ) + + assert result.total == 3 + + +# --------------------------------------------------------------------------- +# bans_by_country — background geo resolution (Task 3) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestBansbyCountryBackground: + """bans_by_country() with http_session uses cache-only geo and fires a + background task for uncached IPs instead of blocking on API calls.""" + + async def test_cached_geo_returned_without_api_call( + self, mixed_origin_db_path: str + ) -> None: + """When all IPs are in the cache, lookup_cached_only returns them and + no background task is created.""" + from app.services import geo_service + + # Pre-populate the cache for all three IPs in the fixture. + geo_service._cache["10.0.0.1"] = geo_service.GeoInfo( # type: ignore[attr-defined] + country_code="DE", country_name="Germany", asn=None, org=None + ) + geo_service._cache["10.0.0.2"] = geo_service.GeoInfo( # type: ignore[attr-defined] + country_code="US", country_name="United States", asn=None, org=None + ) + geo_service._cache["10.0.0.3"] = geo_service.GeoInfo( # type: ignore[attr-defined] + country_code="JP", country_name="Japan", asn=None, org=None + ) + + with ( + patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ), + patch( + "app.services.ban_service.asyncio.create_task" + ) as mock_create_task, + ): + mock_session = AsyncMock() + result = await ban_service.bans_by_country( + "/fake/sock", "24h", http_session=mock_session + ) + + # All countries resolved from cache — no background task needed. + mock_create_task.assert_not_called() + assert result.total == 3 + # Country counts should reflect the cached data. + assert "DE" in result.countries or "US" in result.countries or "JP" in result.countries + geo_service.clear_cache() + + async def test_uncached_ips_trigger_background_task( + self, mixed_origin_db_path: str + ) -> None: + """When IPs are NOT in the cache, create_task is called for background + resolution and the response returns without blocking.""" + from app.services import geo_service + + geo_service.clear_cache() # ensure cache is empty + + with ( + patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ), + patch( + "app.services.ban_service.asyncio.create_task" + ) as mock_create_task, + ): + mock_session = AsyncMock() + result = await ban_service.bans_by_country( + "/fake/sock", "24h", http_session=mock_session + ) + + # Background task must have been scheduled for uncached IPs. + mock_create_task.assert_called_once() + # Response is still valid with empty country map (IPs not cached yet). + assert result.total == 3 + + async def test_no_background_task_without_http_session( + self, mixed_origin_db_path: str + ) -> None: + """When http_session is None, no background task is created.""" + from app.services import geo_service + + geo_service.clear_cache() + + with ( + patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ), + patch( + "app.services.ban_service.asyncio.create_task" + ) as mock_create_task, + ): + result = await ban_service.bans_by_country( + "/fake/sock", "24h", http_session=None + ) + + mock_create_task.assert_not_called() + assert result.total == 3 + + +# --------------------------------------------------------------------------- +# ban_trend +# --------------------------------------------------------------------------- + + +class TestBanTrend: + """Verify ban_service.ban_trend() behaviour.""" + + async def test_24h_returns_24_buckets(self, empty_f2b_db_path: str) -> None: + """``range_='24h'`` always yields exactly 24 buckets.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + assert len(result.buckets) == 24 + assert result.bucket_size == "1h" + + async def test_7d_returns_28_buckets(self, empty_f2b_db_path: str) -> None: + """``range_='7d'`` yields 28 six-hour buckets.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "7d") + + assert len(result.buckets) == 28 + assert result.bucket_size == "6h" + + async def test_30d_returns_30_buckets(self, empty_f2b_db_path: str) -> None: + """``range_='30d'`` yields 30 daily buckets.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "30d") + + assert len(result.buckets) == 30 + assert result.bucket_size == "1d" + + async def test_365d_bucket_size_label(self, empty_f2b_db_path: str) -> None: + """``range_='365d'`` uses '7d' as the bucket size label.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "365d") + + assert result.bucket_size == "7d" + assert len(result.buckets) > 0 + + async def test_empty_db_all_buckets_zero(self, empty_f2b_db_path: str) -> None: + """All bucket counts are zero when the database has no bans.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + assert all(b.count == 0 for b in result.buckets) + + async def test_buckets_are_time_ordered(self, empty_f2b_db_path: str) -> None: + """Buckets are ordered chronologically (ascending timestamps).""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "7d") + + timestamps = [b.timestamp for b in result.buckets] + assert timestamps == sorted(timestamps) + + async def test_bans_counted_in_correct_bucket(self, tmp_path: Path) -> None: + """A ban at a known time appears in the expected bucket.""" + import time as _time + + now = int(_time.time()) + # Place a ban exactly 30 minutes ago — should land in bucket 0 of a 24h range + # (the most recent hour bucket when 'since' is ~24 h ago). + thirty_min_ago = now - 1800 + path = str(tmp_path / "test_bucket.sqlite3") + await _create_f2b_db( + path, + [{"jail": "sshd", "ip": "1.2.3.4", "timeofban": thirty_min_ago}], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + # Total ban count across all buckets must be exactly 1. + assert sum(b.count for b in result.buckets) == 1 + + async def test_origin_filter_blocklist(self, tmp_path: Path) -> None: + """``origin='blocklist'`` counts only blocklist-import bans.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_trend_origin.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago}, + {"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend( + "/fake/sock", "24h", origin="blocklist" + ) + + assert sum(b.count for b in result.buckets) == 1 + + async def test_origin_filter_selfblock(self, tmp_path: Path) -> None: + """``origin='selfblock'`` excludes blocklist-import bans.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_trend_selfblock.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "blocklist-import", "ip": "10.0.0.1", "timeofban": one_hour_ago}, + {"jail": "sshd", "ip": "10.0.0.2", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "10.0.0.3", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.ban_trend( + "/fake/sock", "24h", origin="selfblock" + ) + + assert sum(b.count for b in result.buckets) == 2 + + async def test_each_bucket_has_iso_timestamp(self, empty_f2b_db_path: str) -> None: + """Every bucket timestamp is a valid ISO 8601 string.""" + from datetime import datetime + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.ban_trend("/fake/sock", "24h") + + for bucket in result.buckets: + # datetime.fromisoformat raises ValueError on invalid input. + parsed = datetime.fromisoformat(bucket.timestamp) + assert parsed.tzinfo is not None # Must be timezone-aware (UTC) + + +# --------------------------------------------------------------------------- +# bans_by_jail +# --------------------------------------------------------------------------- + + +class TestBansByJail: + """Verify ban_service.bans_by_jail() behaviour.""" + + async def test_returns_jails_sorted_descending(self, tmp_path: Path) -> None: + """Jails are returned ordered by count descending.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_by_jail.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "sshd", "ip": "1.1.1.1", "timeofban": one_hour_ago}, + {"jail": "sshd", "ip": "1.1.1.2", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "2.2.2.2", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.jails[0].jail == "sshd" + assert result.jails[0].count == 2 + assert result.jails[1].jail == "nginx" + assert result.jails[1].count == 1 + + async def test_total_equals_sum_of_counts(self, tmp_path: Path) -> None: + """``total`` equals the sum of all per-jail counts.""" + import time as _time + + now = int(_time.time()) + one_hour_ago = now - 3600 + path = str(tmp_path / "test_by_jail_total.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "sshd", "ip": "1.1.1.1", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "2.2.2.2", "timeofban": one_hour_ago}, + {"jail": "nginx", "ip": "3.3.3.3", "timeofban": one_hour_ago}, + ], + ) + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.total == sum(j.count for j in result.jails) + assert result.total == 3 + + async def test_empty_db_returns_empty_list(self, empty_f2b_db_path: str) -> None: + """An empty database returns an empty jails list with total zero.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=empty_f2b_db_path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.jails == [] + assert result.total == 0 + + async def test_excludes_bans_outside_time_window(self, f2b_db_path: str) -> None: + """Bans older than the time window are not counted.""" + # f2b_db_path has one ban from _TWO_DAYS_AGO, which is outside "24h". + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + # Only 2 bans within 24h (both from _ONE_HOUR_AGO). + assert result.total == 2 + + async def test_origin_filter_blocklist(self, mixed_origin_db_path: str) -> None: + """``origin='blocklist'`` returns only the blocklist-import jail.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_jail( + "/fake/sock", "24h", origin="blocklist" + ) + + assert len(result.jails) == 1 + assert result.jails[0].jail == "blocklist-import" + assert result.total == 1 + + async def test_origin_filter_selfblock(self, mixed_origin_db_path: str) -> None: + """``origin='selfblock'`` excludes the blocklist-import jail.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_jail( + "/fake/sock", "24h", origin="selfblock" + ) + + jail_names = {j.jail for j in result.jails} + assert "blocklist-import" not in jail_names + assert result.total == 2 + + async def test_no_origin_filter_returns_all_jails( + self, mixed_origin_db_path: str + ) -> None: + """``origin=None`` returns bans from all jails.""" + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=mixed_origin_db_path), + ): + result = await ban_service.bans_by_jail( + "/fake/sock", "24h", origin=None + ) + + assert result.total == 3 + assert len(result.jails) == 3 + + async def test_diagnostic_warning_when_zero_results_despite_data( + self, tmp_path: Path + ) -> None: + """A warning is logged when the time-range filter excludes all existing rows.""" + import time as _time + + # Insert rows with timeofban far in the past (outside any range window). + far_past = int(_time.time()) - 400 * 24 * 3600 # ~400 days ago + path = str(tmp_path / "test_diag.sqlite3") + await _create_f2b_db( + path, + [ + {"jail": "sshd", "ip": "1.1.1.1", "timeofban": far_past}, + ], + ) + + with ( + patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=path), + ), + patch("app.services.ban_service.log") as mock_log, + ): + result = await ban_service.bans_by_jail("/fake/sock", "24h") + + assert result.total == 0 + assert result.jails == [] + # The diagnostic warning must have been emitted. + warning_calls = [ + c + for c in mock_log.warning.call_args_list + if c[0][0] == "ban_service_bans_by_jail_empty_despite_data" + ] + assert len(warning_calls) == 1 + diff --git a/backend/tests/test_services/test_ban_service_perf.py b/backend/tests/test_services/test_ban_service_perf.py new file mode 100644 index 0000000..bbf007b --- /dev/null +++ b/backend/tests/test_services/test_ban_service_perf.py @@ -0,0 +1,257 @@ +"""Performance benchmark for ban_service with 10 000+ banned IPs. + +These tests assert that both ``list_bans`` and ``bans_by_country`` complete +within 2 seconds wall-clock time when the geo cache is warm and the fail2ban +database contains 10 000 synthetic ban records. + +External network calls are eliminated by pre-populating the in-memory geo +cache before the timed section, so the benchmark measures only the database +query and in-process aggregation overhead. +""" + +from __future__ import annotations + +import random +import time +from typing import Any +from unittest.mock import AsyncMock, patch + +import aiosqlite +import pytest + +from app.services import ban_service, geo_service +from app.services.geo_service import GeoInfo + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BAN_COUNT: int = 10_000 +_WALL_CLOCK_LIMIT: float = 2.0 # seconds + +_NOW: int = int(time.time()) + +#: Country codes to cycle through when generating synthetic geo data. +_COUNTRIES: list[tuple[str, str]] = [ + ("DE", "Germany"), + ("US", "United States"), + ("CN", "China"), + ("RU", "Russia"), + ("FR", "France"), + ("BR", "Brazil"), + ("IN", "India"), + ("GB", "United Kingdom"), +] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _random_ip() -> str: + """Generate a random-looking public IPv4 address string. + + Returns: + Dotted-decimal string with each octet in range 1–254. + """ + return ".".join(str(random.randint(1, 254)) for _ in range(4)) + + +def _random_jail() -> str: + """Pick a jail name from a small pool. + + Returns: + One of ``sshd``, ``nginx``, ``blocklist-import``. + """ + return random.choice(["sshd", "nginx", "blocklist-import"]) + + +async def _seed_f2b_db(path: str, n: int) -> list[str]: + """Create a fail2ban SQLite database with *n* synthetic ban rows. + + Bans are spread uniformly over the last 365 days. + + Args: + path: Filesystem path for the new database. + n: Number of rows to insert. + + Returns: + List of all unique IP address strings inserted. + """ + year_seconds = 365 * 24 * 3600 + ips: list[str] = [_random_ip() for _ in range(n)] + + async with aiosqlite.connect(path) as db: + await db.execute( + "CREATE TABLE jails (" + "name TEXT NOT NULL UNIQUE, " + "enabled INTEGER NOT NULL DEFAULT 1" + ")" + ) + await db.execute( + "CREATE TABLE bans (" + "jail TEXT NOT NULL, " + "ip TEXT, " + "timeofban INTEGER NOT NULL, " + "bantime INTEGER NOT NULL DEFAULT 3600, " + "bancount INTEGER NOT NULL DEFAULT 1, " + "data JSON" + ")" + ) + rows = [ + (_random_jail(), ip, _NOW - random.randint(0, year_seconds), 3600, 1, None) + for ip in ips + ] + await db.executemany( + "INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) " + "VALUES (?, ?, ?, ?, ?, ?)", + rows, + ) + await db.commit() + + return ips + + +@pytest.fixture(scope="module") +def event_loop_policy() -> None: # type: ignore[misc] + """Use the default event loop policy for module-scoped fixtures.""" + return None + + +@pytest.fixture(scope="module") +async def perf_db_path(tmp_path_factory: Any) -> str: # type: ignore[misc] + """Return the path to a fail2ban DB seeded with 10 000 synthetic bans. + + Module-scoped so the database is created only once for all perf tests. + """ + tmp_path = tmp_path_factory.mktemp("perf") + path = str(tmp_path / "fail2ban_perf.sqlite3") + ips = await _seed_f2b_db(path, _BAN_COUNT) + + # Pre-populate the in-memory geo cache so no network calls are made. + geo_service.clear_cache() + country_cycle = _COUNTRIES * (_BAN_COUNT // len(_COUNTRIES) + 1) + for i, ip in enumerate(ips): + cc, cn = country_cycle[i] + geo_service._cache[ip] = GeoInfo( # noqa: SLF001 (test-only direct access) + country_code=cc, + country_name=cn, + asn=f"AS{1000 + i % 500}", + org="Synthetic ISP", + ) + + return path + + +# --------------------------------------------------------------------------- +# Benchmark tests +# --------------------------------------------------------------------------- + + +class TestBanServicePerformance: + """Wall-clock performance assertions for the ban service.""" + + async def test_list_bans_returns_within_time_limit( + self, perf_db_path: str + ) -> None: + """``list_bans`` with 10 000 bans completes in under 2 seconds.""" + + async def noop_enricher(ip: str) -> GeoInfo | None: + return geo_service._cache.get(ip) # noqa: SLF001 + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=perf_db_path), + ): + start = time.perf_counter() + result = await ban_service.list_bans( + "/fake/sock", + "365d", + page=1, + page_size=100, + geo_enricher=noop_enricher, + ) + elapsed = time.perf_counter() - start + + assert result.total == _BAN_COUNT, ( + f"Expected {_BAN_COUNT} total bans, got {result.total}" + ) + assert len(result.items) == 100 + assert elapsed < _WALL_CLOCK_LIMIT, ( + f"list_bans took {elapsed:.2f}s — must be < {_WALL_CLOCK_LIMIT}s" + ) + + async def test_bans_by_country_returns_within_time_limit( + self, perf_db_path: str + ) -> None: + """``bans_by_country`` with 10 000 bans completes in under 2 seconds.""" + + async def noop_enricher(ip: str) -> GeoInfo | None: + return geo_service._cache.get(ip) # noqa: SLF001 + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=perf_db_path), + ): + start = time.perf_counter() + result = await ban_service.bans_by_country( + "/fake/sock", + "365d", + geo_enricher=noop_enricher, + ) + elapsed = time.perf_counter() - start + + assert result.total == _BAN_COUNT + assert len(result.countries) > 0 # At least one country resolved + assert elapsed < _WALL_CLOCK_LIMIT, ( + f"bans_by_country took {elapsed:.2f}s — must be < {_WALL_CLOCK_LIMIT}s" + ) + + async def test_list_bans_country_data_populated( + self, perf_db_path: str + ) -> None: + """All returned items have geo data from the warm cache.""" + + async def noop_enricher(ip: str) -> GeoInfo | None: + return geo_service._cache.get(ip) # noqa: SLF001 + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=perf_db_path), + ): + result = await ban_service.list_bans( + "/fake/sock", + "365d", + page=1, + page_size=100, + geo_enricher=noop_enricher, + ) + + # Every item should have a country because the cache is warm. + missing = [i for i in result.items if i.country_code is None] + assert missing == [], f"{len(missing)} items missing country_code" + + async def test_bans_by_country_aggregation_correct( + self, perf_db_path: str + ) -> None: + """Country aggregation sums across all 10 000 bans.""" + + async def noop_enricher(ip: str) -> GeoInfo | None: + return geo_service._cache.get(ip) # noqa: SLF001 + + with patch( + "app.services.ban_service._get_fail2ban_db_path", + new=AsyncMock(return_value=perf_db_path), + ): + result = await ban_service.bans_by_country( + "/fake/sock", + "365d", + geo_enricher=noop_enricher, + ) + + total_in_countries = sum(result.countries.values()) + # Total bans in country map should equal total bans (all IPs are cached). + assert total_in_countries == _BAN_COUNT, ( + f"Country sum {total_in_countries} != total {_BAN_COUNT}" + ) diff --git a/backend/tests/test_services/test_blocklist_service.py b/backend/tests/test_services/test_blocklist_service.py new file mode 100644 index 0000000..579b4c1 --- /dev/null +++ b/backend/tests/test_services/test_blocklist_service.py @@ -0,0 +1,339 @@ +"""Tests for blocklist_service — source CRUD, preview, import, schedule.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import aiosqlite +import pytest + +from app.db import init_db +from app.models.blocklist import BlocklistSource, ScheduleConfig, ScheduleFrequency +from app.services import blocklist_service + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] + """Provide an initialised aiosqlite connection.""" + conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "bl_svc.db")) + conn.row_factory = aiosqlite.Row + await init_db(conn) + yield conn + await conn.close() + + +def _make_session(text: str, status: int = 200) -> MagicMock: + """Build a mock aiohttp session that returns *text* for GET requests.""" + mock_resp = AsyncMock() + mock_resp.status = status + mock_resp.text = AsyncMock(return_value=text) + mock_resp.content = AsyncMock() + mock_resp.content.read = AsyncMock(return_value=text.encode()) + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + session = MagicMock() + session.get = MagicMock(return_value=mock_ctx) + return session + + +# --------------------------------------------------------------------------- +# Source CRUD +# --------------------------------------------------------------------------- + + +class TestSourceCRUD: + async def test_create_and_get(self, db: aiosqlite.Connection) -> None: + """create_source persists and get_source retrieves a source.""" + source = await blocklist_service.create_source(db, "Test", "https://t.test/") + assert isinstance(source, BlocklistSource) + assert source.name == "Test" + assert source.enabled is True + + fetched = await blocklist_service.get_source(db, source.id) + assert fetched is not None + assert fetched.id == source.id + + async def test_get_missing_returns_none(self, db: aiosqlite.Connection) -> None: + """get_source returns None for a non-existent id.""" + result = await blocklist_service.get_source(db, 9999) + assert result is None + + async def test_list_sources_empty(self, db: aiosqlite.Connection) -> None: + """list_sources returns empty list when no sources exist.""" + sources = await blocklist_service.list_sources(db) + assert sources == [] + + async def test_list_sources_returns_all(self, db: aiosqlite.Connection) -> None: + """list_sources returns all created sources.""" + await blocklist_service.create_source(db, "A", "https://a.test/") + await blocklist_service.create_source(db, "B", "https://b.test/") + sources = await blocklist_service.list_sources(db) + assert len(sources) == 2 + + async def test_update_source_fields(self, db: aiosqlite.Connection) -> None: + """update_source modifies specified fields.""" + source = await blocklist_service.create_source(db, "Original", "https://orig.test/") + updated = await blocklist_service.update_source(db, source.id, name="Updated", enabled=False) + assert updated is not None + assert updated.name == "Updated" + assert updated.enabled is False + + async def test_update_source_missing_returns_none(self, db: aiosqlite.Connection) -> None: + """update_source returns None for a non-existent id.""" + result = await blocklist_service.update_source(db, 9999, name="Ghost") + assert result is None + + async def test_delete_source(self, db: aiosqlite.Connection) -> None: + """delete_source removes a source and returns True.""" + source = await blocklist_service.create_source(db, "Del", "https://del.test/") + deleted = await blocklist_service.delete_source(db, source.id) + assert deleted is True + assert await blocklist_service.get_source(db, source.id) is None + + async def test_delete_source_missing_returns_false(self, db: aiosqlite.Connection) -> None: + """delete_source returns False for a non-existent id.""" + result = await blocklist_service.delete_source(db, 9999) + assert result is False + + +# --------------------------------------------------------------------------- +# Preview +# --------------------------------------------------------------------------- + + +class TestPreview: + async def test_preview_valid_ips(self) -> None: + """preview_source returns valid IPs from the downloaded content.""" + content = "1.2.3.4\n5.6.7.8\n# comment\ninvalid\n9.0.0.1\n" + session = _make_session(content) + result = await blocklist_service.preview_source("https://test.test/ips.txt", session) + assert result.valid_count == 3 + assert result.skipped_count == 1 # "invalid" + assert "1.2.3.4" in result.entries + + async def test_preview_http_error_raises(self) -> None: + """preview_source raises ValueError when the server returns non-200.""" + session = _make_session("", status=404) + with pytest.raises(ValueError, match="HTTP 404"): + await blocklist_service.preview_source("https://bad.test/", session) + + async def test_preview_limits_entries(self) -> None: + """preview_source caps entries to sample_lines.""" + ips = "\n".join(f"1.2.3.{i}" for i in range(50)) + session = _make_session(ips) + result = await blocklist_service.preview_source( + "https://test.test/", session, sample_lines=10 + ) + assert len(result.entries) <= 10 + assert result.valid_count == 50 + + +# --------------------------------------------------------------------------- +# Import +# --------------------------------------------------------------------------- + + +class TestImport: + async def test_import_source_bans_valid_ips(self, db: aiosqlite.Connection) -> None: + """import_source calls ban_ip for every valid IP in the blocklist.""" + content = "1.2.3.4\n5.6.7.8\n# skip me\n" + session = _make_session(content) + + source = await blocklist_service.create_source(db, "Import Test", "https://t.test/") + + with patch( + "app.services.jail_service.ban_ip", new_callable=AsyncMock + ) as mock_ban: + result = await blocklist_service.import_source( + source, session, "/tmp/fake.sock", db + ) + + assert result.ips_imported == 2 + assert result.ips_skipped == 0 + assert result.error is None + assert mock_ban.call_count == 2 + + async def test_import_source_skips_cidrs(self, db: aiosqlite.Connection) -> None: + """import_source skips CIDR ranges (fail2ban expects individual IPs).""" + content = "1.2.3.4\n10.0.0.0/24\n" + session = _make_session(content) + source = await blocklist_service.create_source(db, "CIDR Test", "https://c.test/") + + with patch("app.services.jail_service.ban_ip", new_callable=AsyncMock): + result = await blocklist_service.import_source( + source, session, "/tmp/fake.sock", db + ) + + assert result.ips_imported == 1 + assert result.ips_skipped == 1 + + async def test_import_source_records_download_error(self, db: aiosqlite.Connection) -> None: + """import_source records an error and returns 0 imported on HTTP failure.""" + session = _make_session("", status=503) + source = await blocklist_service.create_source(db, "Err Source", "https://err.test/") + + result = await blocklist_service.import_source( + source, session, "/tmp/fake.sock", db + ) + + assert result.ips_imported == 0 + assert result.error is not None + + async def test_import_source_aborts_on_jail_not_found(self, db: aiosqlite.Connection) -> None: + """import_source aborts immediately and records an error when the target jail + does not exist in fail2ban instead of silently skipping every IP.""" + from app.services.jail_service import JailNotFoundError + + content = "\n".join(f"1.2.3.{i}" for i in range(100)) + session = _make_session(content) + source = await blocklist_service.create_source(db, "Missing Jail", "https://mj.test/") + + call_count = 0 + + async def _raise_jail_not_found(socket_path: str, jail: str, ip: str) -> None: + nonlocal call_count + call_count += 1 + raise JailNotFoundError(jail) + + with patch("app.services.jail_service.ban_ip", side_effect=_raise_jail_not_found): + result = await blocklist_service.import_source( + source, session, "/tmp/fake.sock", db + ) + + # Must abort after the first JailNotFoundError — only one ban attempt. + assert call_count == 1 + assert result.ips_imported == 0 + assert result.error is not None + assert "not found" in result.error.lower() or "blocklist-import" in result.error + + async def test_import_all_runs_all_enabled(self, db: aiosqlite.Connection) -> None: + """import_all aggregates results across all enabled sources.""" + await blocklist_service.create_source(db, "S1", "https://s1.test/") + s2 = await blocklist_service.create_source(db, "S2", "https://s2.test/", enabled=False) + _ = s2 # noqa: F841 + + content = "1.2.3.4\n5.6.7.8\n" + session = _make_session(content) + + with patch( + "app.services.jail_service.ban_ip", new_callable=AsyncMock + ): + result = await blocklist_service.import_all(db, session, "/tmp/fake.sock") + + # Only S1 is enabled, S2 is disabled. + assert len(result.results) == 1 + assert result.results[0].source_url == "https://s1.test/" + + +# --------------------------------------------------------------------------- +# Schedule +# --------------------------------------------------------------------------- + + +class TestSchedule: + async def test_get_schedule_default(self, db: aiosqlite.Connection) -> None: + """get_schedule returns the default daily-03:00 config when nothing is saved.""" + config = await blocklist_service.get_schedule(db) + assert config.frequency == ScheduleFrequency.daily + assert config.hour == 3 + + async def test_set_and_get_round_trip(self, db: aiosqlite.Connection) -> None: + """set_schedule persists config retrievable by get_schedule.""" + cfg = ScheduleConfig(frequency=ScheduleFrequency.hourly, interval_hours=6, hour=0, minute=0, day_of_week=0) + await blocklist_service.set_schedule(db, cfg) + loaded = await blocklist_service.get_schedule(db) + assert loaded.frequency == ScheduleFrequency.hourly + assert loaded.interval_hours == 6 + + async def test_get_schedule_info_no_log(self, db: aiosqlite.Connection) -> None: + """get_schedule_info returns None for last_run_at and last_run_errors when no log exists.""" + info = await blocklist_service.get_schedule_info(db, None) + assert info.last_run_at is None + assert info.next_run_at is None + assert info.last_run_errors is None + + async def test_get_schedule_info_no_errors_when_clean( + self, db: aiosqlite.Connection + ) -> None: + """get_schedule_info returns last_run_errors=False when the last run had no errors.""" + from app.repositories import import_log_repo + + await import_log_repo.add_log( + db, + source_id=None, + source_url="https://example.test/ips.txt", + ips_imported=10, + ips_skipped=0, + errors=None, + ) + info = await blocklist_service.get_schedule_info(db, None) + assert info.last_run_errors is False + + async def test_get_schedule_info_errors_flag_when_failed( + self, db: aiosqlite.Connection + ) -> None: + """get_schedule_info returns last_run_errors=True when the last run had errors.""" + from app.repositories import import_log_repo + + await import_log_repo.add_log( + db, + source_id=None, + source_url="https://example.test/ips.txt", + ips_imported=0, + ips_skipped=0, + errors="Connection timeout", + ) + info = await blocklist_service.get_schedule_info(db, None) + assert info.last_run_errors is True + + +# --------------------------------------------------------------------------- +# Geo prewarm cache filtering +# --------------------------------------------------------------------------- + + +class TestGeoPrewarmCacheFilter: + async def test_import_source_skips_cached_ips_for_geo_prewarm( + self, db: aiosqlite.Connection + ) -> None: + """import_source only sends uncached IPs to geo_service.lookup_batch.""" + content = "1.2.3.4\n5.6.7.8\n9.10.11.12\n" + session = _make_session(content) + source = await blocklist_service.create_source( + db, "Geo Filter", "https://gf.test/" + ) + + # Pretend 1.2.3.4 is already cached. + def _mock_is_cached(ip: str) -> bool: + return ip == "1.2.3.4" + + with ( + patch("app.services.jail_service.ban_ip", new_callable=AsyncMock), + patch( + "app.services.geo_service.is_cached", + side_effect=_mock_is_cached, + ), + patch( + "app.services.geo_service.lookup_batch", + new_callable=AsyncMock, + return_value={}, + ) as mock_batch, + ): + result = await blocklist_service.import_source( + source, session, "/tmp/fake.sock", db + ) + + assert result.ips_imported == 3 + # lookup_batch should receive only the 2 uncached IPs. + mock_batch.assert_called_once() + call_ips = mock_batch.call_args[0][0] + assert "1.2.3.4" not in call_ips + assert set(call_ips) == {"5.6.7.8", "9.10.11.12"} diff --git a/backend/tests/test_services/test_conffile_parser.py b/backend/tests/test_services/test_conffile_parser.py new file mode 100644 index 0000000..e69e4f0 --- /dev/null +++ b/backend/tests/test_services/test_conffile_parser.py @@ -0,0 +1,624 @@ +"""Tests for conffile_parser — fail2ban INI file parser and serializer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.services.conffile_parser import ( + merge_action_update, + merge_filter_update, + parse_action_file, + parse_filter_file, + serialize_action_config, + serialize_filter_config, +) + +# Path to the bundled fail2ban reference configs shipped with this project. +_REF_DIR = Path(__file__).parent.parent.parent.parent / "fail2ban-master" / "config" +_FILTER_DIR = _REF_DIR / "filter.d" +_ACTION_DIR = _REF_DIR / "action.d" + + +# --------------------------------------------------------------------------- +# Filter — parse helpers +# --------------------------------------------------------------------------- + + +MINIMAL_FILTER = """\ +[Definition] +failregex = ^ bad request$ +ignoreregex = +""" + +FULL_FILTER = """\ +[INCLUDES] +before = common.conf + +[DEFAULT] +_daemon = sshd +__prefix = (?:sshd )? + +[Definition] +prefregex = ^%(__prefix)s%(__pref)s +failregex = ^Authentication failure for .* from via + ^User not known .* from +ignoreregex = ^Authorised key from + +maxlines = 10 +datepattern = %%Y-%%m-%%d %%H:%%M:%%S +journalmatch = _SYSTEMD_UNIT=sshd.service +""" + + +class TestParseFilterFile: + """Unit tests for parse_filter_file.""" + + def test_minimal_filter(self) -> None: + cfg = parse_filter_file(MINIMAL_FILTER, name="test", filename="test.conf") + assert cfg.name == "test" + assert cfg.filename == "test.conf" + assert cfg.failregex == ["^ bad request$"] + assert cfg.ignoreregex == [] + assert cfg.before is None + assert cfg.after is None + + def test_full_filter_includes(self) -> None: + cfg = parse_filter_file(FULL_FILTER, name="sshd") + assert cfg.before == "common.conf" + assert cfg.after is None + + def test_full_filter_defaults(self) -> None: + cfg = parse_filter_file(FULL_FILTER, name="sshd") + assert "_daemon" in cfg.variables + assert cfg.variables["_daemon"] == "sshd" + + def test_full_filter_failregex_multiline(self) -> None: + cfg = parse_filter_file(FULL_FILTER, name="sshd") + assert len(cfg.failregex) == 2 + assert "Authentication failure" in cfg.failregex[0] + assert "User not known" in cfg.failregex[1] + + def test_full_filter_ignoreregex(self) -> None: + cfg = parse_filter_file(FULL_FILTER, name="sshd") + assert len(cfg.ignoreregex) == 1 + assert "Authorised key" in cfg.ignoreregex[0] + + def test_full_filter_optional_fields(self) -> None: + cfg = parse_filter_file(FULL_FILTER, name="sshd") + assert cfg.maxlines == 10 + assert cfg.datepattern is not None + assert cfg.journalmatch == "_SYSTEMD_UNIT=sshd.service" + + def test_empty_failregex(self) -> None: + content = "[Definition]\nfailregex =\nignoreregex =\n" + cfg = parse_filter_file(content, name="empty") + assert cfg.failregex == [] + assert cfg.ignoreregex == [] + + def test_comment_lines_stripped_from_failregex(self) -> None: + content = ( + "[Definition]\n" + "failregex = ^ good$\n" + " # this is a comment\n" + " ^ also good$\n" + ) + cfg = parse_filter_file(content, name="t") + assert len(cfg.failregex) == 2 + assert cfg.failregex[1] == "^ also good$" + + def test_malformed_content_does_not_raise(self) -> None: + """Parser should not crash on malformed content; return partial result.""" + cfg = parse_filter_file("not an ini file at all!!!", name="bad") + assert cfg.failregex == [] + + def test_no_definition_section(self) -> None: + cfg = parse_filter_file("[INCLUDES]\nbefore = common.conf\n", name="x") + assert cfg.before == "common.conf" + assert cfg.failregex == [] + + +# --------------------------------------------------------------------------- +# Filter — round-trip (parse → serialize → parse) +# --------------------------------------------------------------------------- + + +class TestFilterRoundTrip: + """Serialize then re-parse and verify the result matches.""" + + def test_round_trip_minimal(self) -> None: + original = parse_filter_file(MINIMAL_FILTER, name="t") + serialized = serialize_filter_config(original) + restored = parse_filter_file(serialized, name="t") + assert restored.failregex == original.failregex + assert restored.ignoreregex == original.ignoreregex + + def test_round_trip_full(self) -> None: + original = parse_filter_file(FULL_FILTER, name="sshd") + serialized = serialize_filter_config(original) + restored = parse_filter_file(serialized, name="sshd") + assert restored.before == original.before + assert restored.failregex == original.failregex + assert restored.ignoreregex == original.ignoreregex + assert restored.maxlines == original.maxlines + assert restored.datepattern == original.datepattern + assert restored.journalmatch == original.journalmatch + + +# --------------------------------------------------------------------------- +# Filter — reference file (sshd.conf shipped with fail2ban-master) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not (_FILTER_DIR / "sshd.conf").exists(), + reason="fail2ban-master reference configs not present", +) +class TestParseRealSshdFilter: + """Parse the real sshd.conf that ships with fail2ban-master.""" + + def test_parses_without_exception(self) -> None: + content = (_FILTER_DIR / "sshd.conf").read_text() + cfg = parse_filter_file(content, name="sshd", filename="sshd.conf") + assert cfg.name == "sshd" + + def test_has_failregex_patterns(self) -> None: + content = (_FILTER_DIR / "sshd.conf").read_text() + cfg = parse_filter_file(content, name="sshd") + assert len(cfg.failregex) > 0 + + def test_has_includes(self) -> None: + content = (_FILTER_DIR / "sshd.conf").read_text() + cfg = parse_filter_file(content, name="sshd") + assert cfg.before is not None + + def test_round_trip_preserves_failregex_count(self) -> None: + content = (_FILTER_DIR / "sshd.conf").read_text() + cfg = parse_filter_file(content, name="sshd") + serialized = serialize_filter_config(cfg) + restored = parse_filter_file(serialized, name="sshd") + assert len(restored.failregex) == len(cfg.failregex) + + +# --------------------------------------------------------------------------- +# FilterConfigUpdate — merge +# --------------------------------------------------------------------------- + + +class TestMergeFilterUpdate: + """Tests for merge_filter_update.""" + + def test_merge_only_failregex(self) -> None: + from app.models.config import FilterConfigUpdate + + base = parse_filter_file(FULL_FILTER, name="sshd") + update = FilterConfigUpdate(failregex=["^new pattern $"]) + merged = merge_filter_update(base, update) + assert merged.failregex == ["^new pattern $"] + # Everything else unchanged: + assert merged.before == base.before + assert merged.maxlines == base.maxlines + + def test_merge_none_fields_unchanged(self) -> None: + from app.models.config import FilterConfigUpdate + + base = parse_filter_file(FULL_FILTER, name="sshd") + update = FilterConfigUpdate() # All None + merged = merge_filter_update(base, update) + assert merged.failregex == base.failregex + assert merged.ignoreregex == base.ignoreregex + + def test_merge_clear_ignoreregex(self) -> None: + from app.models.config import FilterConfigUpdate + + base = parse_filter_file(FULL_FILTER, name="sshd") + update = FilterConfigUpdate(ignoreregex=[]) + merged = merge_filter_update(base, update) + assert merged.ignoreregex == [] + + def test_merge_update_variables(self) -> None: + from app.models.config import FilterConfigUpdate + + base = parse_filter_file(FULL_FILTER, name="sshd") + update = FilterConfigUpdate(variables={"_daemon": "mynewdaemon"}) + merged = merge_filter_update(base, update) + assert merged.variables["_daemon"] == "mynewdaemon" + + +# --------------------------------------------------------------------------- +# Action — parse helpers +# --------------------------------------------------------------------------- + + +MINIMAL_ACTION = """\ +[Definition] +actionban = iptables -I INPUT -s -j DROP +actionunban = iptables -D INPUT -s -j DROP +""" + +FULL_ACTION = """\ +[INCLUDES] +before = iptables-common.conf + +[Definition] +actionstart = { iptables -N f2b- + iptables -A INPUT -p -j f2b- } +actionstop = iptables -D INPUT -p -j f2b- + iptables -F f2b- + iptables -X f2b- +actioncheck = iptables -n -L INPUT | grep -q f2b-[ \\t] +actionban = iptables -I f2b- 1 -s -j +actionunban = iptables -D f2b- -s -j +actionflush = iptables -F f2b- +name = default +protocol = tcp +chain = INPUT +blocktype = REJECT + +[Init] +blocktype = REJECT --reject-with icmp-port-unreachable +name = default +""" + + +class TestParseActionFile: + """Unit tests for parse_action_file.""" + + def test_minimal_action(self) -> None: + cfg = parse_action_file(MINIMAL_ACTION, name="test", filename="test.conf") + assert cfg.name == "test" + assert cfg.actionban is not None + assert "" in cfg.actionban + assert cfg.actionunban is not None + + def test_full_action_includes(self) -> None: + cfg = parse_action_file(FULL_ACTION, name="iptables") + assert cfg.before == "iptables-common.conf" + + def test_full_action_lifecycle_keys(self) -> None: + cfg = parse_action_file(FULL_ACTION, name="iptables") + assert cfg.actionstart is not None + assert cfg.actionstop is not None + assert cfg.actioncheck is not None + assert cfg.actionban is not None + assert cfg.actionunban is not None + assert cfg.actionflush is not None + + def test_full_action_definition_vars(self) -> None: + cfg = parse_action_file(FULL_ACTION, name="iptables") + # 'name', 'protocol', 'chain', 'blocktype' are definition vars + assert "protocol" in cfg.definition_vars + assert "chain" in cfg.definition_vars + + def test_full_action_init_vars(self) -> None: + cfg = parse_action_file(FULL_ACTION, name="iptables") + assert "blocktype" in cfg.init_vars + assert "REJECT" in cfg.init_vars["blocktype"] + + def test_empty_action(self) -> None: + cfg = parse_action_file("[Definition]\n", name="empty") + assert cfg.actionban is None + assert cfg.definition_vars == {} + assert cfg.init_vars == {} + + def test_malformed_does_not_raise(self) -> None: + cfg = parse_action_file("@@@@not valid ini@@@@", name="bad") + assert cfg.actionban is None + + +# --------------------------------------------------------------------------- +# Action — round-trip +# --------------------------------------------------------------------------- + + +class TestActionRoundTrip: + """Serialize then re-parse and verify key fields are preserved.""" + + def test_round_trip_minimal(self) -> None: + original = parse_action_file(MINIMAL_ACTION, name="t") + serialized = serialize_action_config(original) + restored = parse_action_file(serialized, name="t") + assert restored.actionban == original.actionban + assert restored.actionunban == original.actionunban + + def test_round_trip_full(self) -> None: + original = parse_action_file(FULL_ACTION, name="iptables") + serialized = serialize_action_config(original) + restored = parse_action_file(serialized, name="iptables") + assert restored.actionban == original.actionban + assert restored.actionflush == original.actionflush + assert restored.init_vars.get("blocktype") == original.init_vars.get("blocktype") + + +# --------------------------------------------------------------------------- +# Action — reference file +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + not (_ACTION_DIR / "iptables.conf").exists(), + reason="fail2ban-master reference configs not present", +) +class TestParseRealIptablesAction: + """Parse the real iptables.conf that ships with fail2ban-master.""" + + def test_parses_without_exception(self) -> None: + content = (_ACTION_DIR / "iptables.conf").read_text() + cfg = parse_action_file(content, name="iptables", filename="iptables.conf") + assert cfg.name == "iptables" + + def test_has_actionban(self) -> None: + content = (_ACTION_DIR / "iptables.conf").read_text() + cfg = parse_action_file(content, name="iptables") + assert cfg.actionban is not None + + def test_round_trip_preserves_actionban(self) -> None: + content = (_ACTION_DIR / "iptables.conf").read_text() + cfg = parse_action_file(content, name="iptables") + serialized = serialize_action_config(cfg) + restored = parse_action_file(serialized, name="iptables") + assert restored.actionban == cfg.actionban + + +# --------------------------------------------------------------------------- +# ActionConfigUpdate — merge +# --------------------------------------------------------------------------- + + +class TestMergeActionUpdate: + """Tests for merge_action_update.""" + + def test_merge_only_actionban(self) -> None: + from app.models.config import ActionConfigUpdate + + base = parse_action_file(FULL_ACTION, name="iptables") + update = ActionConfigUpdate(actionban="iptables -I INPUT -s -j DROP") + merged = merge_action_update(base, update) + assert merged.actionban == "iptables -I INPUT -s -j DROP" + assert merged.actionunban == base.actionunban + + def test_merge_none_fields_unchanged(self) -> None: + from app.models.config import ActionConfigUpdate + + base = parse_action_file(FULL_ACTION, name="iptables") + update = ActionConfigUpdate() + merged = merge_action_update(base, update) + assert merged.actionban == base.actionban + assert merged.init_vars == base.init_vars + + def test_merge_update_init_vars(self) -> None: + from app.models.config import ActionConfigUpdate + + base = parse_action_file(FULL_ACTION, name="iptables") + update = ActionConfigUpdate(init_vars={"blocktype": "DROP"}) + merged = merge_action_update(base, update) + assert merged.init_vars["blocktype"] == "DROP" + + +# --------------------------------------------------------------------------- +# Jail file test fixtures +# --------------------------------------------------------------------------- + +MINIMAL_JAIL = """\ +[sshd] +enabled = false +port = ssh +filter = sshd +logpath = /var/log/auth.log +""" + +FULL_JAIL = """\ +# fail2ban jail configuration +[sshd] +enabled = true +port = ssh,22 +filter = sshd +backend = polling +maxretry = 3 +findtime = 600 +bantime = 3600 +logpath = /var/log/auth.log + /var/log/syslog + +[nginx-botsearch] +enabled = false +port = http,https +filter = nginx-botsearch +logpath = /var/log/nginx/error.log +maxretry = 2 +action = iptables-multiport[name=botsearch, port="http,https"] + sendmail-whois +""" + +JAIL_WITH_EXTRA = """\ +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +custom_key = custom_value +another_key = 42 +""" + + +# --------------------------------------------------------------------------- +# parse_jail_file +# --------------------------------------------------------------------------- + + +class TestParseJailFile: + """Unit tests for parse_jail_file.""" + + def test_minimal_parses_correctly(self) -> None: + from app.services.conffile_parser import parse_jail_file + + cfg = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf") + assert cfg.filename == "sshd.conf" + assert "sshd" in cfg.jails + jail = cfg.jails["sshd"] + assert jail.enabled is False + assert jail.port == "ssh" + assert jail.filter == "sshd" + assert jail.logpath == ["/var/log/auth.log"] + + def test_full_parses_multiple_jails(self) -> None: + from app.services.conffile_parser import parse_jail_file + + cfg = parse_jail_file(FULL_JAIL) + assert len(cfg.jails) == 2 + assert "sshd" in cfg.jails + assert "nginx-botsearch" in cfg.jails + + def test_full_jail_numeric_fields(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(FULL_JAIL).jails["sshd"] + assert jail.maxretry == 3 + assert jail.findtime == 600 + assert jail.bantime == 3600 + + def test_full_jail_multiline_logpath(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(FULL_JAIL).jails["sshd"] + assert len(jail.logpath) == 2 + assert "/var/log/auth.log" in jail.logpath + assert "/var/log/syslog" in jail.logpath + + def test_full_jail_multiline_action(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"] + assert len(jail.action) == 2 + assert "sendmail-whois" in jail.action + + def test_enabled_true(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(FULL_JAIL).jails["sshd"] + assert jail.enabled is True + + def test_enabled_false(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"] + assert jail.enabled is False + + def test_extra_keys_captured(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"] + assert jail.extra["custom_key"] == "custom_value" + assert jail.extra["another_key"] == "42" + + def test_extra_keys_not_in_named_fields(self) -> None: + from app.services.conffile_parser import parse_jail_file + + jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"] + assert "enabled" not in jail.extra + assert "logpath" not in jail.extra + + def test_empty_file_yields_no_jails(self) -> None: + from app.services.conffile_parser import parse_jail_file + + cfg = parse_jail_file("") + assert cfg.jails == {} + + def test_invalid_ini_does_not_raise(self) -> None: + from app.services.conffile_parser import parse_jail_file + + # Should not raise; just parse what it can. + cfg = parse_jail_file("@@@ not valid ini @@@", filename="bad.conf") + assert isinstance(cfg.jails, dict) + + def test_default_section_ignored(self) -> None: + from app.services.conffile_parser import parse_jail_file + + content = "[DEFAULT]\nignoreip = 127.0.0.1\n\n[sshd]\nenabled = true\n" + cfg = parse_jail_file(content) + assert "DEFAULT" not in cfg.jails + assert "sshd" in cfg.jails + + +# --------------------------------------------------------------------------- +# Jail file round-trip +# --------------------------------------------------------------------------- + + +class TestJailFileRoundTrip: + """Tests that parse → serialize → parse preserves values.""" + + def test_minimal_round_trip(self) -> None: + from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config + + original = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf") + serialized = serialize_jail_file_config(original) + restored = parse_jail_file(serialized, filename="sshd.conf") + assert restored.jails["sshd"].enabled == original.jails["sshd"].enabled + assert restored.jails["sshd"].port == original.jails["sshd"].port + assert restored.jails["sshd"].logpath == original.jails["sshd"].logpath + + def test_full_round_trip(self) -> None: + from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config + + original = parse_jail_file(FULL_JAIL) + serialized = serialize_jail_file_config(original) + restored = parse_jail_file(serialized) + for name, jail in original.jails.items(): + restored_jail = restored.jails[name] + assert restored_jail.enabled == jail.enabled + assert restored_jail.maxretry == jail.maxretry + assert sorted(restored_jail.logpath) == sorted(jail.logpath) + assert sorted(restored_jail.action) == sorted(jail.action) + + def test_extra_keys_round_trip(self) -> None: + from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config + + original = parse_jail_file(JAIL_WITH_EXTRA) + serialized = serialize_jail_file_config(original) + restored = parse_jail_file(serialized) + assert restored.jails["sshd"].extra["custom_key"] == "custom_value" + + +# --------------------------------------------------------------------------- +# merge_jail_file_update +# --------------------------------------------------------------------------- + + +class TestMergeJailFileUpdate: + """Unit tests for merge_jail_file_update.""" + + def test_none_update_returns_original(self) -> None: + from app.models.config import JailFileConfigUpdate + from app.services.conffile_parser import merge_jail_file_update, parse_jail_file + + cfg = parse_jail_file(FULL_JAIL) + update = JailFileConfigUpdate() + merged = merge_jail_file_update(cfg, update) + assert merged.jails == cfg.jails + + def test_update_replaces_jail(self) -> None: + from app.models.config import JailFileConfigUpdate, JailSectionConfig + from app.services.conffile_parser import merge_jail_file_update, parse_jail_file + + cfg = parse_jail_file(FULL_JAIL) + new_sshd = JailSectionConfig(enabled=False, port="2222") + update = JailFileConfigUpdate(jails={"sshd": new_sshd}) + merged = merge_jail_file_update(cfg, update) + assert merged.jails["sshd"].enabled is False + assert merged.jails["sshd"].port == "2222" + # Other jails unchanged + assert "nginx-botsearch" in merged.jails + + def test_update_adds_new_jail(self) -> None: + from app.models.config import JailFileConfigUpdate, JailSectionConfig + from app.services.conffile_parser import merge_jail_file_update, parse_jail_file + + cfg = parse_jail_file(MINIMAL_JAIL) + new_jail = JailSectionConfig(enabled=True, port="443") + update = JailFileConfigUpdate(jails={"https": new_jail}) + merged = merge_jail_file_update(cfg, update) + assert "sshd" in merged.jails + assert "https" in merged.jails + assert merged.jails["https"].port == "443" diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py new file mode 100644 index 0000000..713b389 --- /dev/null +++ b/backend/tests/test_services/test_config_file_service.py @@ -0,0 +1,3113 @@ +"""Tests for config_file_service — fail2ban jail config parser and activator.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from app.services.config_file_service import ( + JailAlreadyActiveError, + JailAlreadyInactiveError, + JailNameError, + JailNotFoundInConfigError, + _build_inactive_jail, + _ordered_config_files, + _parse_jails_sync, + _resolve_filter, + _safe_jail_name, + _write_local_override_sync, + activate_jail, + deactivate_jail, + list_inactive_jails, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write(path: Path, content: str) -> None: + """Write text to *path*, creating parent directories if needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# _safe_jail_name +# --------------------------------------------------------------------------- + + +class TestSafeJailName: + def test_valid_simple(self) -> None: + assert _safe_jail_name("sshd") == "sshd" + + def test_valid_with_hyphen(self) -> None: + assert _safe_jail_name("apache-auth") == "apache-auth" + + def test_valid_with_dot(self) -> None: + assert _safe_jail_name("nginx.http") == "nginx.http" + + def test_valid_with_underscore(self) -> None: + assert _safe_jail_name("my_jail") == "my_jail" + + def test_invalid_path_traversal(self) -> None: + with pytest.raises(JailNameError): + _safe_jail_name("../evil") + + def test_invalid_slash(self) -> None: + with pytest.raises(JailNameError): + _safe_jail_name("a/b") + + def test_invalid_starts_with_dash(self) -> None: + with pytest.raises(JailNameError): + _safe_jail_name("-bad") + + def test_invalid_empty(self) -> None: + with pytest.raises(JailNameError): + _safe_jail_name("") + + +# --------------------------------------------------------------------------- +# _resolve_filter +# --------------------------------------------------------------------------- + + +class TestResolveFilter: + def test_name_substitution(self) -> None: + result = _resolve_filter("%(__name__)s", "sshd", "normal") + assert result == "sshd" + + def test_mode_substitution(self) -> None: + result = _resolve_filter("%(__name__)s[mode=%(mode)s]", "sshd", "aggressive") + assert result == "sshd[mode=aggressive]" + + def test_no_substitution_needed(self) -> None: + result = _resolve_filter("my-filter", "sshd", "normal") + assert result == "my-filter" + + def test_empty_raw(self) -> None: + result = _resolve_filter("", "sshd", "normal") + assert result == "" + + +# --------------------------------------------------------------------------- +# _ordered_config_files +# --------------------------------------------------------------------------- + + +class TestOrderedConfigFiles: + def test_empty_dir(self, tmp_path: Path) -> None: + result = _ordered_config_files(tmp_path) + assert result == [] + + def test_jail_conf_only(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", "[sshd]\nenabled=true\n") + result = _ordered_config_files(tmp_path) + assert result == [tmp_path / "jail.conf"] + + def test_full_merge_order(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", "[DEFAULT]\n") + _write(tmp_path / "jail.local", "[DEFAULT]\n") + _write(tmp_path / "jail.d" / "custom.conf", "[sshd]\n") + _write(tmp_path / "jail.d" / "custom.local", "[sshd]\n") + + result = _ordered_config_files(tmp_path) + + assert result[0] == tmp_path / "jail.conf" + assert result[1] == tmp_path / "jail.local" + assert result[2] == tmp_path / "jail.d" / "custom.conf" + assert result[3] == tmp_path / "jail.d" / "custom.local" + + def test_jail_d_sorted_alphabetically(self, tmp_path: Path) -> None: + (tmp_path / "jail.d").mkdir() + for name in ("zzz.conf", "aaa.conf", "mmm.conf"): + _write(tmp_path / "jail.d" / name, "") + result = _ordered_config_files(tmp_path) + names = [p.name for p in result] + assert names == ["aaa.conf", "mmm.conf", "zzz.conf"] + + +# --------------------------------------------------------------------------- +# _parse_jails_sync +# --------------------------------------------------------------------------- + +JAIL_CONF = """\ +[DEFAULT] +bantime = 10m +findtime = 5m +maxretry = 5 + +[sshd] +enabled = true +filter = sshd +port = ssh +logpath = /var/log/auth.log + +[apache-auth] +enabled = false +filter = apache-auth +port = http,https +logpath = /var/log/apache2/error.log +""" + +JAIL_LOCAL = """\ +[sshd] +bantime = 1h +""" + +JAIL_D_CUSTOM = """\ +[nginx-http-auth] +enabled = false +filter = nginx-http-auth +port = http,https +logpath = /var/log/nginx/error.log +""" + + +class TestParseJailsSync: + def test_parses_all_jails(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + jails, _ = _parse_jails_sync(tmp_path) + assert "sshd" in jails + assert "apache-auth" in jails + + def test_enabled_flag_parsing(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + jails, _ = _parse_jails_sync(tmp_path) + assert jails["sshd"]["enabled"] == "true" + assert jails["apache-auth"]["enabled"] == "false" + + def test_default_inheritance(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + jails, _ = _parse_jails_sync(tmp_path) + # DEFAULT values should flow into each jail via configparser + assert jails["sshd"]["bantime"] == "10m" + assert jails["apache-auth"]["maxretry"] == "5" + + def test_local_override_wins(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "jail.local", JAIL_LOCAL) + jails, _ = _parse_jails_sync(tmp_path) + # jail.local overrides bantime for sshd from 10m → 1h + assert jails["sshd"]["bantime"] == "1h" + + def test_jail_d_conf_included(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "jail.d" / "custom.conf", JAIL_D_CUSTOM) + jails, _ = _parse_jails_sync(tmp_path) + assert "nginx-http-auth" in jails + + def test_source_file_tracked(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "jail.d" / "custom.conf", JAIL_D_CUSTOM) + _, source_files = _parse_jails_sync(tmp_path) + # sshd comes from jail.conf; nginx-http-auth from jail.d/custom.conf + assert source_files["sshd"] == str(tmp_path / "jail.conf") + assert source_files["nginx-http-auth"] == str(tmp_path / "jail.d" / "custom.conf") + + def test_source_file_local_override_tracked(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "jail.local", JAIL_LOCAL) + _, source_files = _parse_jails_sync(tmp_path) + # jail.local defines [sshd] again → that file wins + assert source_files["sshd"] == str(tmp_path / "jail.local") + + def test_default_section_excluded(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + jails, _ = _parse_jails_sync(tmp_path) + assert "DEFAULT" not in jails + + def test_includes_section_excluded(self, tmp_path: Path) -> None: + content = "[INCLUDES]\nbefore = paths-debian.conf\n" + JAIL_CONF + _write(tmp_path / "jail.conf", content) + jails, _ = _parse_jails_sync(tmp_path) + assert "INCLUDES" not in jails + + def test_corrupt_file_skipped_gracefully(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", "[[bad section\n") + # Should not raise; bad section just yields no jails + jails, _ = _parse_jails_sync(tmp_path) + assert isinstance(jails, dict) + + +# --------------------------------------------------------------------------- +# _build_inactive_jail +# --------------------------------------------------------------------------- + + +class TestBuildInactiveJail: + def test_basic_fields(self) -> None: + settings = { + "enabled": "false", + "filter": "sshd", + "port": "ssh", + "logpath": "/var/log/auth.log", + "bantime": "10m", + "findtime": "5m", + "maxretry": "5", + "action": "", + } + jail = _build_inactive_jail("sshd", settings, "/etc/fail2ban/jail.d/sshd.conf") + assert jail.name == "sshd" + assert jail.filter == "sshd" + assert jail.port == "ssh" + assert jail.logpath == ["/var/log/auth.log"] + assert jail.bantime == "10m" + assert jail.findtime == "5m" + assert jail.maxretry == 5 + assert jail.enabled is False + assert "sshd.conf" in jail.source_file + + def test_filter_name_substitution(self) -> None: + settings = {"enabled": "false", "filter": "%(__name__)s"} + jail = _build_inactive_jail("myservice", settings, "/etc/fail2ban/jail.conf") + assert jail.filter == "myservice" + + def test_missing_optional_fields(self) -> None: + jail = _build_inactive_jail("minimal", {}, "/etc/fail2ban/jail.conf") + assert jail.filter == "minimal" # falls back to name + assert jail.port is None + assert jail.logpath == [] + assert jail.bantime is None + assert jail.maxretry is None + + def test_multiline_logpath(self) -> None: + settings = {"logpath": "/var/log/app.log\n/var/log/app2.log"} + jail = _build_inactive_jail("app", settings, "/etc/fail2ban/jail.conf") + assert "/var/log/app.log" in jail.logpath + assert "/var/log/app2.log" in jail.logpath + + def test_multiline_actions(self) -> None: + settings = {"action": "iptables-multiport\niptables-ipset"} + jail = _build_inactive_jail("app", settings, "/etc/fail2ban/jail.conf") + assert len(jail.actions) == 2 + + def test_enabled_true(self) -> None: + settings = {"enabled": "true"} + jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf") + assert jail.enabled is True + + +# --------------------------------------------------------------------------- +# _write_local_override_sync +# --------------------------------------------------------------------------- + + +class TestWriteLocalOverrideSync: + def test_creates_local_file(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {}) + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + + def test_enabled_true_written(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "enabled = true" in content + + def test_enabled_false_written(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", False, {}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "enabled = false" in content + + def test_section_header_written(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "apache-auth", True, {}) + content = (tmp_path / "jail.d" / "apache-auth.local").read_text() + assert "[apache-auth]" in content + + def test_override_bantime(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {"bantime": "1h"}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "bantime" in content + assert "1h" in content + + def test_override_findtime(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {"findtime": "30m"}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "findtime" in content + assert "30m" in content + + def test_override_maxretry(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {"maxretry": 3}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "maxretry" in content + assert "3" in content + + def test_override_port(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {"port": "2222"}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "2222" in content + + def test_override_logpath_list(self, tmp_path: Path) -> None: + _write_local_override_sync( + tmp_path, "sshd", True, {"logpath": ["/var/log/auth.log", "/var/log/secure"]} + ) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "/var/log/auth.log" in content + assert "/var/log/secure" in content + + def test_bang_gui_header_comment(self, tmp_path: Path) -> None: + _write_local_override_sync(tmp_path, "sshd", True, {}) + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "BanGUI" in content + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + local = tmp_path / "jail.d" / "sshd.local" + local.parent.mkdir() + local.write_text("old content") + _write_local_override_sync(tmp_path, "sshd", True, {}) + assert "old content" not in local.read_text() + + +# --------------------------------------------------------------------------- +# list_inactive_jails +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestListInactiveJails: + async def test_returns_only_inactive(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + # sshd is enabled=true; apache-auth is enabled=false + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + + names = [j.name for j in result.jails] + assert "sshd" not in names + assert "apache-auth" in names + + async def test_total_matches_jails_count(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + + assert result.total == len(result.jails) + + async def test_empty_config_dir(self, tmp_path: Path) -> None: + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + + assert result.jails == [] + assert result.total == 0 + + async def test_all_active_returns_empty(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd", "apache-auth"}), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + + assert result.jails == [] + + async def test_fail2ban_unreachable_shows_all(self, tmp_path: Path) -> None: + # When fail2ban is unreachable, _get_active_jail_names returns empty set, + # so every config-defined jail appears as inactive. + _write(tmp_path / "jail.conf", JAIL_CONF) + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + + names = {j.name for j in result.jails} + assert "sshd" in names + assert "apache-auth" in names + + +# --------------------------------------------------------------------------- +# activate_jail +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestActivateJail: + async def test_activates_known_inactive_jail(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest, JailValidationResult + + req = ActivateJailRequest() + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(side_effect=[set(), {"apache-auth"}]), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult(jail_name="apache-auth", valid=True), + ), + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req) + + assert result.active is True + assert "apache-auth" in result.name + local = tmp_path / "jail.d" / "apache-auth.local" + assert local.is_file() + assert "enabled = true" in local.read_text() + + async def test_raises_not_found_for_unknown_jail(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest + + req = ActivateJailRequest() + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ),pytest.raises(JailNotFoundInConfigError) + ): + await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req) + + async def test_raises_already_active(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest + + req = ActivateJailRequest() + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ),pytest.raises(JailAlreadyActiveError) + ): + await activate_jail(str(tmp_path), "/fake.sock", "sshd", req) + + async def test_raises_name_error_for_bad_name(self, tmp_path: Path) -> None: + from app.models.config import ActivateJailRequest + + req = ActivateJailRequest() + with pytest.raises(JailNameError): + await activate_jail(str(tmp_path), "/fake.sock", "../etc/passwd", req) + + async def test_writes_overrides_to_local_file(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest, JailValidationResult + + req = ActivateJailRequest(bantime="2h", maxretry=3) + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + # First call: pre-activation (not active); second: post-reload (started). + new=AsyncMock(side_effect=[set(), {"apache-auth"}]), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult(jail_name="apache-auth", valid=True), + ), + ): + mock_js.reload_all = AsyncMock() + await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req) + + content = (tmp_path / "jail.d" / "apache-auth.local").read_text() + assert "2h" in content + assert "3" in content + + +# --------------------------------------------------------------------------- +# deactivate_jail +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDeactivateJail: + async def test_deactivates_active_jail(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + ): + mock_js.reload_all = AsyncMock() + result = await deactivate_jail(str(tmp_path), "/fake.sock", "sshd") + + assert result.active is False + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + assert "enabled = false" in local.read_text() + + async def test_raises_not_found(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ),pytest.raises(JailNotFoundInConfigError) + ): + await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent") + + async def test_raises_already_inactive(self, tmp_path: Path) -> None: + _write(tmp_path / "jail.conf", JAIL_CONF) + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ),pytest.raises(JailAlreadyInactiveError) + ): + await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth") + + async def test_raises_name_error(self, tmp_path: Path) -> None: + with pytest.raises(JailNameError): + await deactivate_jail(str(tmp_path), "/fake.sock", "a/b") + + +# --------------------------------------------------------------------------- +# _extract_filter_base_name +# --------------------------------------------------------------------------- + + +class TestExtractFilterBaseName: + def test_simple_name(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("sshd") == "sshd" + + def test_name_with_mode(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("sshd[mode=aggressive]") == "sshd" + + def test_name_with_variable_mode(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("sshd[mode=%(mode)s]") == "sshd" + + def test_whitespace_stripped(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name(" nginx ") == "nginx" + + def test_empty_string(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("") == "" + + +# --------------------------------------------------------------------------- +# _build_filter_to_jails_map +# --------------------------------------------------------------------------- + + +class TestBuildFilterToJailsMap: + def test_active_jail_maps_to_filter(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + result = _build_filter_to_jails_map({"sshd": {"filter": "sshd"}}, {"sshd"}) + assert result == {"sshd": ["sshd"]} + + def test_inactive_jail_not_included(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + result = _build_filter_to_jails_map( + {"apache-auth": {"filter": "apache-auth"}}, set() + ) + assert result == {} + + def test_multiple_jails_sharing_filter(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + all_jails = { + "sshd": {"filter": "sshd"}, + "sshd-ddos": {"filter": "sshd"}, + } + result = _build_filter_to_jails_map(all_jails, {"sshd", "sshd-ddos"}) + assert sorted(result["sshd"]) == ["sshd", "sshd-ddos"] + + def test_mode_suffix_stripped(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + result = _build_filter_to_jails_map( + {"sshd": {"filter": "sshd[mode=aggressive]"}}, {"sshd"} + ) + assert "sshd" in result + + def test_missing_filter_key_falls_back_to_jail_name(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + # When jail has no "filter" key the code falls back to the jail name. + result = _build_filter_to_jails_map({"sshd": {}}, {"sshd"}) + assert "sshd" in result + + +# --------------------------------------------------------------------------- +# _parse_filters_sync +# --------------------------------------------------------------------------- + +_FILTER_CONF = """\ +[Definition] +failregex = ^Host: +ignoreregex = +""" + + +class TestParseFiltersSync: + def test_returns_empty_for_missing_dir(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + result = _parse_filters_sync(tmp_path / "nonexistent") + assert result == [] + + def test_single_filter_returned(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + + result = _parse_filters_sync(filter_d) + + assert len(result) == 1 + name, filename, content, has_local, source_path = result[0] + assert name == "nginx" + assert filename == "nginx.conf" + assert "failregex" in content + assert has_local is False + assert source_path.endswith("nginx.conf") + + def test_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + _write(filter_d / "nginx.local", "[Definition]\nignoreregex = ^safe\n") + + result = _parse_filters_sync(filter_d) + + _, _, _, has_local, _ = result[0] + assert has_local is True + + def test_local_content_appended_to_content(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + _write(filter_d / "nginx.local", "[Definition]\n# local tweak\n") + + result = _parse_filters_sync(filter_d) + + _, _, content, _, _ = result[0] + assert "local tweak" in content + + def test_sorted_alphabetically(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + for name in ("zzz", "aaa", "mmm"): + _write(filter_d / f"{name}.conf", _FILTER_CONF) + + result = _parse_filters_sync(filter_d) + + names = [r[0] for r in result] + assert names == ["aaa", "mmm", "zzz"] + + +# --------------------------------------------------------------------------- +# list_filters +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestListFilters: + async def test_returns_all_filters(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "nginx.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + assert result.total == 2 + names = {f.name for f in result.filters} + assert "sshd" in names + assert "nginx" in names + + async def test_active_flag_set_for_used_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + sshd = next(f for f in result.filters if f.name == "sshd") + assert sshd.active is True + assert "sshd" in sshd.used_by_jails + + async def test_inactive_filter_not_marked_active(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + nginx = next(f for f in result.filters if f.name == "nginx") + assert nginx.active is False + assert nginx.used_by_jails == [] + + async def test_has_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + sshd = next(f for f in result.filters if f.name == "sshd") + assert sshd.has_local_override is True + + async def test_empty_filter_d_returns_empty(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + assert result.filters == [] + assert result.total == 0 + + +# --------------------------------------------------------------------------- +# get_filter +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetFilter: + async def test_returns_filter_config(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "sshd") + + assert result.name == "sshd" + assert result.active is True + assert "sshd" in result.used_by_jails + + async def test_accepts_conf_extension(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "sshd.conf") + + assert result.name == "sshd" + + async def test_raises_filter_not_found(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterNotFoundError, get_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterNotFoundError): + await get_filter(str(tmp_path), "/fake.sock", "nonexistent") + + async def test_has_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "sshd") + + assert result.has_local_override is True + + +# --------------------------------------------------------------------------- +# _parse_filters_sync — .local-only filters (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestParseFiltersSyncLocalOnly: + """Verify that .local-only user-created filters appear in results.""" + + def test_local_only_included(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\nfailregex = ^fail\n") + + result = _parse_filters_sync(filter_d) + + assert len(result) == 1 + name, filename, content, has_local, source_path = result[0] + assert name == "custom" + assert filename == "custom.local" + assert has_local is False # .local-only: no conf to override + assert source_path.endswith("custom.local") + + def test_local_only_not_duplicated_when_conf_exists(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + result = _parse_filters_sync(filter_d) + + # sshd should appear exactly once (conf + local, not as separate entry) + names = [r[0] for r in result] + assert names.count("sshd") == 1 + _, _, _, has_local, _ = result[0] + assert has_local is True # conf + local → has_local_override + + def test_local_only_sorted_with_conf_filters(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "zzz.conf", _FILTER_CONF) + _write(filter_d / "aaa.local", "[Definition]\nfailregex = x\n") + + result = _parse_filters_sync(filter_d) + + names = [r[0] for r in result] + assert names == ["aaa", "zzz"] + + +# --------------------------------------------------------------------------- +# get_filter — .local-only filter (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetFilterLocalOnly: + """Verify that get_filter handles .local-only user-created filters.""" + + async def test_returns_local_only_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write( + filter_d / "custom.local", + "[Definition]\nfailregex = ^fail from \n", + ) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "custom") + + assert result.name == "custom" + assert result.has_local_override is False + assert result.source_file.endswith("custom.local") + assert len(result.failregex) == 1 + + async def test_raises_when_neither_conf_nor_local(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterNotFoundError, get_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterNotFoundError): + await get_filter(str(tmp_path), "/fake.sock", "nonexistent") + + async def test_accepts_local_extension(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\nfailregex = x\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "custom.local") + + assert result.name == "custom" + + +# --------------------------------------------------------------------------- +# _validate_regex_patterns (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestValidateRegexPatterns: + def test_valid_patterns_pass(self) -> None: + from app.services.config_file_service import _validate_regex_patterns + + _validate_regex_patterns([r"^fail from \S+", r"\d+\.\d+"]) + + def test_empty_list_passes(self) -> None: + from app.services.config_file_service import _validate_regex_patterns + + _validate_regex_patterns([]) + + def test_invalid_pattern_raises(self) -> None: + from app.services.config_file_service import ( + FilterInvalidRegexError, + _validate_regex_patterns, + ) + + with pytest.raises(FilterInvalidRegexError) as exc_info: + _validate_regex_patterns([r"[unclosed"]) + + assert "[unclosed" in exc_info.value.pattern + + def test_mixed_valid_invalid_raises_on_first_invalid(self) -> None: + from app.services.config_file_service import ( + FilterInvalidRegexError, + _validate_regex_patterns, + ) + + with pytest.raises(FilterInvalidRegexError) as exc_info: + _validate_regex_patterns([r"\d+", r"[bad", r"\w+"]) + + assert "[bad" in exc_info.value.pattern + + +# --------------------------------------------------------------------------- +# _write_filter_local_sync (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestWriteFilterLocalSync: + def test_writes_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_filter_local_sync + + filter_d = tmp_path / "filter.d" + filter_d.mkdir() + _write_filter_local_sync(filter_d, "myfilter", "[Definition]\n") + + local = filter_d / "myfilter.local" + assert local.is_file() + assert "[Definition]" in local.read_text() + + def test_creates_filter_d_if_missing(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_filter_local_sync + + filter_d = tmp_path / "filter.d" + _write_filter_local_sync(filter_d, "test", "[Definition]\n") + assert (filter_d / "test.local").is_file() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_filter_local_sync + + filter_d = tmp_path / "filter.d" + filter_d.mkdir() + (filter_d / "myfilter.local").write_text("old content") + + _write_filter_local_sync(filter_d, "myfilter", "[Definition]\nnew=1\n") + + assert "new=1" in (filter_d / "myfilter.local").read_text() + assert "old content" not in (filter_d / "myfilter.local").read_text() + + +# --------------------------------------------------------------------------- +# _set_jail_local_key_sync (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestSetJailLocalKeySync: + def test_creates_new_local_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _set_jail_local_key_sync + + _set_jail_local_key_sync(tmp_path, "sshd", "filter", "myfilter") + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + content = local.read_text() + assert "filter" in content + assert "myfilter" in content + + def test_updates_existing_local_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _set_jail_local_key_sync + + jail_d = tmp_path / "jail.d" + jail_d.mkdir() + (jail_d / "sshd.local").write_text( + "[sshd]\nenabled = true\n" + ) + + _set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter") + + content = (jail_d / "sshd.local").read_text() + assert "newfilter" in content + # Existing key is preserved + assert "enabled" in content + + def test_overwrites_existing_key(self, tmp_path: Path) -> None: + from app.services.config_file_service import _set_jail_local_key_sync + + jail_d = tmp_path / "jail.d" + jail_d.mkdir() + (jail_d / "sshd.local").write_text("[sshd]\nfilter = old\n") + + _set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter") + + content = (jail_d / "sshd.local").read_text() + assert "newfilter" in content + + +# --------------------------------------------------------------------------- +# update_filter (Task 2.2) +# --------------------------------------------------------------------------- + + +_FILTER_CONF_WITH_REGEX = """\ +[Definition] + +failregex = ^fail from + ^error from + +ignoreregex = +""" + + +@pytest.mark.asyncio +class TestUpdateFilter: + async def test_writes_local_override(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import update_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await update_filter( + str(tmp_path), + "/fake.sock", + "sshd", + FilterUpdateRequest(failregex=[r"^new pattern "]), + ) + + local = filter_d / "sshd.local" + assert local.is_file() + assert result.name == "sshd" + assert any("new pattern" in p for p in result.failregex) + + async def test_accepts_conf_extension(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import update_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await update_filter( + str(tmp_path), + "/fake.sock", + "sshd.conf", + FilterUpdateRequest(datepattern="%Y-%m-%d"), + ) + + assert result.name == "sshd" + + async def test_raises_filter_not_found(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import FilterNotFoundError, update_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterNotFoundError): + await update_filter( + str(tmp_path), + "/fake.sock", + "missing", + FilterUpdateRequest(), + ) + + async def test_raises_on_invalid_regex(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import ( + FilterInvalidRegexError, + update_filter, + ) + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterInvalidRegexError): + await update_filter( + str(tmp_path), + "/fake.sock", + "sshd", + FilterUpdateRequest(failregex=[r"[unclosed"]), + ) + + async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import FilterNameError, update_filter + + with pytest.raises(FilterNameError): + await update_filter( + str(tmp_path), + "/fake.sock", + "../etc/passwd", + FilterUpdateRequest(), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import update_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload: + await update_filter( + str(tmp_path), + "/fake.sock", + "sshd", + FilterUpdateRequest(journalmatch="_SYSTEMD_UNIT=sshd.service"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# create_filter (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestCreateFilter: + async def test_creates_local_file(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import create_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest( + name="my-custom", + failregex=[r"^fail from "], + ), + ) + + local = tmp_path / "filter.d" / "my-custom.local" + assert local.is_file() + assert result.name == "my-custom" + assert result.source_file.endswith("my-custom.local") + + async def test_raises_already_exists_when_conf_exists(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterAlreadyExistsError, create_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterAlreadyExistsError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="sshd"), + ) + + async def test_raises_already_exists_when_local_exists(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterAlreadyExistsError, create_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterAlreadyExistsError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="custom"), + ) + + async def test_raises_invalid_regex(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterInvalidRegexError, create_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterInvalidRegexError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="bad", failregex=[r"[unclosed"]), + ) + + async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterNameError, create_filter + + with pytest.raises(FilterNameError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="../etc/evil"), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import create_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload: + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="newfilter"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# delete_filter (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDeleteFilter: + async def test_deletes_local_file_when_conf_and_local_exist( + self, tmp_path: Path + ) -> None: + from app.services.config_file_service import delete_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + await delete_filter(str(tmp_path), "sshd") + + assert not (filter_d / "sshd.local").exists() + assert (filter_d / "sshd.conf").exists() + + async def test_deletes_local_only_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import delete_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\n") + + await delete_filter(str(tmp_path), "custom") + + assert not (filter_d / "custom.local").exists() + + async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterReadonlyError, delete_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with pytest.raises(FilterReadonlyError): + await delete_filter(str(tmp_path), "sshd") + + async def test_raises_not_found_for_missing_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterNotFoundError, delete_filter + + with pytest.raises(FilterNotFoundError): + await delete_filter(str(tmp_path), "nonexistent") + + async def test_accepts_filter_name_error_for_invalid_name( + self, tmp_path: Path + ) -> None: + from app.services.config_file_service import FilterNameError, delete_filter + + with pytest.raises(FilterNameError): + await delete_filter(str(tmp_path), "../evil") + + +# --------------------------------------------------------------------------- +# assign_filter_to_jail (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestAssignFilterToJail: + async def test_writes_filter_key_to_jail_local(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import assign_filter_to_jail + + # Setup: jail.conf with sshd jail, filter.conf for "myfilter" + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="myfilter"), + ) + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + content = local.read_text() + assert "myfilter" in content + + async def test_raises_jail_not_found(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import ( + JailNotFoundInConfigError, + assign_filter_to_jail, + ) + + _write(tmp_path / "filter.d" / "sshd.conf", _FILTER_CONF) + + with pytest.raises(JailNotFoundInConfigError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "nonexistent-jail", + AssignFilterRequest(filter_name="sshd"), + ) + + async def test_raises_filter_not_found(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import FilterNotFoundError, assign_filter_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + + with pytest.raises(FilterNotFoundError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="nonexistent-filter"), + ) + + async def test_raises_jail_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import JailNameError, assign_filter_to_jail + + with pytest.raises(JailNameError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "../etc/evil", + AssignFilterRequest(filter_name="sshd"), + ) + + async def test_raises_filter_name_error_for_invalid_filter( + self, tmp_path: Path + ) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import FilterNameError, assign_filter_to_jail + + with pytest.raises(FilterNameError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="../etc/evil"), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import assign_filter_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload: + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="myfilter"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# =========================================================================== +# Action service tests (Task 3.1 + 3.2) +# =========================================================================== + +_ACTION_CONF = """\ +[Definition] + +actionstart = /sbin/iptables -N f2b- +actionstop = /sbin/iptables -D INPUT -j f2b- +actionban = /sbin/iptables -I f2b- 1 -s -j DROP +actionunban = /sbin/iptables -D f2b- -s -j DROP + +[Init] + +name = default +port = ssh +protocol = tcp +""" + +_ACTION_CONF_MINIMAL = """\ +[Definition] + +actionban = echo ban +actionunban = echo unban +""" + + +# --------------------------------------------------------------------------- +# _safe_action_name +# --------------------------------------------------------------------------- + + +class TestSafeActionName: + def test_valid_simple(self) -> None: + from app.services.config_file_service import _safe_action_name + + assert _safe_action_name("iptables") == "iptables" + + def test_valid_with_hyphen(self) -> None: + from app.services.config_file_service import _safe_action_name + + assert _safe_action_name("iptables-multiport") == "iptables-multiport" + + def test_valid_with_dot(self) -> None: + from app.services.config_file_service import _safe_action_name + + assert _safe_action_name("my.action") == "my.action" + + def test_invalid_path_traversal(self) -> None: + from app.services.config_file_service import ActionNameError, _safe_action_name + + with pytest.raises(ActionNameError): + _safe_action_name("../evil") + + def test_invalid_empty(self) -> None: + from app.services.config_file_service import ActionNameError, _safe_action_name + + with pytest.raises(ActionNameError): + _safe_action_name("") + + def test_invalid_slash(self) -> None: + from app.services.config_file_service import ActionNameError, _safe_action_name + + with pytest.raises(ActionNameError): + _safe_action_name("a/b") + + +# --------------------------------------------------------------------------- +# _build_action_to_jails_map +# --------------------------------------------------------------------------- + + +class TestBuildActionToJailsMap: + def test_active_jail_maps_to_action(self) -> None: + from app.services.config_file_service import _build_action_to_jails_map + + result = _build_action_to_jails_map( + {"sshd": {"action": "iptables-multiport"}}, {"sshd"} + ) + assert result == {"iptables-multiport": ["sshd"]} + + def test_inactive_jail_not_included(self) -> None: + from app.services.config_file_service import _build_action_to_jails_map + + result = _build_action_to_jails_map( + {"sshd": {"action": "iptables-multiport"}}, set() + ) + assert result == {} + + def test_multiple_actions_per_jail(self) -> None: + from app.services.config_file_service import _build_action_to_jails_map + + result = _build_action_to_jails_map( + {"sshd": {"action": "iptables-multiport\niptables-ipset"}}, {"sshd"} + ) + assert "iptables-multiport" in result + assert "iptables-ipset" in result + + def test_parameter_block_stripped(self) -> None: + from app.services.config_file_service import _build_action_to_jails_map + + result = _build_action_to_jails_map( + {"sshd": {"action": "iptables[port=ssh, protocol=tcp]"}}, {"sshd"} + ) + assert "iptables" in result + + def test_multiple_jails_sharing_action(self) -> None: + from app.services.config_file_service import _build_action_to_jails_map + + all_jails = { + "sshd": {"action": "iptables-multiport"}, + "apache": {"action": "iptables-multiport"}, + } + result = _build_action_to_jails_map(all_jails, {"sshd", "apache"}) + assert sorted(result["iptables-multiport"]) == ["apache", "sshd"] + + def test_jail_with_no_action_key(self) -> None: + from app.services.config_file_service import _build_action_to_jails_map + + result = _build_action_to_jails_map({"sshd": {}}, {"sshd"}) + assert result == {} + + +# --------------------------------------------------------------------------- +# _parse_actions_sync +# --------------------------------------------------------------------------- + + +class TestParseActionsSync: + def test_returns_empty_for_missing_dir(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_actions_sync + + result = _parse_actions_sync(tmp_path / "nonexistent") + assert result == [] + + def test_single_action_returned(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_actions_sync + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + result = _parse_actions_sync(action_d) + + assert len(result) == 1 + name, filename, content, has_local, source_path = result[0] + assert name == "iptables" + assert filename == "iptables.conf" + assert "actionban" in content + assert has_local is False + assert source_path.endswith("iptables.conf") + + def test_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_actions_sync + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + _write(action_d / "iptables.local", "[Definition]\n# override\n") + + result = _parse_actions_sync(action_d) + + _, _, _, has_local, _ = result[0] + assert has_local is True + + def test_local_content_merged_into_content(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_actions_sync + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + _write(action_d / "iptables.local", "[Definition]\n# local override tweak\n") + + result = _parse_actions_sync(action_d) + + _, _, content, _, _ = result[0] + assert "local override tweak" in content + + def test_local_only_action_included(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_actions_sync + + action_d = tmp_path / "action.d" + _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) + + result = _parse_actions_sync(action_d) + + assert len(result) == 1 + name, filename, _, has_local, source_path = result[0] + assert name == "custom" + assert filename == "custom.local" + assert has_local is False # local-only: no .conf to pair with + assert source_path.endswith("custom.local") + + def test_sorted_alphabetically(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_actions_sync + + action_d = tmp_path / "action.d" + for n in ("zzz", "aaa", "mmm"): + _write(action_d / f"{n}.conf", _ACTION_CONF_MINIMAL) + + result = _parse_actions_sync(action_d) + + assert [r[0] for r in result] == ["aaa", "mmm", "zzz"] + + +# --------------------------------------------------------------------------- +# list_actions +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestListActions: + async def test_returns_all_actions(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_actions + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + _write(action_d / "cloudflare.conf", _ACTION_CONF_MINIMAL) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_actions(str(tmp_path), "/fake.sock") + + assert result.total == 2 + names = {a.name for a in result.actions} + assert "iptables" in names + assert "cloudflare" in names + + async def test_active_flag_set_for_used_action(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_actions + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + all_jails_with_action = { + "sshd": { + "enabled": "true", + "filter": "sshd", + "action": "iptables", + }, + "apache-auth": {"enabled": "false"}, + } + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ), + patch( + "app.services.config_file_service._parse_jails_sync", + return_value=(all_jails_with_action, {}), + ), + ): + result = await list_actions(str(tmp_path), "/fake.sock") + + iptables = next(a for a in result.actions if a.name == "iptables") + assert iptables.active is True + assert "sshd" in iptables.used_by_jails + + async def test_inactive_action_has_active_false(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_actions + + action_d = tmp_path / "action.d" + _write(action_d / "cloudflare.conf", _ACTION_CONF_MINIMAL) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_actions(str(tmp_path), "/fake.sock") + + cf = next(a for a in result.actions if a.name == "cloudflare") + assert cf.active is False + assert cf.used_by_jails == [] + + async def test_has_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_actions + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + _write(action_d / "iptables.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_actions(str(tmp_path), "/fake.sock") + + ipt = next(a for a in result.actions if a.name == "iptables") + assert ipt.has_local_override is True + + async def test_empty_action_d_returns_empty(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_actions + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_actions(str(tmp_path), "/fake.sock") + + assert result.actions == [] + assert result.total == 0 + + async def test_total_matches_actions_count(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_actions + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_actions(str(tmp_path), "/fake.sock") + + assert result.total == len(result.actions) + + +# --------------------------------------------------------------------------- +# get_action +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetAction: + async def test_returns_action_config(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_action(str(tmp_path), "/fake.sock", "iptables") + + assert result.name == "iptables" + assert result.actionban is not None + assert "iptables" in (result.actionban or "") + + async def test_strips_conf_extension(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_action(str(tmp_path), "/fake.sock", "iptables.conf") + + assert result.name == "iptables" + + async def test_raises_for_unknown_action(self, tmp_path: Path) -> None: + from app.services.config_file_service import ActionNotFoundError, get_action + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(ActionNotFoundError): + await get_action(str(tmp_path), "/fake.sock", "nonexistent") + + async def test_local_only_action_returned(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_action + + action_d = tmp_path / "action.d" + _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_action(str(tmp_path), "/fake.sock", "custom") + + assert result.name == "custom" + + async def test_active_status_populated(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + all_jails_with_action = { + "sshd": {"enabled": "true", "filter": "sshd", "action": "iptables"}, + } + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ), + patch( + "app.services.config_file_service._parse_jails_sync", + return_value=(all_jails_with_action, {}), + ), + ): + result = await get_action(str(tmp_path), "/fake.sock", "iptables") + + assert result.active is True + assert "sshd" in result.used_by_jails + + +# --------------------------------------------------------------------------- +# _write_action_local_sync +# --------------------------------------------------------------------------- + + +class TestWriteActionLocalSync: + def test_writes_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_action_local_sync + + action_d = tmp_path / "action.d" + action_d.mkdir() + _write_action_local_sync(action_d, "myaction", "[Definition]\n") + + local = action_d / "myaction.local" + assert local.is_file() + assert "[Definition]" in local.read_text() + + def test_creates_action_d_if_missing(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_action_local_sync + + action_d = tmp_path / "action.d" + _write_action_local_sync(action_d, "test", "[Definition]\n") + assert (action_d / "test.local").is_file() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_action_local_sync + + action_d = tmp_path / "action.d" + action_d.mkdir() + (action_d / "myaction.local").write_text("old content") + + _write_action_local_sync(action_d, "myaction", "[Definition]\nnew=1\n") + + assert "new=1" in (action_d / "myaction.local").read_text() + assert "old content" not in (action_d / "myaction.local").read_text() + + +# --------------------------------------------------------------------------- +# update_action (Task 3.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestUpdateAction: + async def test_updates_actionban(self, tmp_path: Path) -> None: + from app.models.config import ActionUpdateRequest + from app.services.config_file_service import update_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await update_action( + str(tmp_path), + "/fake.sock", + "iptables", + ActionUpdateRequest(actionban="echo ban "), + ) + + local = action_d / "iptables.local" + assert local.is_file() + assert "echo ban" in local.read_text() + assert result.name == "iptables" + + async def test_raises_not_found_for_unknown_action(self, tmp_path: Path) -> None: + from app.models.config import ActionUpdateRequest + from app.services.config_file_service import ActionNotFoundError, update_action + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(ActionNotFoundError): + await update_action( + str(tmp_path), + "/fake.sock", + "nonexistent", + ActionUpdateRequest(), + ) + + async def test_raises_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import ActionUpdateRequest + from app.services.config_file_service import ActionNameError, update_action + + with pytest.raises(ActionNameError): + await update_action( + str(tmp_path), + "/fake.sock", + "../evil", + ActionUpdateRequest(), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import ActionUpdateRequest + from app.services.config_file_service import update_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload, + ): + await update_action( + str(tmp_path), + "/fake.sock", + "iptables", + ActionUpdateRequest(actionban="echo ban "), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# create_action (Task 3.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestCreateAction: + async def test_creates_local_file(self, tmp_path: Path) -> None: + from app.models.config import ActionCreateRequest + from app.services.config_file_service import create_action + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await create_action( + str(tmp_path), + "/fake.sock", + ActionCreateRequest( + name="my-action", + actionban="echo ban ", + actionunban="echo unban ", + ), + ) + + local = tmp_path / "action.d" / "my-action.local" + assert local.is_file() + assert result.name == "my-action" + + async def test_raises_already_exists_for_conf(self, tmp_path: Path) -> None: + from app.models.config import ActionCreateRequest + from app.services.config_file_service import ( + ActionAlreadyExistsError, + create_action, + ) + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with pytest.raises(ActionAlreadyExistsError): + await create_action( + str(tmp_path), + "/fake.sock", + ActionCreateRequest(name="iptables"), + ) + + async def test_raises_already_exists_for_local(self, tmp_path: Path) -> None: + from app.models.config import ActionCreateRequest + from app.services.config_file_service import ( + ActionAlreadyExistsError, + create_action, + ) + + action_d = tmp_path / "action.d" + _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) + + with pytest.raises(ActionAlreadyExistsError): + await create_action( + str(tmp_path), + "/fake.sock", + ActionCreateRequest(name="custom"), + ) + + async def test_raises_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import ActionCreateRequest + from app.services.config_file_service import ActionNameError, create_action + + with pytest.raises(ActionNameError): + await create_action( + str(tmp_path), + "/fake.sock", + ActionCreateRequest(name="../evil"), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import ActionCreateRequest + from app.services.config_file_service import create_action + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload, + ): + await create_action( + str(tmp_path), + "/fake.sock", + ActionCreateRequest(name="new-action"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# delete_action (Task 3.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDeleteAction: + async def test_deletes_local_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import delete_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + _write(action_d / "iptables.local", "[Definition]\n") + + await delete_action(str(tmp_path), "iptables") + + assert not (action_d / "iptables.local").is_file() + assert (action_d / "iptables.conf").is_file() # original untouched + + async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None: + from app.services.config_file_service import ActionReadonlyError, delete_action + + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with pytest.raises(ActionReadonlyError): + await delete_action(str(tmp_path), "iptables") + + async def test_raises_not_found_for_missing(self, tmp_path: Path) -> None: + from app.services.config_file_service import ActionNotFoundError, delete_action + + with pytest.raises(ActionNotFoundError): + await delete_action(str(tmp_path), "nonexistent") + + async def test_deletes_local_only_action(self, tmp_path: Path) -> None: + from app.services.config_file_service import delete_action + + action_d = tmp_path / "action.d" + _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) + + await delete_action(str(tmp_path), "custom") + + assert not (action_d / "custom.local").is_file() + + async def test_raises_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.services.config_file_service import ActionNameError, delete_action + + with pytest.raises(ActionNameError): + await delete_action(str(tmp_path), "../etc/evil") + + +# --------------------------------------------------------------------------- +# _append_jail_action_sync +# --------------------------------------------------------------------------- + + +class TestAppendJailActionSync: + def test_creates_local_with_action(self, tmp_path: Path) -> None: + from app.services.config_file_service import _append_jail_action_sync + + _append_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + assert "iptables-multiport" in local.read_text() + + def test_appends_to_existing_action_list(self, tmp_path: Path) -> None: + from app.services.config_file_service import _append_jail_action_sync + + jail_d = tmp_path / "jail.d" + _write(jail_d / "sshd.local", "[sshd]\naction = iptables-multiport\n") + + _append_jail_action_sync(tmp_path, "sshd", "cloudflare") + + content = (jail_d / "sshd.local").read_text() + assert "iptables-multiport" in content + assert "cloudflare" in content + + def test_does_not_duplicate_action(self, tmp_path: Path) -> None: + from app.services.config_file_service import _append_jail_action_sync + + jail_d = tmp_path / "jail.d" + _write(jail_d / "sshd.local", "[sshd]\naction = iptables-multiport\n") + + _append_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + _append_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + + content = (jail_d / "sshd.local").read_text() + # Should appear only once in the action list + assert content.count("iptables-multiport") == 1 + + def test_does_not_duplicate_when_params_differ(self, tmp_path: Path) -> None: + from app.services.config_file_service import _append_jail_action_sync + + jail_d = tmp_path / "jail.d" + _write( + jail_d / "sshd.local", + "[sshd]\naction = iptables[port=ssh]\n", + ) + + # Same base name, different params — should not duplicate. + _append_jail_action_sync(tmp_path, "sshd", "iptables[port=22]") + + content = (jail_d / "sshd.local").read_text() + assert content.count("iptables") == 1 + + +# --------------------------------------------------------------------------- +# _remove_jail_action_sync +# --------------------------------------------------------------------------- + + +class TestRemoveJailActionSync: + def test_removes_action_from_list(self, tmp_path: Path) -> None: + from app.services.config_file_service import _remove_jail_action_sync + + jail_d = tmp_path / "jail.d" + _write( + jail_d / "sshd.local", + "[sshd]\naction = iptables-multiport\n", + ) + + _remove_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + + content = (jail_d / "sshd.local").read_text() + assert "iptables-multiport" not in content + + def test_removes_only_targeted_action(self, tmp_path: Path) -> None: + from app.services.config_file_service import ( + _append_jail_action_sync, + _remove_jail_action_sync, + ) + + jail_d = tmp_path / "jail.d" + jail_d.mkdir(parents=True, exist_ok=True) + _append_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + _append_jail_action_sync(tmp_path, "sshd", "cloudflare") + + _remove_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + + content = (jail_d / "sshd.local").read_text() + assert "iptables-multiport" not in content + assert "cloudflare" in content + + def test_is_noop_when_no_local_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _remove_jail_action_sync + + # Should not raise; no .local file to modify. + _remove_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + + def test_is_noop_when_action_not_in_list(self, tmp_path: Path) -> None: + from app.services.config_file_service import _remove_jail_action_sync + + jail_d = tmp_path / "jail.d" + _write(jail_d / "sshd.local", "[sshd]\naction = cloudflare\n") + + _remove_jail_action_sync(tmp_path, "sshd", "iptables-multiport") + + content = (jail_d / "sshd.local").read_text() + assert "cloudflare" in content # untouched + + +# --------------------------------------------------------------------------- +# assign_action_to_jail (Task 3.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestAssignActionToJail: + async def test_creates_local_with_action(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import assign_action_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignActionRequest(action_name="iptables"), + ) + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + assert "iptables" in local.read_text() + + async def test_params_written_to_action_entry(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import assign_action_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignActionRequest(action_name="iptables", params={"port": "ssh"}), + ) + + content = (tmp_path / "jail.d" / "sshd.local").read_text() + assert "port=ssh" in content + + async def test_raises_jail_not_found(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import ( + JailNotFoundInConfigError, + assign_action_to_jail, + ) + + with pytest.raises(JailNotFoundInConfigError): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "nonexistent", + AssignActionRequest(action_name="iptables"), + ) + + async def test_raises_action_not_found(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import ( + ActionNotFoundError, + assign_action_to_jail, + ) + + _write(tmp_path / "jail.conf", JAIL_CONF) + + with pytest.raises(ActionNotFoundError): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignActionRequest(action_name="nonexistent-action"), + ) + + async def test_raises_jail_name_error(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import JailNameError, assign_action_to_jail + + with pytest.raises(JailNameError): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "../etc/evil", + AssignActionRequest(action_name="iptables"), + ) + + async def test_raises_action_name_error(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import ( + ActionNameError, + assign_action_to_jail, + ) + + with pytest.raises(ActionNameError): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignActionRequest(action_name="../evil"), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import AssignActionRequest + from app.services.config_file_service import assign_action_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + action_d = tmp_path / "action.d" + _write(action_d / "iptables.conf", _ACTION_CONF) + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload, + ): + await assign_action_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignActionRequest(action_name="iptables"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# remove_action_from_jail (Task 3.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestRemoveActionFromJail: + async def test_removes_action_from_local(self, tmp_path: Path) -> None: + from app.services.config_file_service import remove_action_from_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + jail_d = tmp_path / "jail.d" + _write(jail_d / "sshd.local", "[sshd]\naction = iptables-multiport\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + await remove_action_from_jail( + str(tmp_path), "/fake.sock", "sshd", "iptables-multiport" + ) + + content = (jail_d / "sshd.local").read_text() + assert "iptables-multiport" not in content + + async def test_raises_jail_not_found(self, tmp_path: Path) -> None: + from app.services.config_file_service import ( + JailNotFoundInConfigError, + remove_action_from_jail, + ) + + with pytest.raises(JailNotFoundInConfigError): + await remove_action_from_jail( + str(tmp_path), "/fake.sock", "nonexistent", "iptables" + ) + + async def test_raises_jail_name_error(self, tmp_path: Path) -> None: + from app.services.config_file_service import JailNameError, remove_action_from_jail + + with pytest.raises(JailNameError): + await remove_action_from_jail( + str(tmp_path), "/fake.sock", "../evil", "iptables" + ) + + async def test_raises_action_name_error(self, tmp_path: Path) -> None: + from app.services.config_file_service import ActionNameError, remove_action_from_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + + with pytest.raises(ActionNameError): + await remove_action_from_jail( + str(tmp_path), "/fake.sock", "sshd", "../evil" + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.services.config_file_service import remove_action_from_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + jail_d = tmp_path / "jail.d" + _write(jail_d / "sshd.local", "[sshd]\naction = iptables\n") + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload, + ): + await remove_action_from_jail( + str(tmp_path), "/fake.sock", "sshd", "iptables", do_reload=True + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# activate_jail — reload_all keyword argument assertions (Stage 5.1) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestActivateJailReloadArgs: + """Verify activate_jail calls reload_all with include_jails=[name].""" + + async def test_activate_passes_include_jails(self, tmp_path: Path) -> None: + """activate_jail must pass include_jails=[name] to reload_all.""" + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest, JailValidationResult + + req = ActivateJailRequest() + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(side_effect=[set(), {"apache-auth"}]), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult(jail_name="apache-auth", valid=True), + ), + ): + mock_js.reload_all = AsyncMock() + await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req) + + mock_js.reload_all.assert_awaited_once_with( + "/fake.sock", include_jails=["apache-auth"] + ) + + async def test_activate_returns_active_true_when_jail_starts( + self, tmp_path: Path + ) -> None: + """activate_jail returns active=True when the jail appears in post-reload names.""" + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest, JailValidationResult + + req = ActivateJailRequest() + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(side_effect=[set(), {"apache-auth"}]), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult(jail_name="apache-auth", valid=True), + ), + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail( + str(tmp_path), "/fake.sock", "apache-auth", req + ) + + assert result.active is True + assert "activated" in result.message.lower() + + async def test_activate_returns_active_false_when_jail_does_not_start( + self, tmp_path: Path + ) -> None: + """activate_jail returns active=False when the jail is absent after reload. + + This covers the Stage 3.1 requirement: if the jail config is invalid + (bad regex, missing log file, etc.) fail2ban may silently refuse to + start the jail even though the reload command succeeded. + """ + _write(tmp_path / "jail.conf", JAIL_CONF) + from app.models.config import ActivateJailRequest, JailValidationResult + + req = ActivateJailRequest() + # Pre-reload: jail not running. Post-reload: still not running (boot failed). + # fail2ban is up (probe succeeds) but the jail didn't appear. + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(side_effect=[set(), set()]), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult(jail_name="apache-auth", valid=True), + ), + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail( + str(tmp_path), "/fake.sock", "apache-auth", req + ) + + assert result.active is False + assert "apache-auth" in result.name + + +# --------------------------------------------------------------------------- +# deactivate_jail — reload_all keyword argument assertions (Stage 5.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDeactivateJailReloadArgs: + """Verify deactivate_jail calls reload_all with exclude_jails=[name].""" + + async def test_deactivate_passes_exclude_jails(self, tmp_path: Path) -> None: + """deactivate_jail must pass exclude_jails=[name] to reload_all.""" + _write(tmp_path / "jail.conf", JAIL_CONF) + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + ): + mock_js.reload_all = AsyncMock() + await deactivate_jail(str(tmp_path), "/fake.sock", "sshd") + + mock_js.reload_all.assert_awaited_once_with( + "/fake.sock", exclude_jails=["sshd"] + ) + + +# --------------------------------------------------------------------------- +# _validate_jail_config_sync (Task 3) +# --------------------------------------------------------------------------- + +from app.services.config_file_service import ( # noqa: E402 (added after block) + _validate_jail_config_sync, + _extract_filter_base_name, + _extract_action_base_name, + validate_jail_config, + rollback_jail, +) + + +class TestExtractFilterBaseName: + def test_plain_name(self) -> None: + assert _extract_filter_base_name("sshd") == "sshd" + + def test_strips_mode_suffix(self) -> None: + assert _extract_filter_base_name("sshd[mode=aggressive]") == "sshd" + + def test_strips_whitespace(self) -> None: + assert _extract_filter_base_name(" nginx ") == "nginx" + + +class TestExtractActionBaseName: + def test_plain_name(self) -> None: + assert _extract_action_base_name("iptables-multiport") == "iptables-multiport" + + def test_strips_option_suffix(self) -> None: + assert _extract_action_base_name("iptables[name=SSH]") == "iptables" + + def test_returns_none_for_variable_interpolation(self) -> None: + assert _extract_action_base_name("%(action_)s") is None + + def test_returns_none_for_dollar_variable(self) -> None: + assert _extract_action_base_name("${action}") is None + + +class TestValidateJailConfigSync: + """Tests for _validate_jail_config_sync — the sync validation core.""" + + def _setup_config(self, config_dir: Path, jail_cfg: str) -> None: + """Write a minimal fail2ban directory layout with *jail_cfg* content.""" + _write(config_dir / "jail.d" / "test.conf", jail_cfg) + + def test_valid_config_no_issues(self, tmp_path: Path) -> None: + """A jail whose filter exists and has a valid regex should pass.""" + # Create a real filter file so the existence check passes. + filter_d = tmp_path / "filter.d" + filter_d.mkdir(parents=True, exist_ok=True) + (filter_d / "sshd.conf").write_text("[Definition]\nfailregex = Host .* \n") + + self._setup_config( + tmp_path, + "[sshd]\nenabled = false\nfilter = sshd\nlogpath = /no/such/log\n", + ) + + result = _validate_jail_config_sync(tmp_path, "sshd") + # logpath advisory warning is OK; no blocking errors expected. + blocking = [i for i in result.issues if i.field != "logpath"] + assert blocking == [], blocking + + def test_missing_filter_reported(self, tmp_path: Path) -> None: + """A jail whose filter file does not exist must report a filter issue.""" + self._setup_config( + tmp_path, + "[bad-jail]\nenabled = false\nfilter = nonexistent-filter\n", + ) + + result = _validate_jail_config_sync(tmp_path, "bad-jail") + assert not result.valid + fields = [i.field for i in result.issues] + assert "filter" in fields + + def test_bad_failregex_reported(self, tmp_path: Path) -> None: + """A jail with an un-compilable failregex must report a failregex issue.""" + self._setup_config( + tmp_path, + "[broken]\nenabled = false\nfailregex = [invalid regex(\n", + ) + + result = _validate_jail_config_sync(tmp_path, "broken") + assert not result.valid + fields = [i.field for i in result.issues] + assert "failregex" in fields + + def test_missing_log_path_is_advisory(self, tmp_path: Path) -> None: + """A missing log path should be reported in the logpath field.""" + self._setup_config( + tmp_path, + "[myjail]\nenabled = false\nlogpath = /no/such/path.log\n", + ) + + result = _validate_jail_config_sync(tmp_path, "myjail") + fields = [i.field for i in result.issues] + assert "logpath" in fields + + def test_missing_action_reported(self, tmp_path: Path) -> None: + """A jail referencing a non-existent action file must report an action issue.""" + self._setup_config( + tmp_path, + "[myjail]\nenabled = false\naction = nonexistent-action\n", + ) + + result = _validate_jail_config_sync(tmp_path, "myjail") + fields = [i.field for i in result.issues] + assert "action" in fields + + def test_unknown_jail_name(self, tmp_path: Path) -> None: + """Requesting validation for a jail not in any config returns an invalid result.""" + (tmp_path / "jail.d").mkdir(parents=True, exist_ok=True) + + result = _validate_jail_config_sync(tmp_path, "ghost") + assert not result.valid + assert any(i.field == "name" for i in result.issues) + + def test_variable_action_not_flagged(self, tmp_path: Path) -> None: + """An action like ``%(action_)s`` should not be checked for file existence.""" + self._setup_config( + tmp_path, + "[myjail]\nenabled = false\naction = %(action_)s\n", + ) + result = _validate_jail_config_sync(tmp_path, "myjail") + # Ensure no action file-missing error (the variable expression is skipped). + action_errors = [i for i in result.issues if i.field == "action"] + assert action_errors == [] + + +@pytest.mark.asyncio +class TestValidateJailConfigAsync: + """Tests for the public async wrapper validate_jail_config.""" + + async def test_returns_jail_validation_result(self, tmp_path: Path) -> None: + (tmp_path / "jail.d").mkdir(parents=True, exist_ok=True) + _write( + tmp_path / "jail.d" / "test.conf", + "[testjail]\nenabled = false\n", + ) + + result = await validate_jail_config(str(tmp_path), "testjail") + assert result.jail_name == "testjail" + + async def test_rejects_unsafe_name(self, tmp_path: Path) -> None: + with pytest.raises(JailNameError): + await validate_jail_config(str(tmp_path), "../evil") + + +@pytest.mark.asyncio +class TestRollbackJail: + """Tests for rollback_jail (Task 3).""" + + async def test_rollback_success(self, tmp_path: Path) -> None: + """When fail2ban comes back online, rollback returns fail2ban_running=True.""" + _write(tmp_path / "jail.d" / "sshd.conf", "[sshd]\nenabled = true\n") + + with ( + patch( + "app.services.config_file_service._start_daemon", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._wait_for_fail2ban", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + ): + result = await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + assert result.disabled is True + assert result.fail2ban_running is True + assert result.jail_name == "sshd" + # .local file must have enabled=false + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + assert "enabled = false" in local.read_text() + + async def test_rollback_fail2ban_still_down(self, tmp_path: Path) -> None: + """When fail2ban does not come back, rollback returns fail2ban_running=False.""" + _write(tmp_path / "jail.d" / "sshd.conf", "[sshd]\nenabled = true\n") + + with ( + patch( + "app.services.config_file_service._start_daemon", + new=AsyncMock(return_value=False), + ), + patch( + "app.services.config_file_service._wait_for_fail2ban", + new=AsyncMock(return_value=False), + ), + ): + result = await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + assert result.fail2ban_running is False + assert result.disabled is True + + async def test_rollback_rejects_unsafe_name(self, tmp_path: Path) -> None: + with pytest.raises(JailNameError): + await rollback_jail( + str(tmp_path), "/fake.sock", "../evil", ["fail2ban-client", "start"] + ) + + +# --------------------------------------------------------------------------- +# activate_jail — blocking on missing filter / logpath (Task 5) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestActivateJailBlocking: + """activate_jail must refuse to proceed when validation finds critical issues.""" + + async def test_activate_jail_blocked_when_logpath_missing(self, tmp_path: Path) -> None: + """activate_jail returns active=False if _validate_jail_config_sync reports a missing logpath.""" + from app.models.config import ActivateJailRequest, JailValidationIssue, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + req = ActivateJailRequest() + missing_issue = JailValidationIssue(field="logpath", message="log file '/var/log/missing.log' not found") + validation = JailValidationResult(jail_name="apache-auth", valid=False, issues=[missing_issue]) + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=validation, + ), + patch("app.services.config_file_service.jail_service") as mock_js, + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req) + + assert result.active is False + assert result.fail2ban_running is True + assert "cannot be activated" in result.message + mock_js.reload_all.assert_not_awaited() + + async def test_activate_jail_blocked_when_filter_missing(self, tmp_path: Path) -> None: + """activate_jail returns active=False if a filter file is missing.""" + from app.models.config import ActivateJailRequest, JailValidationIssue, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + req = ActivateJailRequest() + filter_issue = JailValidationIssue(field="filter", message="filter file 'sshd.conf' not found") + validation = JailValidationResult(jail_name="sshd", valid=False, issues=[filter_issue]) + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=validation, + ), + patch("app.services.config_file_service.jail_service") as mock_js, + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail(str(tmp_path), "/fake.sock", "sshd", req) + + assert result.active is False + assert result.fail2ban_running is True + assert "cannot be activated" in result.message + mock_js.reload_all.assert_not_awaited() + + async def test_activate_jail_proceeds_when_only_regex_warnings(self, tmp_path: Path) -> None: + """activate_jail proceeds normally when only non-blocking (failregex) warnings exist.""" + from app.models.config import ActivateJailRequest, JailValidationIssue, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + req = ActivateJailRequest() + advisory_issue = JailValidationIssue(field="failregex", message="no failregex defined") + # valid=True but with a non-blocking advisory issue + validation = JailValidationResult(jail_name="apache-auth", valid=True, issues=[advisory_issue]) + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(side_effect=[set(), {"apache-auth"}]), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=validation, + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req) + + assert result.active is True + mock_js.reload_all.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# activate_jail — rollback on failure (Task 2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestActivateJailRollback: + """Rollback logic in activate_jail restores the .local file and recovers.""" + + async def test_activate_jail_rollback_on_reload_failure( + self, tmp_path: Path + ) -> None: + """Rollback when reload_all raises on the activation reload. + + Expects: + - The .local file is restored to its original content. + - The response indicates recovered=True. + """ + from app.models.config import ActivateJailRequest, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + original_local = "[apache-auth]\nenabled = false\n" + local_path = tmp_path / "jail.d" / "apache-auth.local" + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_text(original_local) + + req = ActivateJailRequest() + reload_call_count = 0 + + async def reload_side_effect(socket_path: str, **kwargs: object) -> None: + nonlocal reload_call_count + reload_call_count += 1 + if reload_call_count == 1: + raise RuntimeError("fail2ban crashed") + # Recovery reload succeeds. + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult( + jail_name="apache-auth", valid=True + ), + ), + ): + mock_js.reload_all = AsyncMock(side_effect=reload_side_effect) + result = await activate_jail( + str(tmp_path), "/fake.sock", "apache-auth", req + ) + + assert result.active is False + assert result.recovered is True + assert local_path.read_text() == original_local + + async def test_activate_jail_rollback_on_health_check_failure( + self, tmp_path: Path + ) -> None: + """Rollback when fail2ban is unreachable after the activation reload. + + Expects: + - The .local file is restored to its original content. + - The response indicates recovered=True. + """ + from app.models.config import ActivateJailRequest, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + original_local = "[apache-auth]\nenabled = false\n" + local_path = tmp_path / "jail.d" / "apache-auth.local" + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_text(original_local) + + req = ActivateJailRequest() + probe_call_count = 0 + + async def probe_side_effect(socket_path: str) -> bool: + nonlocal probe_call_count + probe_call_count += 1 + # First _POST_RELOAD_MAX_ATTEMPTS probes (health-check after + # activation) all fail; subsequent probes (recovery) succeed. + from app.services.config_file_service import _POST_RELOAD_MAX_ATTEMPTS + + return probe_call_count > _POST_RELOAD_MAX_ATTEMPTS + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(side_effect=probe_side_effect), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult( + jail_name="apache-auth", valid=True + ), + ), + ): + mock_js.reload_all = AsyncMock() + result = await activate_jail( + str(tmp_path), "/fake.sock", "apache-auth", req + ) + + assert result.active is False + assert result.recovered is True + assert local_path.read_text() == original_local + + async def test_activate_jail_rollback_failure(self, tmp_path: Path) -> None: + """recovered=False when both the activation and recovery reloads fail. + + Expects: + - The response indicates recovered=False. + """ + from app.models.config import ActivateJailRequest, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + original_local = "[apache-auth]\nenabled = false\n" + local_path = tmp_path / "jail.d" / "apache-auth.local" + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_text(original_local) + + req = ActivateJailRequest() + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult( + jail_name="apache-auth", valid=True + ), + ), + ): + # Both the activation reload and the recovery reload fail. + mock_js.reload_all = AsyncMock( + side_effect=RuntimeError("fail2ban unavailable") + ) + result = await activate_jail( + str(tmp_path), "/fake.sock", "apache-auth", req + ) + + assert result.active is False + assert result.recovered is False + + diff --git a/backend/tests/test_services/test_config_service.py b/backend/tests/test_services/test_config_service.py new file mode 100644 index 0000000..6b90074 --- /dev/null +++ b/backend/tests/test_services/test_config_service.py @@ -0,0 +1,748 @@ +"""Tests for config_service functions.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from app.models.config import ( + GlobalConfigUpdate, + JailConfigListResponse, + JailConfigResponse, + LogPreviewRequest, + RegexTestRequest, +) +from app.services import config_service +from app.services.config_service import ( + ConfigValidationError, + JailNotFoundError, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SOCKET = "/fake/fail2ban.sock" + + +def _make_global_status(names: str = "sshd") -> tuple[int, list[Any]]: + return (0, [("Number of jail", 1), ("Jail list", names)]) + + +def _make_short_status() -> tuple[int, list[Any]]: + return ( + 0, + [ + ("Filter", [("Currently failed", 3), ("Total failed", 20)]), + ("Actions", [("Currently banned", 2), ("Total banned", 10)]), + ], + ) + + +def _make_send(responses: dict[str, Any]) -> AsyncMock: + async def _side_effect(command: list[Any]) -> Any: + key = "|".join(str(c) for c in command) + if key in responses: + return responses[key] + for resp_key, resp_value in responses.items(): + if key.startswith(resp_key): + return resp_value + return (0, None) + + return AsyncMock(side_effect=_side_effect) + + +def _patch_client(responses: dict[str, Any]) -> Any: + mock_send = _make_send(responses) + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = mock_send + + return patch("app.services.config_service.Fail2BanClient", _FakeClient) + + +_DEFAULT_JAIL_RESPONSES: dict[str, Any] = { + "status|sshd|short": _make_short_status(), + "get|sshd|bantime": (0, 600), + "get|sshd|findtime": (0, 600), + "get|sshd|maxretry": (0, 5), + "get|sshd|failregex": (0, ["regex1", "regex2"]), + "get|sshd|ignoreregex": (0, []), + "get|sshd|logpath": (0, ["/var/log/auth.log"]), + "get|sshd|datepattern": (0, None), + "get|sshd|logencoding": (0, "UTF-8"), + "get|sshd|backend": (0, "polling"), + "get|sshd|usedns": (0, "warn"), + "get|sshd|prefregex": (0, ""), + "get|sshd|actions": (0, ["iptables"]), +} + + +# --------------------------------------------------------------------------- +# get_jail_config +# --------------------------------------------------------------------------- + + +class TestGetJailConfig: + """Unit tests for :func:`~app.services.config_service.get_jail_config`.""" + + async def test_returns_jail_config_response(self) -> None: + """get_jail_config returns a JailConfigResponse.""" + with _patch_client(_DEFAULT_JAIL_RESPONSES): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert isinstance(result, JailConfigResponse) + assert result.jail.name == "sshd" + assert result.jail.ban_time == 600 + assert result.jail.max_retry == 5 + assert result.jail.fail_regex == ["regex1", "regex2"] + assert result.jail.log_paths == ["/var/log/auth.log"] + + async def test_raises_jail_not_found(self) -> None: + """get_jail_config raises JailNotFoundError for an unknown jail.""" + + async def _send(command: list[Any]) -> Any: + raise Exception("Unknown jail 'missing'") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + # Patch the client to raise on status command. + async def _faulty_send(command: list[Any]) -> Any: + if command[0] == "status": + return (1, "unknown jail 'missing'") + return (0, None) + + with patch( + "app.services.config_service.Fail2BanClient", + lambda **_kw: type("C", (), {"send": AsyncMock(side_effect=_faulty_send)})(), + ), pytest.raises(JailNotFoundError): + await config_service.get_jail_config(_SOCKET, "missing") + + async def test_actions_parsed_correctly(self) -> None: + """get_jail_config includes actions list.""" + with _patch_client(_DEFAULT_JAIL_RESPONSES): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert "iptables" in result.jail.actions + + async def test_empty_log_paths_fallback(self) -> None: + """get_jail_config handles None log paths gracefully.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|logpath": (0, None)} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.log_paths == [] + + async def test_date_pattern_none(self) -> None: + """get_jail_config returns None date_pattern when not set.""" + with _patch_client(_DEFAULT_JAIL_RESPONSES): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.date_pattern is None + + async def test_use_dns_populated(self) -> None: + """get_jail_config returns use_dns from the socket response.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|usedns": (0, "no")} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.use_dns == "no" + + async def test_use_dns_default_when_missing(self) -> None: + """get_jail_config defaults use_dns to 'warn' when socket returns None.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|usedns": (0, None)} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.use_dns == "warn" + + async def test_prefregex_populated(self) -> None: + """get_jail_config returns prefregex from the socket response.""" + responses = { + **_DEFAULT_JAIL_RESPONSES, + "get|sshd|prefregex": (0, r"^%(__prefix_line)s"), + } + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.prefregex == r"^%(__prefix_line)s" + + async def test_prefregex_empty_when_missing(self) -> None: + """get_jail_config returns empty string prefregex when socket returns None.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|prefregex": (0, None)} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.prefregex == "" + + +# --------------------------------------------------------------------------- +# list_jail_configs +# --------------------------------------------------------------------------- + + +class TestListJailConfigs: + """Unit tests for :func:`~app.services.config_service.list_jail_configs`.""" + + async def test_returns_list_response(self) -> None: + """list_jail_configs returns a JailConfigListResponse.""" + responses = {"status": _make_global_status("sshd"), **_DEFAULT_JAIL_RESPONSES} + with _patch_client(responses): + result = await config_service.list_jail_configs(_SOCKET) + + assert isinstance(result, JailConfigListResponse) + assert result.total == 1 + assert result.jails[0].name == "sshd" + + async def test_empty_when_no_jails(self) -> None: + """list_jail_configs returns empty list when no jails are active.""" + responses = {"status": (0, [("Jail list", ""), ("Number of jail", 0)])} + with _patch_client(responses): + result = await config_service.list_jail_configs(_SOCKET) + + assert result.total == 0 + assert result.jails == [] + + async def test_multiple_jails(self) -> None: + """list_jail_configs handles comma-separated jail names.""" + nginx_responses = { + k.replace("sshd", "nginx"): v for k, v in _DEFAULT_JAIL_RESPONSES.items() + } + responses = { + "status": _make_global_status("sshd, nginx"), + **_DEFAULT_JAIL_RESPONSES, + **nginx_responses, + } + with _patch_client(responses): + result = await config_service.list_jail_configs(_SOCKET) + + assert result.total == 2 + names = {j.name for j in result.jails} + assert names == {"sshd", "nginx"} + + +# --------------------------------------------------------------------------- +# update_jail_config +# --------------------------------------------------------------------------- + + +class TestUpdateJailConfig: + """Unit tests for :func:`~app.services.config_service.update_jail_config`.""" + + async def test_updates_numeric_fields(self) -> None: + """update_jail_config sends set commands for numeric fields.""" + 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(ban_time=3600, max_retry=10) + 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 "bantime" in keys + assert "maxretry" in keys + + async def test_raises_validation_error_on_bad_regex(self) -> None: + """update_jail_config raises ConfigValidationError for invalid regex.""" + from app.models.config import JailConfigUpdate + + update = JailConfigUpdate(fail_regex=["[invalid"]) + with pytest.raises(ConfigValidationError, match="Invalid regex"): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + async def test_skips_none_fields(self) -> None: + """update_jail_config does not send commands for None fields.""" + 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(ban_time=None, max_retry=None, find_time=None) + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + set_commands = [cmd for cmd in sent_commands if len(cmd) >= 3 and cmd[0] == "set"] + assert set_commands == [] + + async def test_replaces_fail_regex(self) -> None: + """update_jail_config deletes old regexes and adds new ones.""" + sent_commands: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent_commands.append(command) + if command[0] == "get": + return (0, ["old_pattern"]) + 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(fail_regex=["new_pattern"]) + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + add_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "addfailregex"), + None, + ) + assert add_cmd is not None + assert add_cmd[3] == "new_pattern" + + async def test_sets_dns_mode(self) -> None: + """update_jail_config sends 'set usedns' for dns_mode.""" + from app.models.config import JailConfigUpdate + + 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) + + update = JailConfigUpdate(dns_mode="no") + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + usedns_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "usedns"), + None, + ) + assert usedns_cmd is not None + assert usedns_cmd[3] == "no" + + async def test_sets_prefregex(self) -> None: + """update_jail_config sends 'set prefregex' for prefregex.""" + from app.models.config import JailConfigUpdate + + 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) + + update = JailConfigUpdate(prefregex=r"^%(__prefix_line)s") + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + prefregex_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "prefregex"), + None, + ) + assert prefregex_cmd is not None + assert prefregex_cmd[3] == r"^%(__prefix_line)s" + + async def test_skips_none_prefregex(self) -> None: + """update_jail_config does not send prefregex command when field is None.""" + from app.models.config import JailConfigUpdate + + 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) + + update = JailConfigUpdate(prefregex=None) + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + prefregex_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "prefregex"), + None, + ) + assert prefregex_cmd is None + + async def test_raises_validation_error_on_invalid_prefregex(self) -> None: + """update_jail_config raises ConfigValidationError for an invalid prefregex.""" + from app.models.config import JailConfigUpdate + + update = JailConfigUpdate(prefregex="[invalid") + with pytest.raises(ConfigValidationError, match="prefregex"): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + +# --------------------------------------------------------------------------- +# get_global_config +# --------------------------------------------------------------------------- + + +class TestGetGlobalConfig: + """Unit tests for :func:`~app.services.config_service.get_global_config`.""" + + async def test_returns_global_config(self) -> None: + """get_global_config returns parsed GlobalConfigResponse.""" + responses = { + "get|loglevel": (0, "WARNING"), + "get|logtarget": (0, "/var/log/fail2ban.log"), + "get|dbpurgeage": (0, 86400), + "get|dbmaxmatches": (0, 10), + } + with _patch_client(responses): + result = await config_service.get_global_config(_SOCKET) + + assert result.log_level == "WARNING" + assert result.log_target == "/var/log/fail2ban.log" + assert result.db_purge_age == 86400 + assert result.db_max_matches == 10 + + async def test_defaults_used_on_error(self) -> None: + """get_global_config uses fallback defaults when commands fail.""" + responses: dict[str, Any] = {} + with _patch_client(responses): + result = await config_service.get_global_config(_SOCKET) + + assert result.log_level is not None + assert result.log_target is not None + + +# --------------------------------------------------------------------------- +# update_global_config +# --------------------------------------------------------------------------- + + +class TestUpdateGlobalConfig: + """Unit tests for :func:`~app.services.config_service.update_global_config`.""" + + async def test_sends_set_commands(self) -> None: + """update_global_config sends set commands for non-None fields.""" + sent: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = GlobalConfigUpdate(log_level="debug", db_purge_age=3600) + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_global_config(_SOCKET, update) + + keys = [cmd[1] for cmd in sent if len(cmd) >= 3 and cmd[0] == "set"] + assert "loglevel" in keys + assert "dbpurgeage" in keys + + async def test_log_level_uppercased(self) -> None: + """update_global_config uppercases log_level before sending.""" + sent: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = GlobalConfigUpdate(log_level="debug") + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_global_config(_SOCKET, update) + + cmd = next(c for c in sent if len(c) >= 3 and c[1] == "loglevel") + assert cmd[2] == "DEBUG" + + +# --------------------------------------------------------------------------- +# test_regex (synchronous) +# --------------------------------------------------------------------------- + + +class TestTestRegex: + """Unit tests for :func:`~app.services.config_service.test_regex`.""" + + def test_matching_pattern(self) -> None: + """test_regex returns matched=True for a valid match.""" + req = RegexTestRequest( + log_line="Failed password for user from 1.2.3.4", + fail_regex=r"(?P\d+\.\d+\.\d+\.\d+)", + ) + result = config_service.test_regex(req) + + assert result.matched is True + assert "1.2.3.4" in result.groups + assert result.error is None + + def test_non_matching_pattern(self) -> None: + """test_regex returns matched=False when pattern does not match.""" + req = RegexTestRequest( + log_line="Normal log line here", + fail_regex=r"BANME", + ) + result = config_service.test_regex(req) + + assert result.matched is False + assert result.groups == [] + + def test_invalid_pattern_returns_error(self) -> None: + """test_regex returns error message for an invalid regex.""" + req = RegexTestRequest( + log_line="any line", + fail_regex=r"[invalid", + ) + result = config_service.test_regex(req) + + assert result.matched is False + assert result.error is not None + assert len(result.error) > 0 + + def test_empty_groups_when_no_capture(self) -> None: + """test_regex returns empty groups when pattern has no capture groups.""" + req = RegexTestRequest( + log_line="fail here", + fail_regex=r"fail", + ) + result = config_service.test_regex(req) + + assert result.matched is True + assert result.groups == [] + + def test_multiple_capture_groups(self) -> None: + """test_regex returns all captured groups.""" + req = RegexTestRequest( + log_line="user=root ip=1.2.3.4", + fail_regex=r"user=(\w+) ip=([\d.]+)", + ) + result = config_service.test_regex(req) + + assert result.matched is True + assert len(result.groups) == 2 + + +# --------------------------------------------------------------------------- +# preview_log +# --------------------------------------------------------------------------- + + +class TestPreviewLog: + """Unit tests for :func:`~app.services.config_service.preview_log`.""" + + async def test_returns_error_for_invalid_regex(self, tmp_path: Any) -> None: + """preview_log returns regex_error for an invalid pattern.""" + req = LogPreviewRequest(log_path=str(tmp_path / "fake.log"), fail_regex="[bad") + result = await config_service.preview_log(req) + + assert result.regex_error is not None + assert result.total_lines == 0 + + async def test_returns_error_for_missing_file(self) -> None: + """preview_log returns regex_error when file does not exist.""" + req = LogPreviewRequest( + log_path="/nonexistent/path/log.txt", + fail_regex=r"test", + ) + result = await config_service.preview_log(req) + + assert result.regex_error is not None + + async def test_matches_lines_in_file(self, tmp_path: Any) -> None: + """preview_log correctly identifies matching and non-matching lines.""" + log_file = tmp_path / "test.log" + log_file.write_text("FAIL login from 1.2.3.4\nOK normal line\nFAIL from 5.6.7.8\n") + + req = LogPreviewRequest(log_path=str(log_file), fail_regex=r"FAIL") + result = await config_service.preview_log(req) + + assert result.total_lines == 3 + assert result.matched_count == 2 + + async def test_matched_line_has_groups(self, tmp_path: Any) -> None: + """preview_log captures regex groups in matched lines.""" + log_file = tmp_path / "test.log" + log_file.write_text("error from 1.2.3.4 port 22\n") + + req = LogPreviewRequest( + log_path=str(log_file), + fail_regex=r"from (\d+\.\d+\.\d+\.\d+)", + ) + result = await config_service.preview_log(req) + + matched = [ln for ln in result.lines if ln.matched] + assert len(matched) == 1 + assert "1.2.3.4" in matched[0].groups + + async def test_num_lines_limit(self, tmp_path: Any) -> None: + """preview_log respects the num_lines limit.""" + log_file = tmp_path / "big.log" + log_file.write_text("\n".join(f"line {i}" for i in range(500)) + "\n") + + req = LogPreviewRequest(log_path=str(log_file), fail_regex=r"line", num_lines=50) + result = await config_service.preview_log(req) + + assert result.total_lines <= 50 + + +# --------------------------------------------------------------------------- +# read_fail2ban_log +# --------------------------------------------------------------------------- + + +class TestReadFail2BanLog: + """Tests for :func:`config_service.read_fail2ban_log`.""" + + def _patch_client(self, log_level: str = "INFO", log_target: str = "/var/log/fail2ban.log") -> Any: + """Build a patched Fail2BanClient that returns *log_level* and *log_target*.""" + async def _send(command: list[Any]) -> Any: + key = "|".join(str(c) for c in command) + if key == "get|loglevel": + return (0, log_level) + if key == "get|logtarget": + return (0, log_target) + return (0, None) + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + return patch("app.services.config_service.Fail2BanClient", _FakeClient) + + async def test_returns_log_lines_from_file(self, tmp_path: Any) -> None: + """read_fail2ban_log returns lines from the file and counts totals.""" + log_file = tmp_path / "fail2ban.log" + log_file.write_text("line1\nline2\nline3\n") + log_dir = str(tmp_path) + + # Patch _SAFE_LOG_PREFIXES to allow tmp_path + with self._patch_client(log_target=str(log_file)), \ + patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)): + result = await config_service.read_fail2ban_log(_SOCKET, 200) + + assert result.log_path == str(log_file.resolve()) + assert result.total_lines >= 3 + assert any("line1" in ln for ln in result.lines) + assert result.log_level == "INFO" + + async def test_filter_narrows_returned_lines(self, tmp_path: Any) -> None: + """read_fail2ban_log filters lines by substring.""" + log_file = tmp_path / "fail2ban.log" + log_file.write_text("INFO sshd Found 1.2.3.4\nERROR something else\nINFO sshd Found 5.6.7.8\n") + log_dir = str(tmp_path) + + with self._patch_client(log_target=str(log_file)), \ + patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)): + result = await config_service.read_fail2ban_log(_SOCKET, 200, "Found") + + assert all("Found" in ln for ln in result.lines) + assert result.total_lines >= 3 # total is unfiltered + + async def test_non_file_target_raises_operation_error(self) -> None: + """read_fail2ban_log raises ConfigOperationError for STDOUT target.""" + with self._patch_client(log_target="STDOUT"), \ + pytest.raises(config_service.ConfigOperationError, match="STDOUT"): + await config_service.read_fail2ban_log(_SOCKET, 200) + + async def test_syslog_target_raises_operation_error(self) -> None: + """read_fail2ban_log raises ConfigOperationError for SYSLOG target.""" + with self._patch_client(log_target="SYSLOG"), \ + pytest.raises(config_service.ConfigOperationError, match="SYSLOG"): + await config_service.read_fail2ban_log(_SOCKET, 200) + + async def test_path_outside_safe_dir_raises_operation_error(self, tmp_path: Any) -> None: + """read_fail2ban_log rejects a log_target outside allowed directories.""" + log_file = tmp_path / "secret.log" + log_file.write_text("secret data\n") + + # Allow only /var/log — tmp_path is deliberately not in the safe list. + with self._patch_client(log_target=str(log_file)), \ + patch("app.services.config_service._SAFE_LOG_PREFIXES", ("/var/log",)), \ + pytest.raises(config_service.ConfigOperationError, match="outside the allowed"): + await config_service.read_fail2ban_log(_SOCKET, 200) + + async def test_missing_log_file_raises_operation_error(self, tmp_path: Any) -> None: + """read_fail2ban_log raises ConfigOperationError when the file does not exist.""" + missing = str(tmp_path / "nonexistent.log") + log_dir = str(tmp_path) + + with self._patch_client(log_target=missing), \ + patch("app.services.config_service._SAFE_LOG_PREFIXES", (log_dir,)), \ + pytest.raises(config_service.ConfigOperationError, match="not found"): + await config_service.read_fail2ban_log(_SOCKET, 200) + + +# --------------------------------------------------------------------------- +# get_service_status +# --------------------------------------------------------------------------- + + +class TestGetServiceStatus: + """Tests for :func:`config_service.get_service_status`.""" + + async def test_online_status_includes_log_config(self) -> None: + """get_service_status returns correct fields when fail2ban is online.""" + from app.models.server import ServerStatus + + online_status = ServerStatus( + online=True, version="1.0.0", active_jails=2, total_bans=5, total_failures=3 + ) + + async def _send(command: list[Any]) -> Any: + key = "|".join(str(c) for c in command) + if key == "get|loglevel": + return (0, "DEBUG") + if key == "get|logtarget": + return (0, "/var/log/fail2ban.log") + return (0, None) + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + with patch("app.services.config_service.Fail2BanClient", _FakeClient), \ + patch("app.services.health_service.probe", AsyncMock(return_value=online_status)): + result = await config_service.get_service_status(_SOCKET) + + assert result.online is True + assert result.version == "1.0.0" + assert result.jail_count == 2 + assert result.total_bans == 5 + assert result.total_failures == 3 + assert result.log_level == "DEBUG" + assert result.log_target == "/var/log/fail2ban.log" + + async def test_offline_status_returns_unknown_log_fields(self) -> None: + """get_service_status returns 'UNKNOWN' log fields when fail2ban is offline.""" + from app.models.server import ServerStatus + + offline_status = ServerStatus(online=False) + + with patch("app.services.health_service.probe", AsyncMock(return_value=offline_status)): + result = await config_service.get_service_status(_SOCKET) + + assert result.online is False + assert result.jail_count == 0 + assert result.log_level == "UNKNOWN" + assert result.log_target == "UNKNOWN" diff --git a/backend/tests/test_services/test_fail2ban_client.py b/backend/tests/test_services/test_fail2ban_client.py new file mode 100644 index 0000000..8e344ec --- /dev/null +++ b/backend/tests/test_services/test_fail2ban_client.py @@ -0,0 +1,524 @@ +"""Tests for app.utils.fail2ban_client.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.utils.fail2ban_client import ( + _PROTO_END, + Fail2BanClient, + Fail2BanConnectionError, + Fail2BanProtocolError, + _coerce_command_token, + _send_command_sync, +) + + +class TestFail2BanClientPing: + """Tests for :meth:`Fail2BanClient.ping`.""" + + @pytest.mark.asyncio + async def test_ping_returns_true_when_daemon_responds(self) -> None: + """``ping()`` must return ``True`` when fail2ban responds with 1.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + with patch.object(client, "send", new_callable=AsyncMock, return_value=1): + result = await client.ping() + assert result is True + + @pytest.mark.asyncio + async def test_ping_returns_false_on_connection_error(self) -> None: + """``ping()`` must return ``False`` when the daemon is unreachable.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + with patch.object( + client, + "send", + new_callable=AsyncMock, + side_effect=Fail2BanConnectionError("refused", "/fake/fail2ban.sock"), + ): + result = await client.ping() + assert result is False + + @pytest.mark.asyncio + async def test_ping_returns_false_on_protocol_error(self) -> None: + """``ping()`` must return ``False`` if the response cannot be parsed.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + with patch.object( + client, + "send", + new_callable=AsyncMock, + side_effect=Fail2BanProtocolError("bad pickle"), + ): + result = await client.ping() + assert result is False + + +class TestFail2BanClientContextManager: + """Tests for the async context manager protocol.""" + + @pytest.mark.asyncio + async def test_context_manager_returns_self(self) -> None: + """``async with Fail2BanClient(...)`` must yield the client itself.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + async with client as ctx: + assert ctx is client + + +class TestSendCommandSync: + """Tests for the synchronous :func:`_send_command_sync` helper.""" + + def test_send_command_sync_raises_connection_error_when_socket_absent(self) -> None: + """Must raise :class:`Fail2BanConnectionError` if the socket does not exist.""" + with pytest.raises(Fail2BanConnectionError): + _send_command_sync( + socket_path="/nonexistent/fail2ban.sock", + command=["ping"], + timeout=1.0, + ) + + def test_send_command_sync_raises_connection_error_on_oserror(self) -> None: + """Must translate :class:`OSError` into :class:`Fail2BanConnectionError`.""" + with patch("socket.socket") as mock_socket_cls: + mock_sock = MagicMock() + mock_sock.connect.side_effect = OSError("connection refused") + mock_socket_cls.return_value = mock_sock + with pytest.raises(Fail2BanConnectionError): + _send_command_sync( + socket_path="/fake/fail2ban.sock", + command=["status"], + timeout=1.0, + ) + + +class TestSendCommandSyncProtocol: + """Tests for edge cases in the receive-loop and unpickling logic.""" + + def _make_connected_sock(self) -> MagicMock: + """Return a minimal mock socket that reports a successful connect. + + Returns: + A :class:`unittest.mock.MagicMock` that mimics a socket. + """ + mock_sock = MagicMock() + mock_sock.connect.return_value = None + return mock_sock + + def test_send_command_sync_raises_connection_error_on_empty_chunk(self) -> None: + """Must raise :class:`Fail2BanConnectionError` when the server closes mid-stream.""" + mock_sock = self._make_connected_sock() + # First recv returns empty bytes → server closed the connection. + mock_sock.recv.return_value = b"" + + with ( + patch("socket.socket", return_value=mock_sock), + pytest.raises(Fail2BanConnectionError, match="closed unexpectedly"), + ): + _send_command_sync( + socket_path="/fake/fail2ban.sock", + command=["ping"], + timeout=1.0, + ) + + def test_send_command_sync_raises_protocol_error_on_bad_pickle(self) -> None: + """Must raise :class:`Fail2BanProtocolError` when the response is not valid pickle.""" + mock_sock = self._make_connected_sock() + # Return the end marker directly so the recv-loop terminates immediately, + # but prepend garbage bytes so ``loads`` fails. + mock_sock.recv.side_effect = [ + _PROTO_END, # first call — exits the receive loop + ] + + # Patch loads to raise to simulate a corrupted response. + with ( + patch("socket.socket", return_value=mock_sock), + patch("app.utils.fail2ban_client.loads", side_effect=Exception("bad pickle")), + pytest.raises(Fail2BanProtocolError, match="Failed to unpickle"), + ): + _send_command_sync( + socket_path="/fake/fail2ban.sock", + command=["status"], + timeout=1.0, + ) + + def test_send_command_sync_returns_parsed_response(self) -> None: + """Must return the Python object that was pickled by fail2ban.""" + expected_response = [0, ["sshd", "nginx"]] + mock_sock = self._make_connected_sock() + # Return the proto end-marker so the recv-loop exits, then parse the raw bytes. + mock_sock.recv.return_value = _PROTO_END + + with ( + patch("socket.socket", return_value=mock_sock), + patch("app.utils.fail2ban_client.loads", return_value=expected_response), + ): + result = _send_command_sync( + socket_path="/fake/fail2ban.sock", + command=["status"], + timeout=1.0, + ) + + assert result == expected_response + + +# --------------------------------------------------------------------------- +# Tests for _coerce_command_token +# --------------------------------------------------------------------------- + + +class TestCoerceCommandToken: + """Tests for :func:`~app.utils.fail2ban_client._coerce_command_token`.""" + + def test_coerce_str_unchanged(self) -> None: + """``str`` tokens must pass through unchanged.""" + assert _coerce_command_token("sshd") == "sshd" + + def test_coerce_bool_unchanged(self) -> None: + """``bool`` tokens must pass through unchanged.""" + assert _coerce_command_token(True) is True # noqa: FBT003 + + def test_coerce_int_unchanged(self) -> None: + """``int`` tokens must pass through unchanged.""" + assert _coerce_command_token(42) == 42 + + def test_coerce_float_unchanged(self) -> None: + """``float`` tokens must pass through unchanged.""" + assert _coerce_command_token(1.5) == 1.5 + + def test_coerce_list_unchanged(self) -> None: + """``list`` tokens must pass through unchanged.""" + token: list[int] = [1, 2] + assert _coerce_command_token(token) is token + + def test_coerce_dict_unchanged(self) -> None: + """``dict`` tokens must pass through unchanged.""" + token: dict[str, str] = {"key": "value"} + assert _coerce_command_token(token) is token + + def test_coerce_set_unchanged(self) -> None: + """``set`` tokens must pass through unchanged.""" + token: set[str] = {"a", "b"} + assert _coerce_command_token(token) is token + + def test_coerce_unknown_type_stringified(self) -> None: + """Any other type must be converted to its ``str()`` representation.""" + + class CustomObj: + def __str__(self) -> str: + return "custom_repr" + + assert _coerce_command_token(CustomObj()) == "custom_repr" + + def test_coerce_none_stringified(self) -> None: + """``None`` must be stringified to ``"None"``.""" + assert _coerce_command_token(None) == "None" + + +# --------------------------------------------------------------------------- +# Extended tests for Fail2BanClient.send +# --------------------------------------------------------------------------- + + +class TestFail2BanClientSend: + """Tests for :meth:`Fail2BanClient.send`.""" + + @pytest.mark.asyncio + async def test_send_returns_response_on_success(self) -> None: + """``send()`` must return the response from the executor.""" + expected = [0, "OK"] + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + # asyncio.get_event_loop().run_in_executor is called inside send(). + # We patch it on the loop object returned by asyncio.get_event_loop(). + with patch("asyncio.get_event_loop") as mock_get_loop: + mock_loop = AsyncMock() + mock_loop.run_in_executor = AsyncMock(return_value=expected) + mock_get_loop.return_value = mock_loop + + result = await client.send(["status"]) + + assert result == expected + + @pytest.mark.asyncio + async def test_send_reraises_connection_error(self) -> None: + """``send()`` must re-raise :class:`Fail2BanConnectionError`.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + + with patch("asyncio.get_event_loop") as mock_get_loop: + mock_loop = AsyncMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=Fail2BanConnectionError("unreachable", "/fake/fail2ban.sock") + ) + mock_get_loop.return_value = mock_loop + + with pytest.raises(Fail2BanConnectionError): + await client.send(["status"]) + + @pytest.mark.asyncio + async def test_send_logs_warning_on_connection_error(self) -> None: + """``send()`` must log a warning when a connection error occurs.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + + with patch("asyncio.get_event_loop") as mock_get_loop: + mock_loop = AsyncMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=Fail2BanConnectionError("refused", "/fake/fail2ban.sock") + ) + mock_get_loop.return_value = mock_loop + + with patch("app.utils.fail2ban_client.log") as mock_log, pytest.raises(Fail2BanConnectionError): + await client.send(["ping"]) + + warning_calls = [ + c for c in mock_log.warning.call_args_list + if c[0][0] == "fail2ban_connection_error" + ] + assert len(warning_calls) == 1 + + @pytest.mark.asyncio + async def test_send_reraises_protocol_error(self) -> None: + """``send()`` must re-raise :class:`Fail2BanProtocolError`.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + + with patch("asyncio.get_event_loop") as mock_get_loop: + mock_loop = AsyncMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=Fail2BanProtocolError("bad pickle") + ) + mock_get_loop.return_value = mock_loop + + with pytest.raises(Fail2BanProtocolError): + await client.send(["status"]) + + @pytest.mark.asyncio + async def test_send_raises_on_protocol_error(self) -> None: + """``send()`` must propagate :class:`Fail2BanProtocolError` to the caller.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + + with patch("asyncio.get_event_loop") as mock_get_loop: + mock_loop = AsyncMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=Fail2BanProtocolError("bad pickle") + ) + mock_get_loop.return_value = mock_loop + + with pytest.raises(Fail2BanProtocolError): + await client.send(["status"]) + + @pytest.mark.asyncio + async def test_send_logs_error_on_protocol_error(self) -> None: + """``send()`` must log an error when a protocol error occurs.""" + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + + with patch("asyncio.get_event_loop") as mock_get_loop: + mock_loop = AsyncMock() + mock_loop.run_in_executor = AsyncMock( + side_effect=Fail2BanProtocolError("corrupt response") + ) + mock_get_loop.return_value = mock_loop + + with patch("app.utils.fail2ban_client.log") as mock_log, pytest.raises(Fail2BanProtocolError): + await client.send(["get", "sshd", "banned"]) + + error_calls = [ + c for c in mock_log.error.call_args_list + if c[0][0] == "fail2ban_protocol_error" + ] + assert len(error_calls) == 1 + + +# --------------------------------------------------------------------------- +# Tests for _send_command_sync retry logic (Stage 6.1 / 6.3) +# --------------------------------------------------------------------------- + + +class TestSendCommandSyncRetry: + """Tests for the retry-on-transient-OSError logic in :func:`_send_command_sync`.""" + + def _make_sock(self) -> MagicMock: + """Return a mock socket that connects without error.""" + mock_sock = MagicMock() + mock_sock.connect.return_value = None + return mock_sock + + def _eagain(self) -> OSError: + """Return an ``OSError`` with ``errno.EAGAIN``.""" + import errno as _errno + + err = OSError("Resource temporarily unavailable") + err.errno = _errno.EAGAIN + return err + + def _enoent(self) -> OSError: + """Return an ``OSError`` with ``errno.ENOENT``.""" + import errno as _errno + + err = OSError("No such file or directory") + err.errno = _errno.ENOENT + return err + + def test_transient_eagain_retried_succeeds_on_second_attempt(self) -> None: + """A single EAGAIN on connect is retried; success on the second attempt.""" + from app.utils.fail2ban_client import _PROTO_END + + call_count = 0 + + def _connect_side_effect(sock_path: str) -> None: + nonlocal call_count + call_count += 1 + if call_count == 1: + raise self._eagain() + # Second attempt succeeds (no-op). + + mock_sock = self._make_sock() + mock_sock.connect.side_effect = _connect_side_effect + mock_sock.recv.return_value = _PROTO_END + expected = [0, "pong"] + + with ( + patch("socket.socket", return_value=mock_sock), + patch("app.utils.fail2ban_client.loads", return_value=expected), + patch("app.utils.fail2ban_client.time.sleep"), # suppress backoff delay + ): + result = _send_command_sync("/fake.sock", ["ping"], 1.0) + + assert result == expected + assert call_count == 2 + + def test_three_eagain_failures_raise_connection_error(self) -> None: + """Three consecutive EAGAIN failures must raise :class:`Fail2BanConnectionError`.""" + mock_sock = self._make_sock() + mock_sock.connect.side_effect = self._eagain() + + with ( + patch("socket.socket", return_value=mock_sock), + patch("app.utils.fail2ban_client.time.sleep"), + pytest.raises(Fail2BanConnectionError), + ): + _send_command_sync("/fake.sock", ["status"], 1.0) + + # connect() should have been called exactly _RETRY_MAX_ATTEMPTS times. + from app.utils.fail2ban_client import _RETRY_MAX_ATTEMPTS + + assert mock_sock.connect.call_count == _RETRY_MAX_ATTEMPTS + + def test_enoent_raises_immediately_without_retry(self) -> None: + """A non-retryable ``OSError`` (``ENOENT``) must be raised on the first attempt.""" + mock_sock = self._make_sock() + mock_sock.connect.side_effect = self._enoent() + + with ( + patch("socket.socket", return_value=mock_sock), + patch("app.utils.fail2ban_client.time.sleep") as mock_sleep, + pytest.raises(Fail2BanConnectionError), + ): + _send_command_sync("/fake.sock", ["status"], 1.0) + + # No back-off sleep should have been triggered. + mock_sleep.assert_not_called() + assert mock_sock.connect.call_count == 1 + + def test_protocol_error_never_retried(self) -> None: + """A :class:`Fail2BanProtocolError` must be re-raised immediately.""" + from app.utils.fail2ban_client import _PROTO_END + + mock_sock = self._make_sock() + mock_sock.recv.return_value = _PROTO_END + + with ( + patch("socket.socket", return_value=mock_sock), + patch( + "app.utils.fail2ban_client.loads", + side_effect=Exception("bad pickle"), + ), + patch("app.utils.fail2ban_client.time.sleep") as mock_sleep, + pytest.raises(Fail2BanProtocolError), + ): + _send_command_sync("/fake.sock", ["status"], 1.0) + + mock_sleep.assert_not_called() + + def test_retry_emits_structured_log_event(self) -> None: + """Each retry attempt logs a ``fail2ban_socket_retry`` warning.""" + mock_sock = self._make_sock() + mock_sock.connect.side_effect = self._eagain() + + with ( + patch("socket.socket", return_value=mock_sock), + patch("app.utils.fail2ban_client.time.sleep"), + patch("app.utils.fail2ban_client.log") as mock_log, + pytest.raises(Fail2BanConnectionError), + ): + _send_command_sync("/fake.sock", ["status"], 1.0) + + retry_calls = [ + c for c in mock_log.warning.call_args_list + if c[0][0] == "fail2ban_socket_retry" + ] + from app.utils.fail2ban_client import _RETRY_MAX_ATTEMPTS + + # One retry log per attempt except the last (which raises directly). + assert len(retry_calls) == _RETRY_MAX_ATTEMPTS - 1 + + +# --------------------------------------------------------------------------- +# Tests for Fail2BanClient semaphore (Stage 6.2 / 6.3) +# --------------------------------------------------------------------------- + + +class TestFail2BanClientSemaphore: + """Tests for the concurrency semaphore in :meth:`Fail2BanClient.send`.""" + + @pytest.mark.asyncio + async def test_semaphore_limits_concurrency(self) -> None: + """No more than _COMMAND_SEMAPHORE_CONCURRENCY commands overlap.""" + import asyncio as _asyncio + + import app.utils.fail2ban_client as _module + + # Reset module-level semaphore so this test starts fresh. + _module._command_semaphore = None + + concurrency_limit = 3 + _module._COMMAND_SEMAPHORE_CONCURRENCY = concurrency_limit + _module._command_semaphore = _asyncio.Semaphore(concurrency_limit) + + in_flight: list[int] = [] + peak_concurrent: list[int] = [] + + async def _slow_send(command: list[Any]) -> Any: + in_flight.append(1) + peak_concurrent.append(len(in_flight)) + await _asyncio.sleep(0) # yield to allow other coroutines to run + in_flight.pop() + return (0, "ok") + + client = Fail2BanClient(socket_path="/fake/fail2ban.sock") + with patch.object(client, "send", wraps=_slow_send) as _patched: + # Bypass the semaphore wrapper — test the actual send directly. + pass + + # Override _command_semaphore and run concurrently via the real send path + # but mock _send_command_sync to avoid actual socket I/O. + async def _fast_executor(_fn: Any, *_args: Any) -> Any: + in_flight.append(1) + peak_concurrent.append(len(in_flight)) + await _asyncio.sleep(0) + in_flight.pop() + return (0, "ok") + + client2 = Fail2BanClient(socket_path="/fake/fail2ban.sock") + with patch("asyncio.get_event_loop") as mock_loop_getter: + mock_loop = MagicMock() + mock_loop.run_in_executor = _fast_executor + mock_loop_getter.return_value = mock_loop + + tasks = [ + _asyncio.create_task(client2.send(["ping"])) for _ in range(10) + ] + await _asyncio.gather(*tasks) + + # Peak concurrent activity must never exceed the semaphore limit. + assert max(peak_concurrent) <= concurrency_limit + + # Restore module defaults after test. + _module._COMMAND_SEMAPHORE_CONCURRENCY = 10 + _module._command_semaphore = None diff --git a/backend/tests/test_services/test_file_config_service.py b/backend/tests/test_services/test_file_config_service.py new file mode 100644 index 0000000..202b4b4 --- /dev/null +++ b/backend/tests/test_services/test_file_config_service.py @@ -0,0 +1,589 @@ +"""Tests for file_config_service functions.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.models.config import ActionConfigUpdate, FilterConfigUpdate, JailFileConfigUpdate +from app.models.file_config import ConfFileCreateRequest, ConfFileUpdateRequest +from app.services.file_config_service import ( + ConfigDirError, + ConfigFileExistsError, + ConfigFileNameError, + ConfigFileNotFoundError, + ConfigFileWriteError, + _parse_enabled, + _set_enabled_in_content, + _validate_new_name, + create_action_file, + create_filter_file, + create_jail_config_file, + get_action_file, + get_filter_file, + get_jail_config_file, + get_parsed_action_file, + get_parsed_filter_file, + get_parsed_jail_file, + list_action_files, + list_filter_files, + list_jail_config_files, + set_jail_config_enabled, + update_parsed_action_file, + update_parsed_filter_file, + update_parsed_jail_file, + write_action_file, + write_filter_file, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_config_dir(tmp_path: Path) -> Path: + """Create a minimal fail2ban config directory structure.""" + config_dir = tmp_path / "fail2ban" + (config_dir / "jail.d").mkdir(parents=True) + (config_dir / "filter.d").mkdir(parents=True) + (config_dir / "action.d").mkdir(parents=True) + return config_dir + + +# --------------------------------------------------------------------------- +# _parse_enabled +# --------------------------------------------------------------------------- + + +def test_parse_enabled_explicit_true(tmp_path: Path) -> None: + f = tmp_path / "sshd.conf" + f.write_text("[sshd]\nenabled = true\n") + assert _parse_enabled(f) is True + + +def test_parse_enabled_explicit_false(tmp_path: Path) -> None: + f = tmp_path / "sshd.conf" + f.write_text("[sshd]\nenabled = false\n") + assert _parse_enabled(f) is False + + +def test_parse_enabled_default_true_when_absent(tmp_path: Path) -> None: + f = tmp_path / "sshd.conf" + f.write_text("[sshd]\nbantime = 600\n") + assert _parse_enabled(f) is True + + +def test_parse_enabled_in_default_section(tmp_path: Path) -> None: + f = tmp_path / "custom.conf" + f.write_text("[DEFAULT]\nenabled = false\n") + assert _parse_enabled(f) is False + + +# --------------------------------------------------------------------------- +# _set_enabled_in_content +# --------------------------------------------------------------------------- + + +def test_set_enabled_replaces_existing_line() -> None: + src = "[sshd]\nenabled = false\nbantime = 600\n" + result = _set_enabled_in_content(src, True) + assert "enabled = true" in result + assert "enabled = false" not in result + + +def test_set_enabled_inserts_after_section() -> None: + src = "[sshd]\nbantime = 600\n" + result = _set_enabled_in_content(src, False) + assert "enabled = false" in result + + +def test_set_enabled_prepends_default_when_no_section() -> None: + result = _set_enabled_in_content("bantime = 600\n", True) + assert "enabled = true" in result + + +# --------------------------------------------------------------------------- +# _validate_new_name +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", ["sshd", "my-filter", "test.local", "A1_filter"]) +def test_validate_new_name_valid(name: str) -> None: + _validate_new_name(name) # should not raise + + +@pytest.mark.parametrize( + "name", + [ + "", + ".", + ".hidden", + "../escape", + "bad/slash", + "a" * 129, # too long + "hello world", # space + ], +) +def test_validate_new_name_invalid(name: str) -> None: + with pytest.raises(ConfigFileNameError): + _validate_new_name(name) + + +# --------------------------------------------------------------------------- +# list_jail_config_files +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_jail_config_files_empty(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + resp = await list_jail_config_files(str(config_dir)) + assert resp.files == [] + assert resp.total == 0 + + +@pytest.mark.asyncio +async def test_list_jail_config_files_returns_conf_files(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "jail.d" / "sshd.conf").write_text("[sshd]\nenabled = true\n") + (config_dir / "jail.d" / "nginx.conf").write_text("[nginx]\n") + (config_dir / "jail.d" / "other.txt").write_text("ignored") + + resp = await list_jail_config_files(str(config_dir)) + names = {f.filename for f in resp.files} + assert names == {"sshd.conf", "nginx.conf"} + assert resp.total == 2 + + +@pytest.mark.asyncio +async def test_list_jail_config_files_enabled_state(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "jail.d" / "a.conf").write_text("[a]\nenabled = false\n") + (config_dir / "jail.d" / "b.conf").write_text("[b]\n") + + resp = await list_jail_config_files(str(config_dir)) + by_name = {f.filename: f for f in resp.files} + assert by_name["a.conf"].enabled is False + assert by_name["b.conf"].enabled is True + + +@pytest.mark.asyncio +async def test_list_jail_config_files_missing_config_dir(tmp_path: Path) -> None: + with pytest.raises(ConfigDirError): + await list_jail_config_files(str(tmp_path / "nonexistent")) + + +# --------------------------------------------------------------------------- +# get_jail_config_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_jail_config_file_returns_content(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "jail.d" / "sshd.conf").write_text("[sshd]\nenabled = true\n") + + result = await get_jail_config_file(str(config_dir), "sshd.conf") + assert result.filename == "sshd.conf" + assert result.name == "sshd" + assert result.enabled is True + assert "[sshd]" in result.content + + +@pytest.mark.asyncio +async def test_get_jail_config_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises(ConfigFileNotFoundError): + await get_jail_config_file(str(config_dir), "missing.conf") + + +@pytest.mark.asyncio +async def test_get_jail_config_file_invalid_extension(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "jail.d" / "bad.txt").write_text("content") + with pytest.raises(ConfigFileNameError): + await get_jail_config_file(str(config_dir), "bad.txt") + + +@pytest.mark.asyncio +async def test_get_jail_config_file_path_traversal(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises((ConfigFileNameError, ConfigFileNotFoundError)): + await get_jail_config_file(str(config_dir), "../jail.conf") + + +# --------------------------------------------------------------------------- +# set_jail_config_enabled +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_jail_config_enabled_writes_false(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + path = config_dir / "jail.d" / "sshd.conf" + path.write_text("[sshd]\nenabled = true\n") + + await set_jail_config_enabled(str(config_dir), "sshd.conf", False) + assert "enabled = false" in path.read_text() + + +@pytest.mark.asyncio +async def test_set_jail_config_enabled_inserts_when_missing(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + path = config_dir / "jail.d" / "sshd.conf" + path.write_text("[sshd]\nbantime = 600\n") + + await set_jail_config_enabled(str(config_dir), "sshd.conf", False) + assert "enabled = false" in path.read_text() + + +@pytest.mark.asyncio +async def test_set_jail_config_enabled_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises(ConfigFileNotFoundError): + await set_jail_config_enabled(str(config_dir), "missing.conf", True) + + +# --------------------------------------------------------------------------- +# list_filter_files / list_action_files +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_filter_files_empty(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + resp = await list_filter_files(str(config_dir)) + assert resp.files == [] + + +@pytest.mark.asyncio +async def test_list_filter_files_returns_files(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "nginx.conf").write_text("[Definition]\n") + (config_dir / "filter.d" / "sshd.local").write_text("[Definition]\n") + (config_dir / "filter.d" / "ignore.py").write_text("# ignored") + + resp = await list_filter_files(str(config_dir)) + names = {f.filename for f in resp.files} + assert names == {"nginx.conf", "sshd.local"} + + +@pytest.mark.asyncio +async def test_list_action_files_returns_files(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "action.d" / "iptables.conf").write_text("[Definition]\n") + + resp = await list_action_files(str(config_dir)) + assert resp.files[0].filename == "iptables.conf" + + +# --------------------------------------------------------------------------- +# get_filter_file / get_action_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_filter_file_by_stem(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "nginx.conf").write_text("[Definition]\nfailregex = test\n") + + result = await get_filter_file(str(config_dir), "nginx") + assert result.name == "nginx" + assert "failregex" in result.content + + +@pytest.mark.asyncio +async def test_get_filter_file_by_full_name(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "nginx.conf").write_text("[Definition]\n") + + result = await get_filter_file(str(config_dir), "nginx.conf") + assert result.filename == "nginx.conf" + + +@pytest.mark.asyncio +async def test_get_filter_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises(ConfigFileNotFoundError): + await get_filter_file(str(config_dir), "nonexistent") + + +@pytest.mark.asyncio +async def test_get_action_file_returns_content(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "action.d" / "iptables.conf").write_text("[Definition]\nactionban = \n") + + result = await get_action_file(str(config_dir), "iptables") + assert "actionban" in result.content + + +# --------------------------------------------------------------------------- +# write_filter_file / write_action_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_write_filter_file_updates_content(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "nginx.conf").write_text("[Definition]\n") + + req = ConfFileUpdateRequest(content="[Definition]\nfailregex = new\n") + await write_filter_file(str(config_dir), "nginx", req) + + assert "failregex = new" in (config_dir / "filter.d" / "nginx.conf").read_text() + + +@pytest.mark.asyncio +async def test_write_filter_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + req = ConfFileUpdateRequest(content="[Definition]\n") + with pytest.raises(ConfigFileNotFoundError): + await write_filter_file(str(config_dir), "missing", req) + + +@pytest.mark.asyncio +async def test_write_filter_file_too_large(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "nginx.conf").write_text("[Definition]\n") + + big_content = "x" * (512 * 1024 + 1) + req = ConfFileUpdateRequest(content=big_content) + with pytest.raises(ConfigFileWriteError): + await write_filter_file(str(config_dir), "nginx", req) + + +@pytest.mark.asyncio +async def test_write_action_file_updates_content(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "action.d" / "iptables.conf").write_text("[Definition]\n") + + req = ConfFileUpdateRequest(content="[Definition]\nactionban = new\n") + await write_action_file(str(config_dir), "iptables", req) + + assert "actionban = new" in (config_dir / "action.d" / "iptables.conf").read_text() + + +# --------------------------------------------------------------------------- +# create_filter_file / create_action_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_filter_file_creates_file(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + req = ConfFileCreateRequest(name="myfilter", content="[Definition]\n") + + result = await create_filter_file(str(config_dir), req) + + assert result == "myfilter.conf" + assert (config_dir / "filter.d" / "myfilter.conf").is_file() + + +@pytest.mark.asyncio +async def test_create_filter_file_conflict(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "ngx.conf").write_text("[Definition]\n") + + req = ConfFileCreateRequest(name="ngx", content="[Definition]\n") + with pytest.raises(ConfigFileExistsError): + await create_filter_file(str(config_dir), req) + + +@pytest.mark.asyncio +async def test_create_filter_file_invalid_name(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + req = ConfFileCreateRequest(name="../escape", content="[Definition]\n") + with pytest.raises(ConfigFileNameError): + await create_filter_file(str(config_dir), req) + + +@pytest.mark.asyncio +async def test_create_action_file_creates_file(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + req = ConfFileCreateRequest(name="my-action", content="[Definition]\n") + + result = await create_action_file(str(config_dir), req) + + assert result == "my-action.conf" + assert (config_dir / "action.d" / "my-action.conf").is_file() + + +# --------------------------------------------------------------------------- +# create_jail_config_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_jail_config_file_creates_file(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + req = ConfFileCreateRequest(name="myjail", content="[myjail]\nenabled = true\n") + + result = await create_jail_config_file(str(config_dir), req) + + assert result == "myjail.conf" + assert (config_dir / "jail.d" / "myjail.conf").is_file() + + +@pytest.mark.asyncio +async def test_create_jail_config_file_conflict(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "jail.d" / "sshd.conf").write_text("[sshd]\nenabled = true\n") + + req = ConfFileCreateRequest(name="sshd", content="[sshd]\nenabled = true\n") + with pytest.raises(ConfigFileExistsError): + await create_jail_config_file(str(config_dir), req) + + +@pytest.mark.asyncio +async def test_create_jail_config_file_invalid_name(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + req = ConfFileCreateRequest(name="../escape", content="[x]\nenabled = true\n") + with pytest.raises(ConfigFileNameError): + await create_jail_config_file(str(config_dir), req) + + +# --------------------------------------------------------------------------- +# get_parsed_filter_file / update_parsed_filter_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_parsed_filter_file_returns_structured_model(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + content = "[Definition]\nfailregex = ^\nignoreregex = ^.*ignore.*$\n" + (config_dir / "filter.d" / "nginx.conf").write_text(content) + + result = await get_parsed_filter_file(str(config_dir), "nginx") + + assert result.name == "nginx" + assert result.filename == "nginx.conf" + assert result.failregex == ["^"] + assert result.ignoreregex == ["^.*ignore.*$"] + + +@pytest.mark.asyncio +async def test_get_parsed_filter_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises(ConfigFileNotFoundError): + await get_parsed_filter_file(str(config_dir), "missing") + + +@pytest.mark.asyncio +async def test_update_parsed_filter_file_writes_changes(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "filter.d" / "nginx.conf").write_text( + "[Definition]\nfailregex = ^ old\n" + ) + update = FilterConfigUpdate(failregex=["^ new"]) + + await update_parsed_filter_file(str(config_dir), "nginx", update) + + written = (config_dir / "filter.d" / "nginx.conf").read_text() + assert "new" in written + + +@pytest.mark.asyncio +async def test_update_parsed_filter_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + update = FilterConfigUpdate(failregex=["^"]) + with pytest.raises(ConfigFileNotFoundError): + await update_parsed_filter_file(str(config_dir), "missing", update) + + +# --------------------------------------------------------------------------- +# get_parsed_action_file / update_parsed_action_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_parsed_action_file_returns_structured_model(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + content = "[Definition]\nactionban = iptables -I INPUT -s -j DROP\n" + (config_dir / "action.d" / "iptables.conf").write_text(content) + + result = await get_parsed_action_file(str(config_dir), "iptables") + + assert result.name == "iptables" + assert result.filename == "iptables.conf" + assert result.actionban is not None + assert "" in result.actionban + + +@pytest.mark.asyncio +async def test_get_parsed_action_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises(ConfigFileNotFoundError): + await get_parsed_action_file(str(config_dir), "missing") + + +@pytest.mark.asyncio +async def test_update_parsed_action_file_writes_changes(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "action.d" / "iptables.conf").write_text( + "[Definition]\nactionban = iptables -I INPUT -s -j DROP\n" + ) + update = ActionConfigUpdate(actionban="nft add element inet f2b-table ") + + await update_parsed_action_file(str(config_dir), "iptables", update) + + written = (config_dir / "action.d" / "iptables.conf").read_text() + assert "nft" in written + + +@pytest.mark.asyncio +async def test_update_parsed_action_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + update = ActionConfigUpdate(actionban="iptables -I INPUT -s -j DROP") + with pytest.raises(ConfigFileNotFoundError): + await update_parsed_action_file(str(config_dir), "missing", update) + + +# --------------------------------------------------------------------------- +# get_parsed_jail_file / update_parsed_jail_file +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_parsed_jail_file_returns_structured_model(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + content = "[sshd]\nenabled = true\nport = ssh\nmaxretry = 5\n" + (config_dir / "jail.d" / "sshd.conf").write_text(content) + + result = await get_parsed_jail_file(str(config_dir), "sshd.conf") + + assert result.filename == "sshd.conf" + assert "sshd" in result.jails + assert result.jails["sshd"].enabled is True + assert result.jails["sshd"].maxretry == 5 + + +@pytest.mark.asyncio +async def test_get_parsed_jail_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + with pytest.raises(ConfigFileNotFoundError): + await get_parsed_jail_file(str(config_dir), "missing.conf") + + +@pytest.mark.asyncio +async def test_update_parsed_jail_file_writes_changes(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + (config_dir / "jail.d" / "sshd.conf").write_text( + "[sshd]\nenabled = true\nport = ssh\n" + ) + from app.models.config import JailSectionConfig + + update = JailFileConfigUpdate(jails={"sshd": JailSectionConfig(enabled=False)}) + + await update_parsed_jail_file(str(config_dir), "sshd.conf", update) + + written = (config_dir / "jail.d" / "sshd.conf").read_text() + assert "false" in written.lower() + + +@pytest.mark.asyncio +async def test_update_parsed_jail_file_not_found(tmp_path: Path) -> None: + config_dir = _make_config_dir(tmp_path) + update = JailFileConfigUpdate(jails={}) + with pytest.raises(ConfigFileNotFoundError): + await update_parsed_jail_file(str(config_dir), "missing.conf", update) diff --git a/backend/tests/test_services/test_geo_service.py b/backend/tests/test_services/test_geo_service.py new file mode 100644 index 0000000..f400059 --- /dev/null +++ b/backend/tests/test_services/test_geo_service.py @@ -0,0 +1,913 @@ +"""Tests for geo_service.lookup().""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services import geo_service +from app.services.geo_service import GeoInfo + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_session(response_json: dict[str, object], status: int = 200) -> MagicMock: + """Build a mock aiohttp.ClientSession that returns *response_json*. + + Args: + response_json: The dict that the mock response's ``json()`` returns. + status: HTTP status code for the mock response. + + Returns: + A :class:`MagicMock` that behaves like an + ``aiohttp.ClientSession`` in an ``async with`` context. + """ + mock_resp = AsyncMock() + mock_resp.status = status + mock_resp.json = AsyncMock(return_value=response_json) + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + session = MagicMock() + session.get = MagicMock(return_value=mock_ctx) + return session + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def clear_geo_cache() -> None: # type: ignore[misc] + """Flush the module-level geo cache before every test.""" + geo_service.clear_cache() + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestLookupSuccess: + """geo_service.lookup() under normal conditions.""" + + async def test_returns_country_code(self) -> None: + """country_code is populated from the ``countryCode`` field.""" + session = _make_session( + { + "status": "success", + "countryCode": "DE", + "country": "Germany", + "as": "AS3320 Deutsche Telekom AG", + "org": "AS3320 Deutsche Telekom AG", + } + ) + result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + + assert result is not None + assert result.country_code == "DE" + + async def test_returns_country_name(self) -> None: + """country_name is populated from the ``country`` field.""" + session = _make_session( + { + "status": "success", + "countryCode": "US", + "country": "United States", + "as": "AS15169 Google LLC", + "org": "Google LLC", + } + ) + result = await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type] + + assert result is not None + assert result.country_name == "United States" + + async def test_asn_extracted_without_org_suffix(self) -> None: + """The ASN field contains only the ``AS`` prefix, not the full string.""" + session = _make_session( + { + "status": "success", + "countryCode": "DE", + "country": "Germany", + "as": "AS3320 Deutsche Telekom AG", + "org": "Deutsche Telekom", + } + ) + result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + + assert result is not None + assert result.asn == "AS3320" + + async def test_org_populated(self) -> None: + """org field is populated from the ``org`` key.""" + session = _make_session( + { + "status": "success", + "countryCode": "US", + "country": "United States", + "as": "AS15169 Google LLC", + "org": "Google LLC", + } + ) + result = await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type] + + assert result is not None + assert result.org == "Google LLC" + + +# --------------------------------------------------------------------------- +# Cache behaviour +# --------------------------------------------------------------------------- + + +class TestLookupCaching: + """Verify that results are cached and the cache can be cleared.""" + + async def test_second_call_uses_cache(self) -> None: + """Subsequent lookups for the same IP do not make additional HTTP requests.""" + session = _make_session( + { + "status": "success", + "countryCode": "DE", + "country": "Germany", + "as": "AS3320 Deutsche Telekom AG", + "org": "Deutsche Telekom", + } + ) + + await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + + # The session.get() should only have been called once. + assert session.get.call_count == 1 + + async def test_clear_cache_forces_refetch(self) -> None: + """After clearing the cache a new HTTP request is made.""" + session = _make_session( + { + "status": "success", + "countryCode": "DE", + "country": "Germany", + "as": "AS3320", + "org": "Telekom", + } + ) + + await geo_service.lookup("2.3.4.5", session) # type: ignore[arg-type] + geo_service.clear_cache() + await geo_service.lookup("2.3.4.5", session) # type: ignore[arg-type] + + assert session.get.call_count == 2 + + async def test_negative_result_stored_in_neg_cache(self) -> None: + """A failed lookup is stored in the negative cache, so the second call is blocked.""" + session = _make_session( + {"status": "fail", "message": "reserved range"} + ) + + await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type] + await geo_service.lookup("192.168.1.1", session) # type: ignore[arg-type] + + # Second call is blocked by the negative cache — only one API hit. + assert session.get.call_count == 1 + + +# --------------------------------------------------------------------------- +# Failure modes +# --------------------------------------------------------------------------- + + +class TestLookupFailures: + """geo_service.lookup() when things go wrong.""" + + async def test_non_200_response_returns_null_geo_info(self) -> None: + """A 429 or 500 status returns GeoInfo with null fields (not None).""" + session = _make_session({}, status=429) + result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + assert result is not None + assert isinstance(result, GeoInfo) + assert result.country_code is None + + async def test_network_error_returns_null_geo_info(self) -> None: + """A network exception returns GeoInfo with null fields (not None).""" + session = MagicMock() + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused")) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + session.get = MagicMock(return_value=mock_ctx) + + result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type] + assert result is not None + assert isinstance(result, GeoInfo) + assert result.country_code is None + + async def test_failed_status_returns_geo_info_with_nulls(self) -> None: + """When ip-api returns ``status=fail`` a GeoInfo with null fields is returned (but not cached).""" + session = _make_session({"status": "fail", "message": "private range"}) + result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type] + + assert result is not None + assert isinstance(result, GeoInfo) + assert result.country_code is None + assert result.country_name is None + + +# --------------------------------------------------------------------------- +# Negative cache +# --------------------------------------------------------------------------- + + +class TestNegativeCache: + """Verify the negative cache throttles retries for failing IPs.""" + + async def test_neg_cache_blocks_second_lookup(self) -> None: + """After a failed lookup the second call is served from the neg cache.""" + session = _make_session({"status": "fail", "message": "private range"}) + + r1 = await geo_service.lookup("192.0.2.1", session) # type: ignore[arg-type] + r2 = await geo_service.lookup("192.0.2.1", session) # type: ignore[arg-type] + + # Only one HTTP call should have been made; second served from neg cache. + assert session.get.call_count == 1 + assert r1 is not None and r1.country_code is None + assert r2 is not None and r2.country_code is None + + async def test_neg_cache_retries_after_ttl(self) -> None: + """When the neg-cache entry is older than the TTL a new API call is made.""" + session = _make_session({"status": "fail", "message": "private range"}) + + await geo_service.lookup("192.0.2.2", session) # type: ignore[arg-type] + + # Manually expire the neg-cache entry. + geo_service._neg_cache["192.0.2.2"] -= geo_service._NEG_CACHE_TTL + 1 # type: ignore[attr-defined] + + await geo_service.lookup("192.0.2.2", session) # type: ignore[arg-type] + + # Both calls should have hit the API. + assert session.get.call_count == 2 + + async def test_clear_neg_cache_allows_immediate_retry(self) -> None: + """After clearing the neg cache the IP is eligible for a new API call.""" + session = _make_session({"status": "fail", "message": "private range"}) + + await geo_service.lookup("192.0.2.3", session) # type: ignore[arg-type] + geo_service.clear_neg_cache() + await geo_service.lookup("192.0.2.3", session) # type: ignore[arg-type] + + assert session.get.call_count == 2 + + async def test_successful_lookup_does_not_pollute_neg_cache(self) -> None: + """A successful lookup must not create a neg-cache entry.""" + session = _make_session( + { + "status": "success", + "countryCode": "DE", + "country": "Germany", + "as": "AS3320", + "org": "Telekom", + } + ) + + await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + + assert "1.2.3.4" not in geo_service._neg_cache # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# GeoIP2 (MaxMind) fallback +# --------------------------------------------------------------------------- + + +class TestGeoipFallback: + """Verify the MaxMind GeoLite2 fallback is used when ip-api fails.""" + + def _make_geoip_reader(self, iso_code: str, name: str) -> MagicMock: + """Build a mock geoip2.database.Reader that returns *iso_code*.""" + country_mock = MagicMock() + country_mock.iso_code = iso_code + country_mock.name = name + + response_mock = MagicMock() + response_mock.country = country_mock + + reader = MagicMock() + reader.country = MagicMock(return_value=response_mock) + return reader + + async def test_geoip_fallback_called_when_api_fails(self) -> None: + """When ip-api returns status=fail, the geoip2 reader is consulted.""" + session = _make_session({"status": "fail", "message": "reserved range"}) + mock_reader = self._make_geoip_reader("DE", "Germany") + + with patch.object(geo_service, "_geoip_reader", mock_reader): + result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + + mock_reader.country.assert_called_once_with("1.2.3.4") + assert result is not None + assert result.country_code == "DE" + assert result.country_name == "Germany" + + async def test_geoip_fallback_result_stored_in_cache(self) -> None: + """A successful geoip2 fallback result is stored in the positive cache.""" + session = _make_session({"status": "fail", "message": "reserved range"}) + mock_reader = self._make_geoip_reader("US", "United States") + + with patch.object(geo_service, "_geoip_reader", mock_reader): + await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type] + # Second call must be served from positive cache without hitting API. + await geo_service.lookup("8.8.8.8", session) # type: ignore[arg-type] + + assert session.get.call_count == 1 + assert "8.8.8.8" in geo_service._cache # type: ignore[attr-defined] + + async def test_geoip_fallback_not_called_on_api_success(self) -> None: + """When ip-api succeeds, the geoip2 reader must not be consulted.""" + session = _make_session( + { + "status": "success", + "countryCode": "JP", + "country": "Japan", + "as": "AS12345", + "org": "NTT", + } + ) + mock_reader = self._make_geoip_reader("XX", "Nowhere") + + with patch.object(geo_service, "_geoip_reader", mock_reader): + result = await geo_service.lookup("1.2.3.4", session) # type: ignore[arg-type] + + mock_reader.country.assert_not_called() + assert result is not None + assert result.country_code == "JP" + + async def test_geoip_fallback_not_called_when_no_reader(self) -> None: + """When no geoip2 reader is configured, the fallback silently does nothing.""" + session = _make_session({"status": "fail", "message": "private range"}) + + with patch.object(geo_service, "_geoip_reader", None): + result = await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type] + + assert result is not None + assert result.country_code is None + + +# --------------------------------------------------------------------------- +# Batch single-commit behaviour (Task 1) +# --------------------------------------------------------------------------- + + +def _make_batch_session(batch_response: list[dict[str, object]]) -> MagicMock: + """Build a mock aiohttp.ClientSession for batch POST calls. + + Args: + batch_response: The list that the mock response's ``json()`` returns. + + Returns: + A :class:`MagicMock` with a ``post`` method wired as an async context. + """ + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value=batch_response) + + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_resp) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + + session = MagicMock() + session.post = MagicMock(return_value=mock_ctx) + return session + + +def _make_async_db() -> MagicMock: + """Build a minimal mock :class:`aiosqlite.Connection`. + + Returns: + MagicMock with ``execute``, ``executemany``, and ``commit`` wired as + async coroutines. + """ + db = MagicMock() + db.execute = AsyncMock() + db.executemany = AsyncMock() + db.commit = AsyncMock() + return db + + +class TestLookupBatchSingleCommit: + """lookup_batch() issues exactly one commit per call, not one per IP.""" + + async def test_single_commit_for_multiple_ips(self) -> None: + """A batch of N IPs produces exactly one db.commit(), not N.""" + ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"] + batch_response = [ + {"query": ip, "status": "success", "countryCode": "DE", "country": "Germany", "as": "AS1", "org": "Org"} + for ip in ips + ] + session = _make_batch_session(batch_response) + db = _make_async_db() + + await geo_service.lookup_batch(ips, session, db=db) # type: ignore[arg-type] + + db.commit.assert_awaited_once() + + async def test_commit_called_even_on_failed_lookups(self) -> None: + """A batch with all-failed lookups still triggers one commit.""" + ips = ["10.0.0.1", "10.0.0.2"] + batch_response = [ + {"query": ip, "status": "fail", "message": "private range"} + for ip in ips + ] + session = _make_batch_session(batch_response) + db = _make_async_db() + + await geo_service.lookup_batch(ips, session, db=db) # type: ignore[arg-type] + + db.commit.assert_awaited_once() + + async def test_no_commit_when_db_is_none(self) -> None: + """When db=None, no commit is attempted.""" + ips = ["1.1.1.1"] + batch_response = [ + { + "query": "1.1.1.1", + "status": "success", + "countryCode": "US", + "country": "United States", + "as": "AS15169", + "org": "Google LLC", + }, + ] + session = _make_batch_session(batch_response) + + # Should not raise; without db there is nothing to commit. + result = await geo_service.lookup_batch(ips, session, db=None) + + assert result["1.1.1.1"].country_code == "US" + + async def test_no_commit_for_all_cached_ips(self) -> None: + """When all IPs are already cached, no HTTP call and no commit occur.""" + geo_service._cache["5.5.5.5"] = GeoInfo( # type: ignore[attr-defined] + country_code="FR", country_name="France", asn="AS1", org="ISP" + ) + db = _make_async_db() + session = _make_batch_session([]) + + result = await geo_service.lookup_batch(["5.5.5.5"], session, db=db) # type: ignore[arg-type] + + assert result["5.5.5.5"].country_code == "FR" + db.commit.assert_not_awaited() + session.post.assert_not_called() + + +# --------------------------------------------------------------------------- +# Dirty-set tracking and flush_dirty (Task 3) +# --------------------------------------------------------------------------- + + +class TestDirtySetTracking: + """_store() marks successfully resolved IPs as dirty.""" + + def test_successful_resolution_adds_to_dirty(self) -> None: + """Storing a GeoInfo with a country_code adds the IP to _dirty.""" + info = GeoInfo(country_code="DE", country_name="Germany", asn="AS1", org="ISP") + geo_service._store("1.2.3.4", info) # type: ignore[attr-defined] + + assert "1.2.3.4" in geo_service._dirty # type: ignore[attr-defined] + + def test_null_country_does_not_add_to_dirty(self) -> None: + """Storing a GeoInfo with country_code=None must not pollute _dirty.""" + info = GeoInfo(country_code=None, country_name=None, asn=None, org=None) + geo_service._store("10.0.0.1", info) # type: ignore[attr-defined] + + assert "10.0.0.1" not in geo_service._dirty # type: ignore[attr-defined] + + def test_clear_cache_also_clears_dirty(self) -> None: + """clear_cache() must discard any pending dirty entries.""" + info = GeoInfo(country_code="US", country_name="United States", asn="AS1", org="ISP") + geo_service._store("8.8.8.8", info) # type: ignore[attr-defined] + assert geo_service._dirty # type: ignore[attr-defined] + + geo_service.clear_cache() + + assert not geo_service._dirty # type: ignore[attr-defined] + + async def test_lookup_batch_populates_dirty(self) -> None: + """After lookup_batch() with db=None, resolved IPs appear in _dirty.""" + ips = ["1.1.1.1", "2.2.2.2"] + batch_response = [ + {"query": ip, "status": "success", "countryCode": "JP", "country": "Japan", "as": "AS7500", "org": "IIJ"} + for ip in ips + ] + session = _make_batch_session(batch_response) + + await geo_service.lookup_batch(ips, session, db=None) + + for ip in ips: + assert ip in geo_service._dirty # type: ignore[attr-defined] + + +class TestFlushDirty: + """flush_dirty() persists dirty entries and clears the set.""" + + async def test_flush_writes_and_clears_dirty(self) -> None: + """flush_dirty() inserts all dirty IPs and clears _dirty afterwards.""" + info = GeoInfo(country_code="GB", country_name="United Kingdom", asn="AS2856", org="BT") + geo_service._store("100.0.0.1", info) # type: ignore[attr-defined] + assert "100.0.0.1" in geo_service._dirty # type: ignore[attr-defined] + + db = _make_async_db() + count = await geo_service.flush_dirty(db) + + assert count == 1 + db.executemany.assert_awaited_once() + db.commit.assert_awaited_once() + assert "100.0.0.1" not in geo_service._dirty # type: ignore[attr-defined] + + async def test_flush_returns_zero_when_nothing_dirty(self) -> None: + """flush_dirty() returns 0 and makes no DB calls when _dirty is empty.""" + db = _make_async_db() + count = await geo_service.flush_dirty(db) + + assert count == 0 + db.executemany.assert_not_awaited() + db.commit.assert_not_awaited() + + async def test_flush_re_adds_to_dirty_on_db_error(self) -> None: + """When the DB write fails, entries are re-added to _dirty for retry.""" + info = GeoInfo(country_code="AU", country_name="Australia", asn="AS1", org="ISP") + geo_service._store("200.0.0.1", info) # type: ignore[attr-defined] + + db = _make_async_db() + db.executemany = AsyncMock(side_effect=OSError("disk full")) + + count = await geo_service.flush_dirty(db) + + assert count == 0 + assert "200.0.0.1" in geo_service._dirty # type: ignore[attr-defined] + + async def test_flush_batch_and_lookup_batch_integration(self) -> None: + """lookup_batch() populates _dirty; flush_dirty() then persists them.""" + ips = ["10.1.2.3", "10.1.2.4"] + batch_response = [ + {"query": ip, "status": "success", "countryCode": "CA", "country": "Canada", "as": "AS812", "org": "Bell"} + for ip in ips + ] + session = _make_batch_session(batch_response) + + # Resolve without DB to populate only in-memory cache and _dirty. + await geo_service.lookup_batch(ips, session, db=None) + assert geo_service._dirty == set(ips) # type: ignore[attr-defined] + + # Now flush to the DB. + db = _make_async_db() + count = await geo_service.flush_dirty(db) + + assert count == 2 + assert not geo_service._dirty # type: ignore[attr-defined] + db.commit.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Rate-limit throttling and retry tests (Task 5) +# --------------------------------------------------------------------------- + + +class TestLookupBatchThrottling: + """Verify the inter-batch delay, retry, and give-up behaviour.""" + + async def test_lookup_batch_throttles_between_chunks(self) -> None: + """When more than _BATCH_SIZE IPs are sent, asyncio.sleep is called + between consecutive batch HTTP calls with at least _BATCH_DELAY.""" + # Generate _BATCH_SIZE + 1 IPs so we get exactly 2 batch calls. + batch_size: int = geo_service._BATCH_SIZE # type: ignore[attr-defined] + ips = [f"10.0.{i // 256}.{i % 256}" for i in range(batch_size + 1)] + + def _make_result(chunk: list[str], _session: object) -> dict[str, GeoInfo]: + return { + ip: GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None) + for ip in chunk + } + + with ( + patch( + "app.services.geo_service._batch_api_call", + new_callable=AsyncMock, + side_effect=_make_result, + ) as mock_batch, + patch("app.services.geo_service.asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + ): + await geo_service.lookup_batch(ips, MagicMock()) + + # Two chunks → one sleep between them. + assert mock_batch.call_count == 2 + mock_sleep.assert_awaited_once() + delay_arg: float = mock_sleep.call_args[0][0] + assert delay_arg >= geo_service._BATCH_DELAY # type: ignore[attr-defined] + + async def test_lookup_batch_retries_on_full_chunk_failure(self) -> None: + """When a chunk returns all-None on first try, it retries and succeeds.""" + ips = ["1.2.3.4", "5.6.7.8"] + + _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None) + _success = { + "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn=None, org=None), + "5.6.7.8": GeoInfo(country_code="US", country_name="United States", asn=None, org=None), + } + _failure: dict[str, GeoInfo] = dict.fromkeys(ips, _empty) + + call_count = 0 + + async def _side_effect(chunk: list[str], _session: object) -> dict[str, GeoInfo]: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _failure + return _success + + with ( + patch( + "app.services.geo_service._batch_api_call", + new_callable=AsyncMock, + side_effect=_side_effect, + ), + patch("app.services.geo_service.asyncio.sleep", new_callable=AsyncMock), + ): + result = await geo_service.lookup_batch(ips, MagicMock()) + + assert call_count == 2 + assert result["1.2.3.4"].country_code == "DE" + assert result["5.6.7.8"].country_code == "US" + + async def test_lookup_batch_gives_up_after_max_retries(self) -> None: + """After _BATCH_MAX_RETRIES + 1 attempts, IPs end up in the neg cache.""" + ips = ["9.9.9.9"] + _empty = GeoInfo(country_code=None, country_name=None, asn=None, org=None) + _failure: dict[str, GeoInfo] = dict.fromkeys(ips, _empty) + + max_retries: int = geo_service._BATCH_MAX_RETRIES # type: ignore[attr-defined] + + with ( + patch( + "app.services.geo_service._batch_api_call", + new_callable=AsyncMock, + return_value=_failure, + ) as mock_batch, + patch("app.services.geo_service.asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + ): + result = await geo_service.lookup_batch(ips, MagicMock()) + + # Initial attempt + max_retries retries. + assert mock_batch.call_count == max_retries + 1 + # IP should have no country. + assert result["9.9.9.9"].country_code is None + # Negative cache should contain the IP. + assert "9.9.9.9" in geo_service._neg_cache # type: ignore[attr-defined] + # Sleep called for each retry with exponential backoff. + assert mock_sleep.call_count == max_retries + backoff_values = [call.args[0] for call in mock_sleep.call_args_list] + batch_delay: float = geo_service._BATCH_DELAY # type: ignore[attr-defined] + for i, val in enumerate(backoff_values): + expected = batch_delay * (2 ** (i + 1)) + assert val == pytest.approx(expected) + + +# --------------------------------------------------------------------------- +# Error logging improvements (Task 2) +# --------------------------------------------------------------------------- + + +class TestErrorLogging: + """Verify that exception details are properly captured in log events. + + Previously ``str(exc)`` was used which yields an empty string for + aiohttp exceptions such as ``ServerDisconnectedError`` that carry no + message. The fix uses ``repr(exc)`` so the exception class name is + always present, and adds an ``exc_type`` field for easy log filtering. + """ + + async def test_empty_message_exception_logs_exc_type(self, caplog: pytest.LogCaptureFixture) -> None: + """When exception str() is empty, exc_type and repr are still logged.""" + + class _EmptyMessageError(Exception): + """Exception whose str() representation is empty.""" + + def __str__(self) -> str: + return "" + + session = MagicMock() + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(side_effect=_EmptyMessageError()) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + session.get = MagicMock(return_value=mock_ctx) + + import structlog.testing + + with structlog.testing.capture_logs() as captured: + result = await geo_service.lookup("197.221.98.153", session) # type: ignore[arg-type] + + assert result is not None + assert result.country_code is None + + request_failed = [e for e in captured if e.get("event") == "geo_lookup_request_failed"] + assert len(request_failed) == 1 + event = request_failed[0] + # exc_type must name the exception class — never empty. + assert event["exc_type"] == "_EmptyMessageError" + # repr() must include the class name even when str() is empty. + assert "_EmptyMessageError" in event["error"] + + async def test_connection_error_logs_exc_type(self, caplog: pytest.LogCaptureFixture) -> None: + """A standard OSError with message is logged both in error and exc_type.""" + session = MagicMock() + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused")) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + session.get = MagicMock(return_value=mock_ctx) + + import structlog.testing + + with structlog.testing.capture_logs() as captured: + await geo_service.lookup("10.0.0.1", session) # type: ignore[arg-type] + + request_failed = [e for e in captured if e.get("event") == "geo_lookup_request_failed"] + assert len(request_failed) == 1 + event = request_failed[0] + assert event["exc_type"] == "OSError" + assert "connection refused" in event["error"] + + async def test_batch_empty_message_exception_logs_exc_type(self) -> None: + """Batch API call: empty-message exceptions include exc_type in the log.""" + + class _EmptyMessageError(Exception): + def __str__(self) -> str: + return "" + + session = MagicMock() + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(side_effect=_EmptyMessageError()) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + session.post = MagicMock(return_value=mock_ctx) + + import structlog.testing + + with structlog.testing.capture_logs() as captured: + result = await geo_service._batch_api_call(["1.2.3.4"], session) # type: ignore[attr-defined] + + assert result["1.2.3.4"].country_code is None + + batch_failed = [e for e in captured if e.get("event") == "geo_batch_request_failed"] + assert len(batch_failed) == 1 + event = batch_failed[0] + assert event["exc_type"] == "_EmptyMessageError" + assert "_EmptyMessageError" in event["error"] + + +# --------------------------------------------------------------------------- +# lookup_cached_only (Task 3) +# --------------------------------------------------------------------------- + + +class TestLookupCachedOnly: + """lookup_cached_only() returns cache hits without making API calls.""" + + def test_returns_cached_ips(self) -> None: + """IPs already in the cache are returned in the geo_map.""" + geo_service._cache["1.1.1.1"] = GeoInfo( # type: ignore[attr-defined] + country_code="AU", country_name="Australia", asn="AS13335", org="Cloudflare" + ) + geo_map, uncached = geo_service.lookup_cached_only(["1.1.1.1"]) + + assert "1.1.1.1" in geo_map + assert geo_map["1.1.1.1"].country_code == "AU" + assert uncached == [] + + def test_returns_uncached_ips(self) -> None: + """IPs not in the cache appear in the uncached list.""" + geo_map, uncached = geo_service.lookup_cached_only(["9.9.9.9"]) + + assert "9.9.9.9" not in geo_map + assert "9.9.9.9" in uncached + + def test_neg_cached_ips_excluded_from_uncached(self) -> None: + """IPs in the negative cache within TTL are not re-queued as uncached.""" + import time + + geo_service._neg_cache["10.0.0.1"] = time.monotonic() # type: ignore[attr-defined] + + geo_map, uncached = geo_service.lookup_cached_only(["10.0.0.1"]) + + assert "10.0.0.1" not in geo_map + assert "10.0.0.1" not in uncached + + def test_expired_neg_cache_requeued(self) -> None: + """IPs whose neg-cache entry has expired are listed as uncached.""" + geo_service._neg_cache["10.0.0.2"] = 0.0 # epoch 0 → expired # type: ignore[attr-defined] + + _geo_map, uncached = geo_service.lookup_cached_only(["10.0.0.2"]) + + assert "10.0.0.2" in uncached + + def test_mixed_ips(self) -> None: + """A mix of cached, neg-cached, and unknown IPs is split correctly.""" + geo_service._cache["1.2.3.4"] = GeoInfo( # type: ignore[attr-defined] + country_code="DE", country_name="Germany", asn=None, org=None + ) + import time + + geo_service._neg_cache["5.5.5.5"] = time.monotonic() # type: ignore[attr-defined] + + geo_map, uncached = geo_service.lookup_cached_only(["1.2.3.4", "5.5.5.5", "9.9.9.9"]) + + assert list(geo_map.keys()) == ["1.2.3.4"] + assert uncached == ["9.9.9.9"] + + def test_deduplication(self) -> None: + """Duplicate IPs in the input appear at most once in the output.""" + geo_service._cache["1.2.3.4"] = GeoInfo( # type: ignore[attr-defined] + country_code="US", country_name="United States", asn=None, org=None + ) + + geo_map, uncached = geo_service.lookup_cached_only( + ["9.9.9.9", "9.9.9.9", "1.2.3.4", "1.2.3.4"] + ) + + assert len([ip for ip in geo_map if ip == "1.2.3.4"]) == 1 + assert uncached.count("9.9.9.9") == 1 + + +# --------------------------------------------------------------------------- +# Bulk DB writes via executemany (Task 3) +# --------------------------------------------------------------------------- + + +class TestLookupBatchBulkWrites: + """lookup_batch() uses executemany for bulk DB writes, not per-IP execute.""" + + async def test_executemany_called_for_successful_ips(self) -> None: + """When multiple IPs resolve successfully, a single executemany write occurs.""" + ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"] + batch_response = [ + { + "query": ip, + "status": "success", + "countryCode": "DE", + "country": "Germany", + "as": "AS3320", + "org": "Telekom", + } + for ip in ips + ] + session = _make_batch_session(batch_response) + db = _make_async_db() + + await geo_service.lookup_batch(ips, session, db=db) # type: ignore[arg-type] + + # One executemany for the positive rows. + assert db.executemany.await_count >= 1 + # High-level: execute() must NOT be called for the batch writes. + db.execute.assert_not_awaited() + + async def test_executemany_called_for_failed_ips(self) -> None: + """When IPs fail resolution, a single executemany write covers neg entries.""" + ips = ["10.0.0.1", "10.0.0.2"] + batch_response = [ + {"query": ip, "status": "fail", "message": "private range"} + for ip in ips + ] + session = _make_batch_session(batch_response) + db = _make_async_db() + + await geo_service.lookup_batch(ips, session, db=db) # type: ignore[arg-type] + + assert db.executemany.await_count >= 1 + db.execute.assert_not_awaited() + + async def test_mixed_results_two_executemany_calls(self) -> None: + """A mix of successful and failed IPs produces two executemany calls.""" + ips = ["1.1.1.1", "10.0.0.1"] + batch_response = [ + { + "query": "1.1.1.1", + "status": "success", + "countryCode": "AU", + "country": "Australia", + "as": "AS13335", + "org": "Cloudflare", + }, + {"query": "10.0.0.1", "status": "fail", "message": "private range"}, + ] + session = _make_batch_session(batch_response) + db = _make_async_db() + + await geo_service.lookup_batch(ips, session, db=db) # type: ignore[arg-type] + + # One executemany for positives, one for negatives. + assert db.executemany.await_count == 2 + db.execute.assert_not_awaited() + diff --git a/backend/tests/test_services/test_health_service.py b/backend/tests/test_services/test_health_service.py new file mode 100644 index 0000000..0fc2a77 --- /dev/null +++ b/backend/tests/test_services/test_health_service.py @@ -0,0 +1,263 @@ +"""Tests for health_service.probe().""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from app.models.server import ServerStatus +from app.services import health_service +from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SOCKET = "/fake/fail2ban.sock" + + +def _make_send(responses: dict[str, Any]) -> AsyncMock: + """Build an ``AsyncMock`` for ``Fail2BanClient.send`` keyed by command[0]. + + For the ``["status", jail_name]`` command the key is + ``"status:"``. + """ + + async def _side_effect(command: list[str]) -> Any: + key = f"status:{command[1]}" if len(command) >= 2 and command[0] == "status" else command[0] + if key not in responses: + raise KeyError(f"Unexpected command key {key!r} in mock") + return responses[key] + + mock = AsyncMock(side_effect=_side_effect) + return mock + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestProbeOnline: + """Verify probe() correctly parses a healthy fail2ban response.""" + + async def test_online_flag_is_true(self) -> None: + """status.online is True when ping succeeds.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.0.2"), + "status": (0, [("Number of jail", 0), ("Jail list", "")]), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result: ServerStatus = await health_service.probe(_SOCKET) + + assert result.online is True + + async def test_version_parsed(self) -> None: + """status.version contains the version string returned by fail2ban.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.1.0"), + "status": (0, [("Number of jail", 0), ("Jail list", "")]), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.version == "1.1.0" + + async def test_active_jails_count(self) -> None: + """status.active_jails reflects the jail count from the status command.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.0.2"), + "status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]), + "status:sshd": ( + 0, + [ + ("Filter", [("Currently failed", 3), ("Total failed", 100)]), + ("Actions", [("Currently banned", 1), ("Total banned", 50)]), + ], + ), + "status:nginx": ( + 0, + [ + ("Filter", [("Currently failed", 2), ("Total failed", 50)]), + ("Actions", [("Currently banned", 0), ("Total banned", 10)]), + ], + ), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.active_jails == 2 + + async def test_total_bans_aggregated(self) -> None: + """status.total_bans sums 'Currently banned' across all jails.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.0.2"), + "status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]), + "status:sshd": ( + 0, + [ + ("Filter", [("Currently failed", 3), ("Total failed", 100)]), + ("Actions", [("Currently banned", 4), ("Total banned", 50)]), + ], + ), + "status:nginx": ( + 0, + [ + ("Filter", [("Currently failed", 1), ("Total failed", 20)]), + ("Actions", [("Currently banned", 2), ("Total banned", 15)]), + ], + ), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.total_bans == 6 # 4 + 2 + + async def test_total_failures_aggregated(self) -> None: + """status.total_failures sums 'Currently failed' across all jails.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.0.2"), + "status": (0, [("Number of jail", 2), ("Jail list", "sshd, nginx")]), + "status:sshd": ( + 0, + [ + ("Filter", [("Currently failed", 3), ("Total failed", 100)]), + ("Actions", [("Currently banned", 1), ("Total banned", 50)]), + ], + ), + "status:nginx": ( + 0, + [ + ("Filter", [("Currently failed", 2), ("Total failed", 20)]), + ("Actions", [("Currently banned", 0), ("Total banned", 10)]), + ], + ), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.total_failures == 5 # 3 + 2 + + async def test_empty_jail_list(self) -> None: + """Probe succeeds with zero jails — no per-jail queries are made.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.0.2"), + "status": (0, [("Number of jail", 0), ("Jail list", "")]), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.online is True + assert result.active_jails == 0 + assert result.total_bans == 0 + assert result.total_failures == 0 + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestProbeOffline: + """Verify probe() returns online=False when the daemon is unreachable.""" + + async def test_connection_error_returns_offline(self) -> None: + """Fail2BanConnectionError → online=False.""" + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = AsyncMock( + side_effect=Fail2BanConnectionError("socket not found", _SOCKET) + ) + result = await health_service.probe(_SOCKET) + + assert result.online is False + assert result.version is None + + async def test_protocol_error_returns_offline(self) -> None: + """Fail2BanProtocolError → online=False.""" + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = AsyncMock( + side_effect=Fail2BanProtocolError("bad pickle") + ) + result = await health_service.probe(_SOCKET) + + assert result.online is False + + async def test_bad_ping_response_returns_offline(self) -> None: + """An unexpected ping response → online=False (defensive guard).""" + send = _make_send({"ping": (0, "NOTPONG")}) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.online is False + + async def test_error_code_in_ping_returns_offline(self) -> None: + """An error return code in the ping response → online=False.""" + send = _make_send({"ping": (1, "ERROR")}) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.online is False + + async def test_per_jail_error_is_tolerated(self) -> None: + """A parse error on an individual jail's status does not break the probe.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": (0, "1.0.2"), + "status": (0, [("Number of jail", 1), ("Jail list", "sshd")]), + # Return garbage to trigger parse tolerance. + "status:sshd": (0, "INVALID"), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + # The service should still be online even if per-jail parsing fails. + assert result.online is True + assert result.total_bans == 0 + assert result.total_failures == 0 + + @pytest.mark.parametrize("version_return", [(1, "ERROR"), (0, None)]) + async def test_version_failure_is_tolerated(self, version_return: tuple[int, Any]) -> None: + """A failed or null version response does not prevent a successful probe.""" + send = _make_send( + { + "ping": (0, "pong"), + "version": version_return, + "status": (0, [("Number of jail", 0), ("Jail list", "")]), + } + ) + with patch("app.services.health_service.Fail2BanClient") as mock_client: + mock_client.return_value.send = send + result = await health_service.probe(_SOCKET) + + assert result.online is True diff --git a/backend/tests/test_services/test_history_service.py b/backend/tests/test_services/test_history_service.py new file mode 100644 index 0000000..425fbc0 --- /dev/null +++ b/backend/tests/test_services/test_history_service.py @@ -0,0 +1,341 @@ +"""Tests for history_service.list_history() and history_service.get_ip_detail().""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, patch + +import aiosqlite +import pytest + +from app.services import history_service + +# --------------------------------------------------------------------------- +# Time helpers +# --------------------------------------------------------------------------- + +_NOW: int = int(time.time()) +_ONE_HOUR_AGO: int = _NOW - 3600 +_TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600 +_THIRTY_DAYS_AGO: int = _NOW - 30 * 24 * 3600 + + +# --------------------------------------------------------------------------- +# DB fixture helpers +# --------------------------------------------------------------------------- + + +async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None: + """Create a minimal fail2ban SQLite database with the given ban rows.""" + async with aiosqlite.connect(path) as db: + await db.execute( + "CREATE TABLE jails (" + "name TEXT NOT NULL UNIQUE, " + "enabled INTEGER NOT NULL DEFAULT 1" + ")" + ) + await db.execute( + "CREATE TABLE bans (" + "jail TEXT NOT NULL, " + "ip TEXT, " + "timeofban INTEGER NOT NULL, " + "bantime INTEGER NOT NULL, " + "bancount INTEGER NOT NULL DEFAULT 1, " + "data JSON" + ")" + ) + for row in rows: + await db.execute( + "INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + row["jail"], + row["ip"], + row["timeofban"], + row.get("bantime", 3600), + row.get("bancount", 1), + json.dumps(row["data"]) if "data" in row else None, + ), + ) + await db.commit() + + +@pytest.fixture +async def f2b_db_path(tmp_path: Path) -> str: # type: ignore[misc] + """Return the path to a test fail2ban SQLite database.""" + path = str(tmp_path / "fail2ban_test.sqlite3") + await _create_f2b_db( + path, + [ + { + "jail": "sshd", + "ip": "1.2.3.4", + "timeofban": _ONE_HOUR_AGO, + "bantime": 3600, + "bancount": 3, + "data": { + "matches": ["Mar 1 sshd[1]: Failed password for root"], + "failures": 5, + }, + }, + { + "jail": "nginx", + "ip": "5.6.7.8", + "timeofban": _ONE_HOUR_AGO, + "bantime": 7200, + "bancount": 1, + "data": {"matches": ["GET /admin HTTP/1.1"], "failures": 3}, + }, + { + "jail": "sshd", + "ip": "1.2.3.4", + "timeofban": _TWO_DAYS_AGO, + "bantime": 3600, + "bancount": 2, + "data": {"failures": 5}, + }, + { + "jail": "sshd", + "ip": "9.0.0.1", + "timeofban": _THIRTY_DAYS_AGO, + "bantime": 3600, + "bancount": 1, + "data": None, + }, + ], + ) + return path + + +# --------------------------------------------------------------------------- +# list_history tests +# --------------------------------------------------------------------------- + + +class TestListHistory: + """history_service.list_history().""" + + async def test_returns_all_bans_with_no_filter( + self, f2b_db_path: str + ) -> None: + """No filter returns every record in the database.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history("fake_socket") + assert result.total == 4 + assert len(result.items) == 4 + + async def test_time_range_filter_excludes_old_bans( + self, f2b_db_path: str + ) -> None: + """The ``range_`` filter excludes bans older than the window.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + # "24h" window should include only the two recent bans + result = await history_service.list_history( + "fake_socket", range_="24h" + ) + assert result.total == 2 + + async def test_jail_filter(self, f2b_db_path: str) -> None: + """Jail filter restricts results to bans from that jail.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history("fake_socket", jail="nginx") + assert result.total == 1 + assert result.items[0].jail == "nginx" + + async def test_ip_prefix_filter(self, f2b_db_path: str) -> None: + """IP prefix filter restricts results to matching IPs.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", ip_filter="1.2.3" + ) + assert result.total == 2 + for item in result.items: + assert item.ip.startswith("1.2.3") + + async def test_combined_filters(self, f2b_db_path: str) -> None: + """Jail + IP prefix filters applied together narrow the result set.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", jail="sshd", ip_filter="1.2.3.4" + ) + # 2 sshd bans for 1.2.3.4 + assert result.total == 2 + + async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None: + """Filtering by a non-existent IP returns an empty result set.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", ip_filter="99.99.99.99" + ) + assert result.total == 0 + assert result.items == [] + + async def test_failures_extracted_from_data( + self, f2b_db_path: str + ) -> None: + """``failures`` field is parsed from the JSON ``data`` column.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", ip_filter="5.6.7.8" + ) + assert result.total == 1 + assert result.items[0].failures == 3 + + async def test_matches_extracted_from_data( + self, f2b_db_path: str + ) -> None: + """``matches`` list is parsed from the JSON ``data`` column.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", ip_filter="1.2.3.4", range_="24h" + ) + # Most recent record for 1.2.3.4 has a matches list + recent = result.items[0] + assert len(recent.matches) == 1 + assert "Failed password" in recent.matches[0] + + async def test_null_data_column_handled_gracefully( + self, f2b_db_path: str + ) -> None: + """Records with ``data=NULL`` produce failures=0 and matches=[].""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", ip_filter="9.0.0.1" + ) + assert result.total == 1 + item = result.items[0] + assert item.failures == 0 + assert item.matches == [] + + async def test_pagination(self, f2b_db_path: str) -> None: + """Pagination returns the correct slice.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.list_history( + "fake_socket", page=1, page_size=2 + ) + assert result.total == 4 + assert len(result.items) == 2 + assert result.page == 1 + assert result.page_size == 2 + + +# --------------------------------------------------------------------------- +# get_ip_detail tests +# --------------------------------------------------------------------------- + + +class TestGetIpDetail: + """history_service.get_ip_detail().""" + + async def test_returns_none_for_unknown_ip( + self, f2b_db_path: str + ) -> None: + """Returns ``None`` when the IP has no records in the database.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.get_ip_detail("fake_socket", "99.99.99.99") + assert result is None + + async def test_returns_ip_detail_for_known_ip( + self, f2b_db_path: str + ) -> None: + """Returns an IpDetailResponse with correct totals for a known IP.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.get_ip_detail("fake_socket", "1.2.3.4") + + assert result is not None + assert result.ip == "1.2.3.4" + assert result.total_bans == 2 + assert result.total_failures == 10 # 5 + 5 + + async def test_timeline_ordered_newest_first( + self, f2b_db_path: str + ) -> None: + """Timeline events are ordered newest-first.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.get_ip_detail("fake_socket", "1.2.3.4") + + assert result is not None + assert len(result.timeline) == 2 + # First event should be the most recent + assert result.timeline[0].banned_at > result.timeline[1].banned_at + + async def test_last_ban_at_is_most_recent(self, f2b_db_path: str) -> None: + """``last_ban_at`` matches the banned_at of the first timeline event.""" + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.get_ip_detail("fake_socket", "1.2.3.4") + + assert result is not None + assert result.last_ban_at == result.timeline[0].banned_at + + async def test_geo_enrichment_applied_when_provided( + self, f2b_db_path: str + ) -> None: + """Geolocation is applied when a geo_enricher is provided.""" + from app.services.geo_service import GeoInfo + + mock_geo = GeoInfo( + country_code="US", + country_name="United States", + asn="AS15169", + org="Google", + ) + fake_enricher = AsyncMock(return_value=mock_geo) + + with patch( + "app.services.history_service._get_fail2ban_db_path", + new=AsyncMock(return_value=f2b_db_path), + ): + result = await history_service.get_ip_detail( + "fake_socket", "1.2.3.4", geo_enricher=fake_enricher + ) + + assert result is not None + assert result.country_code == "US" + assert result.country_name == "United States" + assert result.asn == "AS15169" + assert result.org == "Google" diff --git a/backend/tests/test_services/test_ip_utils.py b/backend/tests/test_services/test_ip_utils.py new file mode 100644 index 0000000..20b90d7 --- /dev/null +++ b/backend/tests/test_services/test_ip_utils.py @@ -0,0 +1,106 @@ +"""Tests for app.utils.ip_utils.""" + +import pytest + +from app.utils.ip_utils import ( + ip_version, + is_valid_ip, + is_valid_ip_or_network, + is_valid_network, + normalise_ip, + normalise_network, +) + + +class TestIsValidIp: + """Tests for :func:`is_valid_ip`.""" + + def test_is_valid_ip_with_valid_ipv4_returns_true(self) -> None: + assert is_valid_ip("192.168.1.1") is True + + def test_is_valid_ip_with_valid_ipv6_returns_true(self) -> None: + assert is_valid_ip("2001:db8::1") is True + + def test_is_valid_ip_with_cidr_returns_false(self) -> None: + assert is_valid_ip("10.0.0.0/8") is False + + def test_is_valid_ip_with_empty_string_returns_false(self) -> None: + assert is_valid_ip("") is False + + def test_is_valid_ip_with_hostname_returns_false(self) -> None: + assert is_valid_ip("example.com") is False + + def test_is_valid_ip_with_loopback_returns_true(self) -> None: + assert is_valid_ip("127.0.0.1") is True + + +class TestIsValidNetwork: + """Tests for :func:`is_valid_network`.""" + + def test_is_valid_network_with_valid_cidr_returns_true(self) -> None: + assert is_valid_network("192.168.0.0/24") is True + + def test_is_valid_network_with_host_bits_set_returns_true(self) -> None: + # strict=False means host bits being set is allowed. + assert is_valid_network("192.168.0.1/24") is True + + def test_is_valid_network_with_plain_ip_returns_true(self) -> None: + # A bare IP is treated as a host-only /32 network — this is valid. + assert is_valid_network("192.168.0.1") is True + + def test_is_valid_network_with_hostname_returns_false(self) -> None: + assert is_valid_network("example.com") is False + + def test_is_valid_network_with_invalid_prefix_returns_false(self) -> None: + assert is_valid_network("10.0.0.0/99") is False + + +class TestIsValidIpOrNetwork: + """Tests for :func:`is_valid_ip_or_network`.""" + + def test_accepts_plain_ip(self) -> None: + assert is_valid_ip_or_network("1.2.3.4") is True + + def test_accepts_cidr(self) -> None: + assert is_valid_ip_or_network("10.0.0.0/8") is True + + def test_rejects_garbage(self) -> None: + assert is_valid_ip_or_network("not-an-ip") is False + + +class TestNormaliseIp: + """Tests for :func:`normalise_ip`.""" + + def test_normalise_ip_ipv4_unchanged(self) -> None: + assert normalise_ip("10.20.30.40") == "10.20.30.40" + + def test_normalise_ip_ipv6_compressed(self) -> None: + assert normalise_ip("2001:0db8:0000:0000:0000:0000:0000:0001") == "2001:db8::1" + + def test_normalise_ip_invalid_raises_value_error(self) -> None: + with pytest.raises(ValueError): + normalise_ip("not-an-ip") + + +class TestNormaliseNetwork: + """Tests for :func:`normalise_network`.""" + + def test_normalise_network_masks_host_bits(self) -> None: + assert normalise_network("192.168.1.5/24") == "192.168.1.0/24" + + def test_normalise_network_already_canonical(self) -> None: + assert normalise_network("10.0.0.0/8") == "10.0.0.0/8" + + +class TestIpVersion: + """Tests for :func:`ip_version`.""" + + def test_ip_version_ipv4_returns_4(self) -> None: + assert ip_version("8.8.8.8") == 4 + + def test_ip_version_ipv6_returns_6(self) -> None: + assert ip_version("::1") == 6 + + def test_ip_version_invalid_raises_value_error(self) -> None: + with pytest.raises(ValueError): + ip_version("garbage") diff --git a/backend/tests/test_services/test_jail_service.py b/backend/tests/test_services/test_jail_service.py new file mode 100644 index 0000000..10d1a9a --- /dev/null +++ b/backend/tests/test_services/test_jail_service.py @@ -0,0 +1,900 @@ +"""Tests for jail_service functions.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from app.models.ban import ActiveBanListResponse, JailBannedIpsResponse +from app.models.jail import JailDetailResponse, JailListResponse +from app.services import jail_service +from app.services.jail_service import JailNotFoundError, JailOperationError +from app.utils.fail2ban_client import Fail2BanConnectionError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SOCKET = "/fake/fail2ban.sock" + +_JAIL_NAMES = "sshd, nginx" + + +def _make_global_status(names: str = _JAIL_NAMES) -> tuple[int, list[Any]]: + return (0, [("Number of jail", 2), ("Jail list", names)]) + + +def _make_short_status( + banned: int = 2, + total_banned: int = 10, + failed: int = 3, + total_failed: int = 20, +) -> tuple[int, list[Any]]: + return ( + 0, + [ + ("Filter", [("Currently failed", failed), ("Total failed", total_failed)]), + ("Actions", [("Currently banned", banned), ("Total banned", total_banned)]), + ], + ) + + +def _make_send(responses: dict[str, Any]) -> AsyncMock: + """Build an ``AsyncMock`` for ``Fail2BanClient.send``. + + Responses are keyed by the command joined with a pipe, e.g. + ``"status"`` or ``"status|sshd|short"``. + """ + + async def _side_effect(command: list[Any]) -> Any: + key = "|".join(str(c) for c in command) + if key in responses: + return responses[key] + # Fall back to partial key matching. + for resp_key, resp_value in responses.items(): + if key.startswith(resp_key): + return resp_value + raise KeyError(f"Unexpected command key {key!r}") + + return AsyncMock(side_effect=_side_effect) + + +def _patch_client(responses: dict[str, Any]) -> Any: + """Return a ``patch`` context manager that mocks ``Fail2BanClient``.""" + mock_send = _make_send(responses) + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = mock_send + + return patch("app.services.jail_service.Fail2BanClient", _FakeClient) + + +# --------------------------------------------------------------------------- +# list_jails +# --------------------------------------------------------------------------- + + +class TestListJails: + """Unit tests for :func:`~app.services.jail_service.list_jails`.""" + + async def test_returns_jail_list_response(self) -> None: + """list_jails returns a JailListResponse.""" + responses = { + "status": _make_global_status("sshd"), + "status|sshd|short": _make_short_status(), + "get|sshd|bantime": (0, 600), + "get|sshd|findtime": (0, 600), + "get|sshd|maxretry": (0, 5), + "get|sshd|backend": (0, "polling"), + "get|sshd|idle": (0, False), + } + with _patch_client(responses): + result = await jail_service.list_jails(_SOCKET) + + assert isinstance(result, JailListResponse) + assert result.total == 1 + assert result.jails[0].name == "sshd" + + async def test_empty_jail_list(self) -> None: + """list_jails returns empty response when no jails are active.""" + responses = {"status": (0, [("Number of jail", 0), ("Jail list", "")])} + with _patch_client(responses): + result = await jail_service.list_jails(_SOCKET) + + assert result.total == 0 + assert result.jails == [] + + async def test_jail_status_populated(self) -> None: + """list_jails populates JailStatus with failed/banned counters.""" + responses = { + "status": _make_global_status("sshd"), + "status|sshd|short": _make_short_status(banned=5, total_banned=50), + "get|sshd|bantime": (0, 600), + "get|sshd|findtime": (0, 600), + "get|sshd|maxretry": (0, 5), + "get|sshd|backend": (0, "polling"), + "get|sshd|idle": (0, False), + } + with _patch_client(responses): + result = await jail_service.list_jails(_SOCKET) + + jail = result.jails[0] + assert jail.status is not None + assert jail.status.currently_banned == 5 + assert jail.status.total_banned == 50 + + async def test_jail_config_populated(self) -> None: + """list_jails populates ban_time, find_time, max_retry, backend.""" + responses = { + "status": _make_global_status("sshd"), + "status|sshd|short": _make_short_status(), + "get|sshd|bantime": (0, 3600), + "get|sshd|findtime": (0, 300), + "get|sshd|maxretry": (0, 3), + "get|sshd|backend": (0, "systemd"), + "get|sshd|idle": (0, True), + } + with _patch_client(responses): + result = await jail_service.list_jails(_SOCKET) + + jail = result.jails[0] + assert jail.ban_time == 3600 + assert jail.find_time == 300 + assert jail.max_retry == 3 + assert jail.backend == "systemd" + assert jail.idle is True + + async def test_multiple_jails_returned(self) -> None: + """list_jails fetches all jails listed in the global status.""" + responses = { + "status": _make_global_status("sshd, nginx"), + "status|sshd|short": _make_short_status(), + "status|nginx|short": _make_short_status(banned=0), + "get|sshd|bantime": (0, 600), + "get|sshd|findtime": (0, 600), + "get|sshd|maxretry": (0, 5), + "get|sshd|backend": (0, "polling"), + "get|sshd|idle": (0, False), + "get|nginx|bantime": (0, 1800), + "get|nginx|findtime": (0, 600), + "get|nginx|maxretry": (0, 5), + "get|nginx|backend": (0, "polling"), + "get|nginx|idle": (0, False), + } + with _patch_client(responses): + result = await jail_service.list_jails(_SOCKET) + + assert result.total == 2 + names = {j.name for j in result.jails} + assert names == {"sshd", "nginx"} + + async def test_connection_error_propagates(self) -> None: + """list_jails raises Fail2BanConnectionError when socket unreachable.""" + + async def _raise(*_: Any, **__: Any) -> None: + raise Fail2BanConnectionError("no socket", _SOCKET) + + class _FailClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=Fail2BanConnectionError("no socket", _SOCKET)) + + with patch("app.services.jail_service.Fail2BanClient", _FailClient), pytest.raises(Fail2BanConnectionError): + await jail_service.list_jails(_SOCKET) + + +# --------------------------------------------------------------------------- +# get_jail +# --------------------------------------------------------------------------- + + +class TestGetJail: + """Unit tests for :func:`~app.services.jail_service.get_jail`.""" + + def _full_responses(self, name: str = "sshd") -> dict[str, Any]: + return { + f"status|{name}|short": _make_short_status(), + f"get|{name}|logpath": (0, ["/var/log/auth.log"]), + f"get|{name}|failregex": (0, ["^.*Failed.*from "]), + f"get|{name}|ignoreregex": (0, []), + f"get|{name}|ignoreip": (0, ["127.0.0.1"]), + f"get|{name}|datepattern": (0, None), + f"get|{name}|logencoding": (0, "UTF-8"), + f"get|{name}|bantime": (0, 600), + f"get|{name}|findtime": (0, 600), + f"get|{name}|maxretry": (0, 5), + f"get|{name}|backend": (0, "polling"), + f"get|{name}|idle": (0, False), + f"get|{name}|actions": (0, ["iptables-multiport"]), + } + + async def test_returns_jail_detail_response(self) -> None: + """get_jail returns a JailDetailResponse.""" + with _patch_client(self._full_responses()): + result = await jail_service.get_jail(_SOCKET, "sshd") + + assert isinstance(result, JailDetailResponse) + assert result.jail.name == "sshd" + + async def test_log_paths_parsed(self) -> None: + """get_jail populates log_paths from fail2ban.""" + with _patch_client(self._full_responses()): + result = await jail_service.get_jail(_SOCKET, "sshd") + + assert result.jail.log_paths == ["/var/log/auth.log"] + + async def test_fail_regex_parsed(self) -> None: + """get_jail populates fail_regex list.""" + with _patch_client(self._full_responses()): + result = await jail_service.get_jail(_SOCKET, "sshd") + + assert "^.*Failed.*from " in result.jail.fail_regex + + async def test_ignore_ips_parsed(self) -> None: + """get_jail populates ignore_ips list.""" + with _patch_client(self._full_responses()): + result = await jail_service.get_jail(_SOCKET, "sshd") + + assert "127.0.0.1" in result.jail.ignore_ips + + async def test_actions_parsed(self) -> None: + """get_jail populates actions list.""" + with _patch_client(self._full_responses()): + result = await jail_service.get_jail(_SOCKET, "sshd") + + assert result.jail.actions == ["iptables-multiport"] + + async def test_jail_not_found_raises(self) -> None: + """get_jail raises JailNotFoundError when jail is unknown.""" + not_found_response = (1, Exception("Unknown jail: 'ghost'")) + + with _patch_client({r"status|ghost|short": not_found_response}), pytest.raises(JailNotFoundError): + await jail_service.get_jail(_SOCKET, "ghost") + + +# --------------------------------------------------------------------------- +# Jail control commands +# --------------------------------------------------------------------------- + + +class TestJailControls: + """Unit tests for start, stop, idle, reload commands.""" + + async def test_start_jail_success(self) -> None: + """start_jail sends the start command without error.""" + with _patch_client({"start|sshd": (0, None)}): + await jail_service.start_jail(_SOCKET, "sshd") # should not raise + + async def test_stop_jail_success(self) -> None: + """stop_jail sends the stop command without error.""" + with _patch_client({"stop|sshd": (0, None)}): + await jail_service.stop_jail(_SOCKET, "sshd") # should not raise + + async def test_set_idle_on(self) -> None: + """set_idle sends idle=on when on=True.""" + with _patch_client({"set|sshd|idle|on": (0, True)}): + await jail_service.set_idle(_SOCKET, "sshd", on=True) # should not raise + + async def test_set_idle_off(self) -> None: + """set_idle sends idle=off when on=False.""" + with _patch_client({"set|sshd|idle|off": (0, True)}): + await jail_service.set_idle(_SOCKET, "sshd", on=False) # should not raise + + async def test_reload_jail_success(self) -> None: + """reload_jail sends a reload command with a minimal start-stream.""" + with _patch_client({"reload|sshd|[]|[['start', 'sshd']]": (0, "OK")}): + await jail_service.reload_jail(_SOCKET, "sshd") # should not raise + + async def test_reload_all_success(self) -> None: + """reload_all fetches jail names then sends reload --all with a start-stream.""" + with _patch_client( + { + "status": _make_global_status("sshd, nginx"), + "reload|--all|[]|[['start', 'nginx'], ['start', 'sshd']]": (0, "OK"), + } + ): + await jail_service.reload_all(_SOCKET) # should not raise + + async def test_reload_all_no_jails_still_sends_reload(self) -> None: + """reload_all works with an empty jail list (sends an empty stream).""" + with _patch_client( + { + "status": (0, [("Number of jail", 0), ("Jail list", "")]), + "reload|--all|[]|[]": (0, "OK"), + } + ): + await jail_service.reload_all(_SOCKET) # should not raise + + async def test_reload_all_include_jails(self) -> None: + """reload_all with include_jails adds the new jail to the stream.""" + with _patch_client( + { + "status": _make_global_status("sshd, nginx"), + "reload|--all|[]|[['start', 'apache-auth'], ['start', 'nginx'], ['start', 'sshd']]": (0, "OK"), + } + ): + await jail_service.reload_all(_SOCKET, include_jails=["apache-auth"]) + + async def test_reload_all_exclude_jails(self) -> None: + """reload_all with exclude_jails removes the jail from the stream.""" + with _patch_client( + { + "status": _make_global_status("sshd, nginx"), + "reload|--all|[]|[['start', 'nginx']]": (0, "OK"), + } + ): + await jail_service.reload_all(_SOCKET, exclude_jails=["sshd"]) + + async def test_reload_all_include_and_exclude(self) -> None: + """reload_all with both include and exclude applies both correctly.""" + with _patch_client( + { + "status": _make_global_status("old, nginx"), + "reload|--all|[]|[['start', 'new'], ['start', 'nginx']]": (0, "OK"), + } + ): + await jail_service.reload_all( + _SOCKET, include_jails=["new"], exclude_jails=["old"] + ) + + async def test_start_not_found_raises(self) -> None: + """start_jail raises JailNotFoundError for unknown jail.""" + with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError): + await jail_service.start_jail(_SOCKET, "ghost") + + async def test_stop_jail_already_stopped_is_noop(self) -> None: + """stop_jail silently succeeds when the jail is not found (idempotent).""" + with _patch_client({"stop|sshd": (1, Exception("UnknownJailException('sshd')"))}): + await jail_service.stop_jail(_SOCKET, "sshd") # should not raise + + async def test_stop_operation_error_raises(self) -> None: + """stop_jail raises JailOperationError on a non-not-found fail2ban error.""" + with _patch_client({"stop|sshd": (1, Exception("cannot stop"))}), pytest.raises(JailOperationError): + await jail_service.stop_jail(_SOCKET, "sshd") + + +# --------------------------------------------------------------------------- +# ban_ip / unban_ip +# --------------------------------------------------------------------------- + + +class TestBanUnban: + """Unit tests for :func:`~app.services.jail_service.ban_ip` and + :func:`~app.services.jail_service.unban_ip`. + """ + + async def test_ban_ip_success(self) -> None: + """ban_ip sends the banip command for a valid IP.""" + with _patch_client({"set|sshd|banip|1.2.3.4": (0, 1)}): + await jail_service.ban_ip(_SOCKET, "sshd", "1.2.3.4") # should not raise + + async def test_ban_ip_invalid_raises(self) -> None: + """ban_ip raises ValueError for a non-IP value.""" + with pytest.raises(ValueError, match="Invalid IP"): + await jail_service.ban_ip(_SOCKET, "sshd", "not-an-ip") + + async def test_ban_ip_unknown_jail_exception_raises_jail_not_found(self) -> None: + """ban_ip raises JailNotFoundError when fail2ban returns UnknownJailException. + + fail2ban serialises the exception without a space (``UnknownJailException`` + rather than ``Unknown JailException``), so _is_not_found_error must match + the concatenated form ``"unknownjail``". + """ + response = (1, Exception("UnknownJailException('blocklist-import')")) + with ( + _patch_client({"set|missing-jail|banip|1.2.3.4": response}), + pytest.raises(JailNotFoundError, match="missing-jail"), + ): + await jail_service.ban_ip(_SOCKET, "missing-jail", "1.2.3.4") + + async def test_ban_ipv6_success(self) -> None: + """ban_ip accepts an IPv6 address.""" + with _patch_client({"set|sshd|banip|::1": (0, 1)}): + await jail_service.ban_ip(_SOCKET, "sshd", "::1") # should not raise + + async def test_unban_ip_all_jails(self) -> None: + """unban_ip with jail=None uses the global unban command.""" + with _patch_client({"unban|1.2.3.4": (0, 1)}): + await jail_service.unban_ip(_SOCKET, "1.2.3.4") # should not raise + + async def test_unban_ip_specific_jail(self) -> None: + """unban_ip with a jail sends the set unbanip command.""" + with _patch_client({"set|sshd|unbanip|1.2.3.4": (0, 1)}): + await jail_service.unban_ip(_SOCKET, "1.2.3.4", jail="sshd") # should not raise + + async def test_unban_invalid_ip_raises(self) -> None: + """unban_ip raises ValueError for an invalid IP.""" + with pytest.raises(ValueError, match="Invalid IP"): + await jail_service.unban_ip(_SOCKET, "bad-ip") + + +# --------------------------------------------------------------------------- +# get_active_bans +# --------------------------------------------------------------------------- + + +class TestGetActiveBans: + """Unit tests for :func:`~app.services.jail_service.get_active_bans`.""" + + async def test_returns_active_ban_list_response(self) -> None: + """get_active_bans returns an ActiveBanListResponse.""" + responses = { + "status": _make_global_status("sshd"), + "get|sshd|banip|--with-time": ( + 0, + ["1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"], + ), + } + with _patch_client(responses): + result = await jail_service.get_active_bans(_SOCKET) + + assert isinstance(result, ActiveBanListResponse) + assert result.total == 1 + assert result.bans[0].ip == "1.2.3.4" + assert result.bans[0].jail == "sshd" + + async def test_empty_when_no_jails(self) -> None: + """get_active_bans returns empty list when no jails are active.""" + responses = {"status": (0, [("Number of jail", 0), ("Jail list", "")])} + with _patch_client(responses): + result = await jail_service.get_active_bans(_SOCKET) + + assert result.total == 0 + assert result.bans == [] + + async def test_empty_when_no_bans(self) -> None: + """get_active_bans returns empty list when all jails have zero bans.""" + responses = { + "status": _make_global_status("sshd"), + "get|sshd|banip|--with-time": (0, []), + } + with _patch_client(responses): + result = await jail_service.get_active_bans(_SOCKET) + + assert result.total == 0 + + async def test_ban_time_parsed(self) -> None: + """get_active_bans populates banned_at and expires_at from the entry.""" + responses = { + "status": _make_global_status("sshd"), + "get|sshd|banip|--with-time": ( + 0, + ["10.0.0.1 \t2025-03-01 08:00:00 + 7200 = 2025-03-01 10:00:00"], + ), + } + with _patch_client(responses): + result = await jail_service.get_active_bans(_SOCKET) + + ban = result.bans[0] + assert ban.banned_at is not None + assert "2025-03-01" in ban.banned_at + assert ban.expires_at is not None + assert "2025-03-01" in ban.expires_at + + async def test_error_in_jail_tolerated(self) -> None: + """get_active_bans skips a jail that errors during the ban-list fetch.""" + responses = { + "status": _make_global_status("sshd, nginx"), + "get|sshd|banip|--with-time": ( + 0, + ["1.2.3.4 \t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"], + ), + "get|nginx|banip|--with-time": Fail2BanConnectionError("no nginx", _SOCKET), + } + + async def _side(*args: Any) -> Any: + key = "|".join(str(a) for a in args[0]) + resp = responses.get(key) + if isinstance(resp, Exception): + raise resp + if resp is None: + raise KeyError(f"Unexpected key {key!r}") + return resp + + class _FakeClientPartial: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_side) + + with patch("app.services.jail_service.Fail2BanClient", _FakeClientPartial): + result = await jail_service.get_active_bans(_SOCKET) + + # Only sshd ban returned (nginx silently skipped) + assert result.total == 1 + assert result.bans[0].jail == "sshd" + + async def test_http_session_triggers_lookup_batch(self) -> None: + """When http_session is provided, geo_service.lookup_batch is used.""" + from app.services.geo_service import GeoInfo + + responses = { + "status": _make_global_status("sshd"), + "get|sshd|banip|--with-time": ( + 0, + ["1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"], + ), + } + mock_geo = {"1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS1", org="ISP")} + + with ( + _patch_client(responses), + patch( + "app.services.geo_service.lookup_batch", + new=AsyncMock(return_value=mock_geo), + ) as mock_batch, + ): + mock_session = AsyncMock() + result = await jail_service.get_active_bans( + _SOCKET, http_session=mock_session + ) + + mock_batch.assert_awaited_once() + assert result.total == 1 + assert result.bans[0].country == "DE" + + async def test_http_session_batch_failure_graceful(self) -> None: + """When lookup_batch raises, get_active_bans returns bans without geo.""" + responses = { + "status": _make_global_status("sshd"), + "get|sshd|banip|--with-time": ( + 0, + ["1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"], + ), + } + + with ( + _patch_client(responses), + patch( + "app.services.geo_service.lookup_batch", + new=AsyncMock(side_effect=RuntimeError("geo down")), + ), + ): + mock_session = AsyncMock() + result = await jail_service.get_active_bans( + _SOCKET, http_session=mock_session + ) + + assert result.total == 1 + assert result.bans[0].country is None + + async def test_geo_enricher_still_used_without_http_session(self) -> None: + """Legacy geo_enricher is still called when http_session is not provided.""" + from app.services.geo_service import GeoInfo + + responses = { + "status": _make_global_status("sshd"), + "get|sshd|banip|--with-time": ( + 0, + ["1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00"], + ), + } + + async def _enricher(ip: str) -> GeoInfo | None: + return GeoInfo(country_code="JP", country_name="Japan", asn=None, org=None) + + with _patch_client(responses): + result = await jail_service.get_active_bans( + _SOCKET, geo_enricher=_enricher + ) + + assert result.total == 1 + assert result.bans[0].country == "JP" + + +# --------------------------------------------------------------------------- +# Ignore list +# --------------------------------------------------------------------------- + + +class TestIgnoreList: + """Unit tests for ignore list operations.""" + + async def test_get_ignore_list(self) -> None: + """get_ignore_list returns a list of IP strings.""" + with _patch_client({"get|sshd|ignoreip": (0, ["127.0.0.1", "10.0.0.0/8"])}): + result = await jail_service.get_ignore_list(_SOCKET, "sshd") + + assert "127.0.0.1" in result + assert "10.0.0.0/8" in result + + async def test_add_ignore_ip(self) -> None: + """add_ignore_ip sends addignoreip for a valid CIDR.""" + with _patch_client({"set|sshd|addignoreip|192.168.0.0/24": (0, "OK")}): + await jail_service.add_ignore_ip(_SOCKET, "sshd", "192.168.0.0/24") + + async def test_add_ignore_ip_invalid_raises(self) -> None: + """add_ignore_ip raises ValueError for an invalid CIDR.""" + with pytest.raises(ValueError, match="Invalid IP"): + await jail_service.add_ignore_ip(_SOCKET, "sshd", "not-a-cidr") + + async def test_del_ignore_ip(self) -> None: + """del_ignore_ip sends delignoreip command.""" + with _patch_client({"set|sshd|delignoreip|127.0.0.1": (0, "OK")}): + await jail_service.del_ignore_ip(_SOCKET, "sshd", "127.0.0.1") + + async def test_get_ignore_self(self) -> None: + """get_ignore_self returns a boolean.""" + with _patch_client({"get|sshd|ignoreself": (0, True)}): + result = await jail_service.get_ignore_self(_SOCKET, "sshd") + + assert result is True + + async def test_set_ignore_self_on(self) -> None: + """set_ignore_self sends ignoreself=true.""" + with _patch_client({"set|sshd|ignoreself|true": (0, True)}): + await jail_service.set_ignore_self(_SOCKET, "sshd", on=True) + + +# --------------------------------------------------------------------------- +# lookup_ip +# --------------------------------------------------------------------------- + + +class TestLookupIp: + """Unit tests for :func:`~app.services.jail_service.lookup_ip`.""" + + async def test_basic_lookup(self) -> None: + """lookup_ip returns currently_banned_in list.""" + responses = { + "get|--all|banned|1.2.3.4": (0, []), + "status": _make_global_status("sshd"), + "get|sshd|banip": (0, ["1.2.3.4", "5.6.7.8"]), + } + with _patch_client(responses): + result = await jail_service.lookup_ip(_SOCKET, "1.2.3.4") + + assert result["ip"] == "1.2.3.4" + assert "sshd" in result["currently_banned_in"] + + async def test_invalid_ip_raises(self) -> None: + """lookup_ip raises ValueError for invalid IP.""" + with pytest.raises(ValueError, match="Invalid IP"): + await jail_service.lookup_ip(_SOCKET, "not-an-ip") + + async def test_not_banned_returns_empty_list(self) -> None: + """lookup_ip returns empty currently_banned_in when IP is not banned.""" + responses = { + "get|--all|banned|9.9.9.9": (0, []), + "status": _make_global_status("sshd"), + "get|sshd|banip": (0, ["1.2.3.4"]), + } + with _patch_client(responses): + result = await jail_service.lookup_ip(_SOCKET, "9.9.9.9") + + assert result["currently_banned_in"] == [] + + +# --------------------------------------------------------------------------- +# unban_all_ips +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestUnbanAllIps: + """Tests for :func:`~app.services.jail_service.unban_all_ips`.""" + + async def test_unban_all_ips_returns_count(self) -> None: + """unban_all_ips returns the integer count from fail2ban.""" + responses = {"unban|--all": (0, 5)} + with _patch_client(responses): + count = await jail_service.unban_all_ips(_SOCKET) + + assert count == 5 + + async def test_unban_all_ips_returns_zero_when_none_banned(self) -> None: + """unban_all_ips returns 0 when no IPs are currently banned.""" + responses = {"unban|--all": (0, 0)} + with _patch_client(responses): + count = await jail_service.unban_all_ips(_SOCKET) + + assert count == 0 + + async def test_unban_all_ips_raises_on_connection_error(self) -> None: + """unban_all_ips propagates Fail2BanConnectionError.""" + with ( + patch( + "app.services.jail_service.Fail2BanClient", + side_effect=Fail2BanConnectionError("unreachable", _SOCKET), + ), + pytest.raises(Fail2BanConnectionError), + ): + await jail_service.unban_all_ips(_SOCKET) + + +# --------------------------------------------------------------------------- +# get_jail_banned_ips +# --------------------------------------------------------------------------- + +#: A raw ban entry string in the format produced by fail2ban --with-time. +_BAN_ENTRY_1 = "1.2.3.4\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" +_BAN_ENTRY_2 = "5.6.7.8\t2025-01-01 11:00:00 + 600 = 2025-01-01 11:10:00" +_BAN_ENTRY_3 = "9.10.11.12\t2025-01-01 12:00:00 + 600 = 2025-01-01 12:10:00" + + +def _banned_ips_responses(jail: str = "sshd", entries: list[str] | None = None) -> dict[str, Any]: + """Build mock responses for get_jail_banned_ips tests.""" + if entries is None: + entries = [_BAN_ENTRY_1, _BAN_ENTRY_2] + return { + f"status|{jail}|short": _make_short_status(), + f"get|{jail}|banip|--with-time": (0, entries), + } + + +class TestGetJailBannedIps: + """Unit tests for :func:`~app.services.jail_service.get_jail_banned_ips`.""" + + async def test_returns_jail_banned_ips_response(self) -> None: + """get_jail_banned_ips returns a JailBannedIpsResponse.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd") + + assert isinstance(result, JailBannedIpsResponse) + + async def test_total_reflects_all_entries(self) -> None: + """total equals the number of parsed ban entries.""" + with _patch_client(_banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3])): + result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd") + + assert result.total == 3 + + async def test_page_1_returns_first_n_items(self) -> None: + """page=1 with page_size=2 returns the first two entries.""" + with _patch_client( + _banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3]) + ): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=1, page_size=2 + ) + + assert len(result.items) == 2 + assert result.items[0].ip == "1.2.3.4" + assert result.items[1].ip == "5.6.7.8" + assert result.total == 3 + + async def test_page_2_returns_remaining_items(self) -> None: + """page=2 with page_size=2 returns the third entry.""" + with _patch_client( + _banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3]) + ): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=2, page_size=2 + ) + + assert len(result.items) == 1 + assert result.items[0].ip == "9.10.11.12" + + async def test_page_beyond_last_returns_empty_items(self) -> None: + """Requesting a page past the end returns an empty items list.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=99, page_size=25 + ) + + assert result.items == [] + assert result.total == 2 + + async def test_search_filter_narrows_results(self) -> None: + """search parameter filters entries by IP substring.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", search="1.2.3" + ) + + assert result.total == 1 + assert result.items[0].ip == "1.2.3.4" + + async def test_search_filter_case_insensitive(self) -> None: + """search filter is case-insensitive.""" + entries = ["192.168.0.1\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00"] + with _patch_client(_banned_ips_responses(entries=entries)): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", search="192.168" + ) + + assert result.total == 1 + + async def test_search_no_match_returns_empty(self) -> None: + """search that matches nothing returns empty items and total=0.""" + with _patch_client(_banned_ips_responses()): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", search="999.999" + ) + + assert result.total == 0 + assert result.items == [] + + async def test_empty_ban_list_returns_total_zero(self) -> None: + """get_jail_banned_ips handles an empty ban list gracefully.""" + responses = { + "status|sshd|short": _make_short_status(), + "get|sshd|banip|--with-time": (0, []), + } + with _patch_client(responses): + result = await jail_service.get_jail_banned_ips(_SOCKET, "sshd") + + assert result.total == 0 + assert result.items == [] + + async def test_page_size_clamped_to_max(self) -> None: + """page_size values above 100 are silently clamped to 100.""" + entries = [f"10.0.0.{i}\t2025-01-01 10:00:00 + 600 = 2025-01-01 10:10:00" for i in range(1, 101)] + responses = { + "status|sshd|short": _make_short_status(), + "get|sshd|banip|--with-time": (0, entries), + } + with _patch_client(responses): + result = await jail_service.get_jail_banned_ips( + _SOCKET, "sshd", page=1, page_size=200 + ) + + assert len(result.items) <= 100 + + async def test_geo_enrichment_called_for_page_slice_only(self) -> None: + """Geo enrichment is requested only for IPs in the current page.""" + from unittest.mock import MagicMock + + from app.services import geo_service + + http_session = MagicMock() + geo_enrichment_ips: list[list[str]] = [] + + async def _mock_lookup_batch( + ips: list[str], _session: Any, **_kw: Any + ) -> dict[str, Any]: + geo_enrichment_ips.append(list(ips)) + return {} + + with ( + _patch_client( + _banned_ips_responses(entries=[_BAN_ENTRY_1, _BAN_ENTRY_2, _BAN_ENTRY_3]) + ), + patch.object(geo_service, "lookup_batch", side_effect=_mock_lookup_batch), + ): + result = await jail_service.get_jail_banned_ips( + _SOCKET, + "sshd", + page=1, + page_size=2, + http_session=http_session, + ) + + # Only the 2-IP page slice should be passed to geo enrichment. + assert len(geo_enrichment_ips) == 1 + assert len(geo_enrichment_ips[0]) == 2 + assert result.total == 3 + + async def test_unknown_jail_raises_jail_not_found_error(self) -> None: + """get_jail_banned_ips raises JailNotFoundError for unknown jail.""" + responses = { + "status|ghost|short": (0, pytest.raises), # will be overridden + } + # Simulate fail2ban returning an "unknown jail" error. + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + pass + + async def send(self, command: list[Any]) -> Any: + raise ValueError("Unknown jail: ghost") + + with ( + patch("app.services.jail_service.Fail2BanClient", _FakeClient), + pytest.raises(JailNotFoundError), + ): + await jail_service.get_jail_banned_ips(_SOCKET, "ghost") + + async def test_connection_error_propagates(self) -> None: + """get_jail_banned_ips propagates Fail2BanConnectionError.""" + + class _FailClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock( + side_effect=Fail2BanConnectionError("no socket", _SOCKET) + ) + + with ( + patch("app.services.jail_service.Fail2BanClient", _FailClient), + pytest.raises(Fail2BanConnectionError), + ): + await jail_service.get_jail_banned_ips(_SOCKET, "sshd") diff --git a/backend/tests/test_services/test_server_service.py b/backend/tests/test_services/test_server_service.py new file mode 100644 index 0000000..0c9120b --- /dev/null +++ b/backend/tests/test_services/test_server_service.py @@ -0,0 +1,205 @@ +"""Tests for server_service functions.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from app.models.server import ServerSettingsResponse, ServerSettingsUpdate +from app.services import server_service +from app.services.server_service import ServerOperationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SOCKET = "/fake/fail2ban.sock" + +_DEFAULT_RESPONSES: dict[str, Any] = { + "get|loglevel": (0, "INFO"), + "get|logtarget": (0, "/var/log/fail2ban.log"), + "get|syslogsocket": (0, None), + "get|dbfile": (0, "/var/lib/fail2ban/fail2ban.sqlite3"), + "get|dbpurgeage": (0, 86400), + "get|dbmaxmatches": (0, 10), +} + + +def _make_send(responses: dict[str, Any]) -> AsyncMock: + async def _side_effect(command: list[Any]) -> Any: + key = "|".join(str(c) for c in command) + return responses.get(key, (0, None)) + + return AsyncMock(side_effect=_side_effect) + + +def _patch_client(responses: dict[str, Any]) -> Any: + mock_send = _make_send(responses) + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = mock_send + + return patch("app.services.server_service.Fail2BanClient", _FakeClient) + + +# --------------------------------------------------------------------------- +# get_settings +# --------------------------------------------------------------------------- + + +class TestGetSettings: + """Unit tests for :func:`~app.services.server_service.get_settings`.""" + + async def test_returns_server_settings_response(self) -> None: + """get_settings returns a properly populated ServerSettingsResponse.""" + with _patch_client(_DEFAULT_RESPONSES): + result = await server_service.get_settings(_SOCKET) + + assert isinstance(result, ServerSettingsResponse) + assert result.settings.log_level == "INFO" + assert result.settings.log_target == "/var/log/fail2ban.log" + assert result.settings.db_purge_age == 86400 + assert result.settings.db_max_matches == 10 + + async def test_db_path_parsed(self) -> None: + """get_settings returns the correct database file path.""" + with _patch_client(_DEFAULT_RESPONSES): + result = await server_service.get_settings(_SOCKET) + + assert result.settings.db_path == "/var/lib/fail2ban/fail2ban.sqlite3" + + async def test_syslog_socket_none(self) -> None: + """get_settings returns None for syslog_socket when not configured.""" + with _patch_client(_DEFAULT_RESPONSES): + result = await server_service.get_settings(_SOCKET) + + assert result.settings.syslog_socket is None + + async def test_fallback_defaults_on_missing_commands(self) -> None: + """get_settings uses fallback defaults when commands return None.""" + with _patch_client({}): + result = await server_service.get_settings(_SOCKET) + + assert result.settings.log_level == "INFO" + assert result.settings.db_max_matches == 10 + + +# --------------------------------------------------------------------------- +# update_settings +# --------------------------------------------------------------------------- + + +class TestUpdateSettings: + """Unit tests for :func:`~app.services.server_service.update_settings`.""" + + async def test_sends_set_commands_for_non_none_fields(self) -> None: + """update_settings sends set commands only for non-None fields.""" + sent: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = ServerSettingsUpdate(log_level="DEBUG", db_purge_age=3600) + with patch("app.services.server_service.Fail2BanClient", _FakeClient): + await server_service.update_settings(_SOCKET, update) + + keys = [cmd[1] for cmd in sent if len(cmd) >= 3] + assert "loglevel" in keys + assert "dbpurgeage" in keys + + async def test_skips_none_fields(self) -> None: + """update_settings does not send commands for None fields.""" + sent: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = ServerSettingsUpdate() # all None + with patch("app.services.server_service.Fail2BanClient", _FakeClient): + await server_service.update_settings(_SOCKET, update) + + assert sent == [] + + async def test_raises_server_operation_error_on_failure(self) -> None: + """update_settings raises ServerOperationError when fail2ban rejects.""" + + async def _send(command: list[Any]) -> Any: + return (1, "invalid log level") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = ServerSettingsUpdate(log_level="INVALID") + with patch("app.services.server_service.Fail2BanClient", _FakeClient), pytest.raises(ServerOperationError): + await server_service.update_settings(_SOCKET, update) + + async def test_uppercases_log_level(self) -> None: + """update_settings uppercases the log_level value before sending.""" + sent: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = ServerSettingsUpdate(log_level="warning") + with patch("app.services.server_service.Fail2BanClient", _FakeClient): + await server_service.update_settings(_SOCKET, update) + + cmd = next(c for c in sent if len(c) >= 3 and c[1] == "loglevel") + assert cmd[2] == "WARNING" + + +# --------------------------------------------------------------------------- +# flush_logs +# --------------------------------------------------------------------------- + + +class TestFlushLogs: + """Unit tests for :func:`~app.services.server_service.flush_logs`.""" + + async def test_returns_result_string(self) -> None: + """flush_logs returns the string response from fail2ban.""" + + async def _send(command: list[Any]) -> Any: + assert command == ["flushlogs"] + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + with patch("app.services.server_service.Fail2BanClient", _FakeClient): + result = await server_service.flush_logs(_SOCKET) + + assert result == "OK" + + async def test_raises_operation_error_on_failure(self) -> None: + """flush_logs raises ServerOperationError when fail2ban rejects.""" + + async def _send(command: list[Any]) -> Any: + return (1, "flushlogs failed") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + with patch("app.services.server_service.Fail2BanClient", _FakeClient), pytest.raises(ServerOperationError): + await server_service.flush_logs(_SOCKET) diff --git a/backend/tests/test_services/test_setup_service.py b/backend/tests/test_services/test_setup_service.py new file mode 100644 index 0000000..2de760c --- /dev/null +++ b/backend/tests/test_services/test_setup_service.py @@ -0,0 +1,235 @@ +"""Tests for setup_service and settings_repo.""" + +from __future__ import annotations + +import asyncio +import inspect +from pathlib import Path + +import aiosqlite +import pytest + +from app.db import init_db +from app.repositories import settings_repo +from app.services import setup_service + + +@pytest.fixture +async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] + """Provide an initialised aiosqlite connection for service-level tests.""" + conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "test.db")) + conn.row_factory = aiosqlite.Row + await init_db(conn) + yield conn + await conn.close() + + +class TestIsSetupComplete: + async def test_returns_false_on_fresh_db( + self, db: aiosqlite.Connection + ) -> None: + """Setup is not complete on a fresh database.""" + assert await setup_service.is_setup_complete(db) is False + + async def test_returns_true_after_run_setup( + self, db: aiosqlite.Connection + ) -> None: + """Setup is marked complete after run_setup() succeeds.""" + await setup_service.run_setup( + db, + master_password="mypassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="UTC", + session_duration_minutes=60, + ) + assert await setup_service.is_setup_complete(db) is True + + +class TestRunSetup: + async def test_persists_all_settings(self, db: aiosqlite.Connection) -> None: + """run_setup() stores every provided setting.""" + await setup_service.run_setup( + db, + master_password="mypassword1", + database_path="/data/bangui.db", + fail2ban_socket="/tmp/f2b.sock", + timezone="Europe/Berlin", + session_duration_minutes=120, + ) + all_settings = await settings_repo.get_all_settings(db) + assert all_settings["database_path"] == "/data/bangui.db" + assert all_settings["fail2ban_socket"] == "/tmp/f2b.sock" + assert all_settings["timezone"] == "Europe/Berlin" + assert all_settings["session_duration_minutes"] == "120" + + async def test_password_stored_as_bcrypt_hash( + self, db: aiosqlite.Connection + ) -> None: + """The master password is stored as a bcrypt hash, not plain text.""" + import bcrypt + + await setup_service.run_setup( + db, + master_password="mypassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="UTC", + session_duration_minutes=60, + ) + stored = await setup_service.get_password_hash(db) + assert stored is not None + assert stored != "mypassword1" + # Verify it is a valid bcrypt hash. + assert bcrypt.checkpw(b"mypassword1", stored.encode()) + + async def test_raises_if_setup_already_complete( + self, db: aiosqlite.Connection + ) -> None: + """run_setup() raises RuntimeError if called a second time.""" + kwargs = { + "master_password": "mypassword1", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, + } + await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="already been completed"): + await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type] + + async def test_initializes_map_color_thresholds_with_defaults( + self, db: aiosqlite.Connection + ) -> None: + """run_setup() initializes map color thresholds with default values.""" + await setup_service.run_setup( + db, + master_password="mypassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="UTC", + session_duration_minutes=60, + ) + high, medium, low = await setup_service.get_map_color_thresholds(db) + assert high == 100 + assert medium == 50 + assert low == 20 + + +class TestGetTimezone: + async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None: + """get_timezone() returns 'UTC' before setup is run.""" + assert await setup_service.get_timezone(db) == "UTC" + + async def test_returns_configured_timezone( + self, db: aiosqlite.Connection + ) -> None: + """get_timezone() returns the value set during setup.""" + await setup_service.run_setup( + db, + master_password="mypassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="America/New_York", + session_duration_minutes=60, + ) + assert await setup_service.get_timezone(db) == "America/New_York" + + +class TestMapColorThresholds: + async def test_get_map_color_thresholds_returns_defaults_on_fresh_db( + self, db: aiosqlite.Connection + ) -> None: + """get_map_color_thresholds() returns default values on a fresh database.""" + high, medium, low = await setup_service.get_map_color_thresholds(db) + assert high == 100 + assert medium == 50 + assert low == 20 + + async def test_set_map_color_thresholds_persists_values( + self, db: aiosqlite.Connection + ) -> None: + """set_map_color_thresholds() stores and retrieves custom values.""" + await setup_service.set_map_color_thresholds( + db, threshold_high=200, threshold_medium=80, threshold_low=30 + ) + high, medium, low = await setup_service.get_map_color_thresholds(db) + assert high == 200 + assert medium == 80 + assert low == 30 + + async def test_set_map_color_thresholds_rejects_non_positive( + self, db: aiosqlite.Connection + ) -> None: + """set_map_color_thresholds() raises ValueError for non-positive thresholds.""" + with pytest.raises(ValueError, match="positive integers"): + await setup_service.set_map_color_thresholds( + db, threshold_high=100, threshold_medium=50, threshold_low=0 + ) + with pytest.raises(ValueError, match="positive integers"): + await setup_service.set_map_color_thresholds( + db, threshold_high=-10, threshold_medium=50, threshold_low=20 + ) + + async def test_set_map_color_thresholds_rejects_invalid_order( + self, db: aiosqlite.Connection + ) -> None: + """ + set_map_color_thresholds() rejects invalid ordering. + """ + with pytest.raises(ValueError, match="high > medium > low"): + await setup_service.set_map_color_thresholds( + db, threshold_high=50, threshold_medium=50, threshold_low=20 + ) + with pytest.raises(ValueError, match="high > medium > low"): + await setup_service.set_map_color_thresholds( + db, threshold_high=100, threshold_medium=30, threshold_low=50 + ) + + async def test_run_setup_initializes_default_thresholds( + self, db: aiosqlite.Connection + ) -> None: + """run_setup() initializes map color thresholds with defaults.""" + await setup_service.run_setup( + db, + master_password="mypassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="UTC", + session_duration_minutes=60, + ) + high, medium, low = await setup_service.get_map_color_thresholds(db) + assert high == 100 + assert medium == 50 + assert low == 20 + + +class TestRunSetupAsync: + """Verify the async/non-blocking bcrypt behavior of run_setup.""" + + async def test_run_setup_is_coroutine_function(self) -> None: + """run_setup must be declared as an async function.""" + assert inspect.iscoroutinefunction(setup_service.run_setup) + + async def test_password_hash_does_not_block_event_loop( + self, db: aiosqlite.Connection + ) -> None: + """run_setup completes without blocking; other coroutines can interleave.""" + + async def noop() -> str: + """A trivial coroutine that should run concurrently with setup.""" + await asyncio.sleep(0) + return "ok" + + setup_coro = setup_service.run_setup( + db, + master_password="mypassword1", + database_path="bangui.db", + fail2ban_socket="/var/run/fail2ban/fail2ban.sock", + timezone="UTC", + session_duration_minutes=60, + ) + # Both tasks should finish without error. + results = await asyncio.gather(setup_coro, noop()) + assert results[1] == "ok" + assert await setup_service.is_setup_complete(db) is True diff --git a/backend/tests/test_services/test_time_utils.py b/backend/tests/test_services/test_time_utils.py new file mode 100644 index 0000000..bb4ffa6 --- /dev/null +++ b/backend/tests/test_services/test_time_utils.py @@ -0,0 +1,79 @@ +"""Tests for app.utils.time_utils.""" + +import datetime + +from app.utils.time_utils import add_minutes, hours_ago, is_expired, utc_from_timestamp, utc_now + + +class TestUtcNow: + """Tests for :func:`utc_now`.""" + + def test_utc_now_returns_timezone_aware_datetime(self) -> None: + result = utc_now() + assert result.tzinfo is not None + + def test_utc_now_timezone_is_utc(self) -> None: + result = utc_now() + assert result.tzinfo == datetime.UTC + + def test_utc_now_is_recent(self) -> None: + before = datetime.datetime.now(datetime.UTC) + result = utc_now() + after = datetime.datetime.now(datetime.UTC) + assert before <= result <= after + + +class TestUtcFromTimestamp: + """Tests for :func:`utc_from_timestamp`.""" + + def test_utc_from_timestamp_epoch_returns_utc_epoch(self) -> None: + result = utc_from_timestamp(0.0) + assert result == datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC) + + def test_utc_from_timestamp_returns_aware_datetime(self) -> None: + result = utc_from_timestamp(1_000_000_000.0) + assert result.tzinfo is not None + + +class TestAddMinutes: + """Tests for :func:`add_minutes`.""" + + def test_add_minutes_positive(self) -> None: + dt = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC) + result = add_minutes(dt, 30) + expected = datetime.datetime(2024, 1, 1, 12, 30, 0, tzinfo=datetime.UTC) + assert result == expected + + def test_add_minutes_negative(self) -> None: + dt = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC) + result = add_minutes(dt, -60) + expected = datetime.datetime(2024, 1, 1, 11, 0, 0, tzinfo=datetime.UTC) + assert result == expected + + +class TestIsExpired: + """Tests for :func:`is_expired`.""" + + def test_is_expired_past_timestamp_returns_true(self) -> None: + past = datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC) + assert is_expired(past) is True + + def test_is_expired_future_timestamp_returns_false(self) -> None: + future = datetime.datetime(2099, 1, 1, tzinfo=datetime.UTC) + assert is_expired(future) is False + + +class TestHoursAgo: + """Tests for :func:`hours_ago`.""" + + def test_hours_ago_returns_past_datetime(self) -> None: + result = hours_ago(24) + assert result < utc_now() + + def test_hours_ago_correct_delta(self) -> None: + before = utc_now() + result = hours_ago(1) + after = utc_now() + expected_min = before - datetime.timedelta(hours=1, seconds=1) + expected_max = after - datetime.timedelta(hours=1) + datetime.timedelta(seconds=1) + assert expected_min <= result <= expected_max diff --git a/backend/tests/test_tasks/__init__.py b/backend/tests/test_tasks/__init__.py new file mode 100644 index 0000000..ccfed15 --- /dev/null +++ b/backend/tests/test_tasks/__init__.py @@ -0,0 +1 @@ +"""APScheduler task tests package.""" diff --git a/backend/tests/test_tasks/test_blocklist_import.py b/backend/tests/test_tasks/test_blocklist_import.py new file mode 100644 index 0000000..b512601 --- /dev/null +++ b/backend/tests/test_tasks/test_blocklist_import.py @@ -0,0 +1,352 @@ +"""Tests for the blocklist import background task. + +Validates that :func:`~app.tasks.blocklist_import._run_import` correctly +delegates to :func:`~app.services.blocklist_service.import_all`, handles +unexpected exceptions gracefully, and that :func:`~app.tasks.blocklist_import._apply_schedule` +registers the correct APScheduler trigger for each frequency preset. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from app.models.blocklist import ImportRunResult, ScheduleConfig, ScheduleFrequency +from app.tasks.blocklist_import import JOB_ID, _apply_schedule, _run_import + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app( + import_result: ImportRunResult | None = None, + import_side_effect: Exception | None = None, +) -> MagicMock: + """Build a minimal mock ``app`` for blocklist import task tests. + + Args: + import_result: Value returned by the mocked ``import_all`` call. + import_side_effect: If provided, ``import_all`` raises this exception. + + Returns: + A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``. + """ + app = MagicMock() + app.state.db = MagicMock() + app.state.http_session = MagicMock() + app.state.settings.fail2ban_socket = "/var/run/fail2ban/fail2ban.sock" + return app + + +def _make_import_result( + total_imported: int = 50, + total_skipped: int = 5, + errors_count: int = 0, +) -> ImportRunResult: + """Construct a minimal :class:`ImportRunResult` for testing. + + Args: + total_imported: Number of IPs successfully imported. + total_skipped: Number of skipped entries. + errors_count: Number of sources that encountered errors. + + Returns: + An :class:`ImportRunResult` with the given counters. + """ + return ImportRunResult( + results=[], + total_imported=total_imported, + total_skipped=total_skipped, + errors_count=errors_count, + ) + + +def _make_scheduler(has_existing_job: bool = False) -> MagicMock: + """Build a mock APScheduler-like object. + + Args: + has_existing_job: Whether ``get_job(JOB_ID)`` should return a truthy value. + + Returns: + A :class:`unittest.mock.MagicMock` that mimics a scheduler. + """ + scheduler = MagicMock() + scheduler.get_job.return_value = MagicMock() if has_existing_job else None + return scheduler + + +# --------------------------------------------------------------------------- +# Tests for _run_import +# --------------------------------------------------------------------------- + + +class TestRunImport: + """Tests for :func:`~app.tasks.blocklist_import._run_import`.""" + + @pytest.mark.asyncio + async def test_run_import_happy_path_calls_import_all(self) -> None: + """``_run_import`` must delegate to ``blocklist_service.import_all``.""" + app = _make_app() + result = _make_import_result(total_imported=100, total_skipped=2, errors_count=0) + + with patch( + "app.tasks.blocklist_import.blocklist_service.import_all", + new_callable=AsyncMock, + return_value=result, + ) as mock_import_all: + await _run_import(app) + + mock_import_all.assert_awaited_once_with( + app.state.db, + app.state.http_session, + app.state.settings.fail2ban_socket, + ) + + @pytest.mark.asyncio + async def test_run_import_logs_counters_on_success(self) -> None: + """``_run_import`` must emit a structured log event with import counters.""" + app = _make_app() + result = _make_import_result(total_imported=42, total_skipped=3, errors_count=1) + + with patch( + "app.tasks.blocklist_import.blocklist_service.import_all", + new_callable=AsyncMock, + return_value=result, + ), patch("app.tasks.blocklist_import.log") as mock_log: + await _run_import(app) + + info_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "blocklist_import_finished"] + assert len(info_calls) == 1 + kwargs = info_calls[0][1] + assert kwargs["total_imported"] == 42 + assert kwargs["total_skipped"] == 3 + assert kwargs["errors"] == 1 + + @pytest.mark.asyncio + async def test_run_import_logs_start_event(self) -> None: + """``_run_import`` must emit a ``blocklist_import_starting`` event.""" + app = _make_app() + result = _make_import_result() + + with patch( + "app.tasks.blocklist_import.blocklist_service.import_all", + new_callable=AsyncMock, + return_value=result, + ), patch("app.tasks.blocklist_import.log") as mock_log: + await _run_import(app) + + start_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "blocklist_import_starting"] + assert len(start_calls) == 1 + + @pytest.mark.asyncio + async def test_run_import_handles_unexpected_exception(self) -> None: + """``_run_import`` must catch unexpected exceptions and log them.""" + app = _make_app() + + with patch( + "app.tasks.blocklist_import.blocklist_service.import_all", + new_callable=AsyncMock, + side_effect=RuntimeError("unexpected failure"), + ), patch("app.tasks.blocklist_import.log") as mock_log: + # Must not raise — the task swallows unexpected errors. + await _run_import(app) + + mock_log.exception.assert_called_once_with("blocklist_import_unexpected_error") + + +# --------------------------------------------------------------------------- +# Tests for _apply_schedule +# --------------------------------------------------------------------------- + + +class TestApplySchedule: + """Tests for :func:`~app.tasks.blocklist_import._apply_schedule`.""" + + def _make_app_with_scheduler(self, scheduler: Any) -> MagicMock: + """Return a mock ``app`` whose ``state.scheduler`` is *scheduler*. + + Args: + scheduler: Mock scheduler object. + + Returns: + Mock FastAPI application instance. + """ + app = MagicMock() + app.state.scheduler = scheduler + return app + + def test_apply_schedule_daily_registers_cron_trigger(self) -> None: + """Daily frequency must register a ``"cron"`` trigger with hour and minute.""" + scheduler = _make_scheduler() + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig(frequency=ScheduleFrequency.daily, hour=3, minute=0) + + _apply_schedule(app, config) + + scheduler.add_job.assert_called_once() + _, kwargs = scheduler.add_job.call_args + assert kwargs["trigger"] == "cron" + assert kwargs["hour"] == 3 + assert kwargs["minute"] == 0 + assert "day_of_week" not in kwargs + + def test_apply_schedule_hourly_registers_interval_trigger(self) -> None: + """Hourly frequency must register an ``"interval"`` trigger with correct hours.""" + scheduler = _make_scheduler() + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig(frequency=ScheduleFrequency.hourly, interval_hours=6) + + _apply_schedule(app, config) + + scheduler.add_job.assert_called_once() + _, kwargs = scheduler.add_job.call_args + assert kwargs["trigger"] == "interval" + assert kwargs["hours"] == 6 + + def test_apply_schedule_weekly_registers_cron_trigger_with_day(self) -> None: + """Weekly frequency must register a ``"cron"`` trigger including ``day_of_week``.""" + scheduler = _make_scheduler() + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig( + frequency=ScheduleFrequency.weekly, + day_of_week=0, + hour=4, + minute=30, + ) + + _apply_schedule(app, config) + + scheduler.add_job.assert_called_once() + _, kwargs = scheduler.add_job.call_args + assert kwargs["trigger"] == "cron" + assert kwargs["day_of_week"] == 0 + assert kwargs["hour"] == 4 + assert kwargs["minute"] == 30 + + def test_apply_schedule_removes_existing_job_before_adding(self) -> None: + """If a job with ``JOB_ID`` exists, it must be removed before a new one is added.""" + scheduler = _make_scheduler(has_existing_job=True) + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig(frequency=ScheduleFrequency.daily) + + _apply_schedule(app, config) + + scheduler.remove_job.assert_called_once_with(JOB_ID) + assert scheduler.remove_job.call_args_list.index(call(JOB_ID)) < len( + scheduler.add_job.call_args_list + ) + + def test_apply_schedule_skips_remove_when_no_existing_job(self) -> None: + """If no job exists, ``remove_job`` must not be called.""" + scheduler = _make_scheduler(has_existing_job=False) + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig(frequency=ScheduleFrequency.daily) + + _apply_schedule(app, config) + + scheduler.remove_job.assert_not_called() + + def test_apply_schedule_uses_stable_job_id(self) -> None: + """The registered job must use the module-level ``JOB_ID`` constant.""" + scheduler = _make_scheduler() + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig(frequency=ScheduleFrequency.daily) + + _apply_schedule(app, config) + + _, kwargs = scheduler.add_job.call_args + assert kwargs["id"] == JOB_ID + + def test_apply_schedule_passes_app_in_kwargs(self) -> None: + """The scheduled job must receive ``app`` as a kwarg for state access.""" + scheduler = _make_scheduler() + app = self._make_app_with_scheduler(scheduler) + config = ScheduleConfig(frequency=ScheduleFrequency.daily) + + _apply_schedule(app, config) + + _, kwargs = scheduler.add_job.call_args + assert kwargs["kwargs"] == {"app": app} + + +# --------------------------------------------------------------------------- +# Tests for register / reschedule +# --------------------------------------------------------------------------- + + +class TestRegister: + """Tests for :func:`~app.tasks.blocklist_import.register`.""" + + def test_register_calls_apply_schedule_via_event_loop(self) -> None: + """``register`` must call ``_apply_schedule`` after reading the stored config.""" + import asyncio + + from app.tasks.blocklist_import import register + + app = MagicMock() + app.state.db = MagicMock() + app.state.scheduler = MagicMock() + app.state.scheduler.get_job.return_value = None + + config = ScheduleConfig(frequency=ScheduleFrequency.daily, hour=3, minute=0) + + with patch( + "app.tasks.blocklist_import.blocklist_service.get_schedule", + new_callable=AsyncMock, + return_value=config, + ), patch("app.tasks.blocklist_import._apply_schedule") as mock_apply: + # Use a fresh event loop to avoid interference from pytest-asyncio. + loop = asyncio.new_event_loop() + try: + with patch("asyncio.get_event_loop", return_value=loop): + register(app) + finally: + loop.close() + + mock_apply.assert_called_once_with(app, config) + + def test_register_falls_back_to_ensure_future_on_runtime_error(self) -> None: + """When ``run_until_complete`` raises ``RuntimeError``, ``ensure_future`` is used.""" + from app.tasks.blocklist_import import register + + app = MagicMock() + app.state.db = MagicMock() + app.state.scheduler = MagicMock() + + config = ScheduleConfig(frequency=ScheduleFrequency.daily) + + mock_loop = MagicMock() + mock_loop.run_until_complete.side_effect = RuntimeError("already running") + + with ( + patch( + "app.tasks.blocklist_import.blocklist_service.get_schedule", + new_callable=AsyncMock, + return_value=config, + ), + patch("asyncio.get_event_loop", return_value=mock_loop), + patch("asyncio.ensure_future") as mock_ensure_future, + ): + register(app) + + mock_ensure_future.assert_called_once() + + +class TestReschedule: + """Tests for :func:`~app.tasks.blocklist_import.reschedule`.""" + + def test_reschedule_calls_ensure_future(self) -> None: + """``reschedule`` must schedule the re-registration with ``asyncio.ensure_future``.""" + from app.tasks.blocklist_import import reschedule + + app = MagicMock() + app.state.db = MagicMock() + app.state.scheduler = MagicMock() + + with patch("asyncio.ensure_future") as mock_ensure_future: + reschedule(app) + + mock_ensure_future.assert_called_once() diff --git a/backend/tests/test_tasks/test_geo_cache_flush.py b/backend/tests/test_tasks/test_geo_cache_flush.py new file mode 100644 index 0000000..ab65a39 --- /dev/null +++ b/backend/tests/test_tasks/test_geo_cache_flush.py @@ -0,0 +1,136 @@ +"""Tests for the geo cache flush background task. + +Validates that :func:`~app.tasks.geo_cache_flush._run_flush` correctly +delegates to :func:`~app.services.geo_service.flush_dirty` and only logs +when entries were actually flushed, and that +:func:`~app.tasks.geo_cache_flush.register` configures the APScheduler job +with the correct interval and stable job ID. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.tasks.geo_cache_flush import GEO_FLUSH_INTERVAL, JOB_ID, _run_flush, register + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app(flush_count: int = 0) -> MagicMock: + """Build a minimal mock ``app`` for geo cache flush task tests. + + Args: + flush_count: The value returned by the mocked ``flush_dirty`` call. + + Returns: + A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``. + """ + app = MagicMock() + app.state.db = MagicMock() + app.state.scheduler = MagicMock() + return app + + +# --------------------------------------------------------------------------- +# Tests for _run_flush +# --------------------------------------------------------------------------- + + +class TestRunFlush: + """Tests for :func:`~app.tasks.geo_cache_flush._run_flush`.""" + + @pytest.mark.asyncio + async def test_run_flush_calls_flush_dirty_with_db(self) -> None: + """``_run_flush`` must call ``geo_service.flush_dirty`` with ``app.state.db``.""" + app = _make_app() + + with patch( + "app.tasks.geo_cache_flush.geo_service.flush_dirty", + new_callable=AsyncMock, + return_value=0, + ) as mock_flush: + await _run_flush(app) + + mock_flush.assert_awaited_once_with(app.state.db) + + @pytest.mark.asyncio + async def test_run_flush_logs_when_entries_flushed(self) -> None: + """``_run_flush`` must emit a debug log when ``flush_dirty`` returns > 0.""" + app = _make_app() + + with patch( + "app.tasks.geo_cache_flush.geo_service.flush_dirty", + new_callable=AsyncMock, + return_value=15, + ), patch("app.tasks.geo_cache_flush.log") as mock_log: + await _run_flush(app) + + debug_calls = [c for c in mock_log.debug.call_args_list if c[0][0] == "geo_cache_flush_ran"] + assert len(debug_calls) == 1 + assert debug_calls[0][1]["flushed"] == 15 + + @pytest.mark.asyncio + async def test_run_flush_does_not_log_when_nothing_to_flush(self) -> None: + """``_run_flush`` must not emit any log when ``flush_dirty`` returns 0.""" + app = _make_app() + + with patch( + "app.tasks.geo_cache_flush.geo_service.flush_dirty", + new_callable=AsyncMock, + return_value=0, + ), patch("app.tasks.geo_cache_flush.log") as mock_log: + await _run_flush(app) + + debug_calls = [c for c in mock_log.debug.call_args_list if c[0][0] == "geo_cache_flush_ran"] + assert debug_calls == [] + + +# --------------------------------------------------------------------------- +# Tests for register +# --------------------------------------------------------------------------- + + +class TestRegister: + """Tests for :func:`~app.tasks.geo_cache_flush.register`.""" + + def test_register_adds_interval_job_to_scheduler(self) -> None: + """``register`` must add a job with an ``"interval"`` trigger.""" + app = _make_app() + + register(app) + + app.state.scheduler.add_job.assert_called_once() + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["trigger"] == "interval" + assert kwargs["seconds"] == GEO_FLUSH_INTERVAL + + def test_register_uses_stable_job_id(self) -> None: + """``register`` must use the module-level ``JOB_ID`` constant.""" + app = _make_app() + + register(app) + + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["id"] == JOB_ID + + def test_register_sets_replace_existing(self) -> None: + """``register`` must use ``replace_existing=True`` to avoid duplicate jobs.""" + app = _make_app() + + register(app) + + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["replace_existing"] is True + + def test_register_passes_app_in_kwargs(self) -> None: + """The scheduled job must receive ``app`` as a kwarg for state access.""" + app = _make_app() + + register(app) + + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["kwargs"] == {"app": app} diff --git a/backend/tests/test_tasks/test_geo_re_resolve.py b/backend/tests/test_tasks/test_geo_re_resolve.py new file mode 100644 index 0000000..23ceb66 --- /dev/null +++ b/backend/tests/test_tasks/test_geo_re_resolve.py @@ -0,0 +1,167 @@ +"""Tests for the geo re-resolve background task. + +Validates that :func:`~app.tasks.geo_re_resolve._run_re_resolve` correctly +queries NULL-country IPs from the database, clears the negative cache, and +delegates to :func:`~app.services.geo_service.lookup_batch` for a fresh +resolution attempt. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.geo_service import GeoInfo +from app.tasks.geo_re_resolve import _run_re_resolve + + +class _AsyncRowIterator: + """Minimal async iterator over a list of row tuples.""" + + def __init__(self, rows: list[tuple[str]]) -> None: + self._iter = iter(rows) + + def __aiter__(self) -> _AsyncRowIterator: + return self + + async def __anext__(self) -> tuple[str]: + try: + return next(self._iter) + except StopIteration: + raise StopAsyncIteration # noqa: B904 + + +def _make_app( + unresolved_ips: list[str], + lookup_result: dict[str, GeoInfo] | None = None, +) -> MagicMock: + """Build a minimal mock ``app`` with ``state.db`` and ``state.http_session``. + + The mock database returns *unresolved_ips* when the re-resolve task + queries ``SELECT ip FROM geo_cache WHERE country_code IS NULL``. + + Args: + unresolved_ips: IPs to return from the mocked DB query. + lookup_result: Value returned by the mocked ``lookup_batch``. + Defaults to an empty dict. + + Returns: + A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``. + """ + if lookup_result is None: + lookup_result = {} + + rows = [(ip,) for ip in unresolved_ips] + cursor = _AsyncRowIterator(rows) + + # db.execute() returns an async context manager yielding the cursor. + ctx = AsyncMock() + ctx.__aenter__ = AsyncMock(return_value=cursor) + ctx.__aexit__ = AsyncMock(return_value=False) + + db = AsyncMock() + db.execute = MagicMock(return_value=ctx) + + http_session = MagicMock() + + app = MagicMock() + app.state.db = db + app.state.http_session = http_session + + return app + + +@pytest.mark.asyncio +async def test_run_re_resolve_no_unresolved_ips_skips() -> None: + """The task should return immediately when no NULL-country IPs exist.""" + app = _make_app(unresolved_ips=[]) + + with patch("app.tasks.geo_re_resolve.geo_service") as mock_geo: + await _run_re_resolve(app) + + mock_geo.clear_neg_cache.assert_not_called() + mock_geo.lookup_batch.assert_not_called() + + +@pytest.mark.asyncio +async def test_run_re_resolve_clears_neg_cache() -> None: + """The task must clear the negative cache before calling lookup_batch.""" + ips = ["1.2.3.4", "5.6.7.8"] + result: dict[str, GeoInfo] = { + "1.2.3.4": GeoInfo(country_code="DE", country_name="Germany", asn="AS3320", org="DTAG"), + "5.6.7.8": GeoInfo(country_code="US", country_name="United States", asn="AS15169", org="Google"), + } + app = _make_app(unresolved_ips=ips, lookup_result=result) + + with patch("app.tasks.geo_re_resolve.geo_service") as mock_geo: + mock_geo.lookup_batch = AsyncMock(return_value=result) + + await _run_re_resolve(app) + + mock_geo.clear_neg_cache.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_re_resolve_calls_lookup_batch_with_db() -> None: + """The task must pass the real db to lookup_batch for persistence.""" + ips = ["10.0.0.1", "10.0.0.2"] + result: dict[str, GeoInfo] = { + "10.0.0.1": GeoInfo(country_code="FR", country_name="France", asn=None, org=None), + "10.0.0.2": GeoInfo(country_code=None, country_name=None, asn=None, org=None), + } + app = _make_app(unresolved_ips=ips, lookup_result=result) + + with patch("app.tasks.geo_re_resolve.geo_service") as mock_geo: + mock_geo.lookup_batch = AsyncMock(return_value=result) + + await _run_re_resolve(app) + + mock_geo.lookup_batch.assert_called_once_with( + ips, + app.state.http_session, + db=app.state.db, + ) + + +@pytest.mark.asyncio +async def test_run_re_resolve_logs_correct_counts(caplog: Any) -> None: + """The task should log the number retried and number resolved.""" + ips = ["1.1.1.1", "2.2.2.2", "3.3.3.3"] + result: dict[str, GeoInfo] = { + "1.1.1.1": GeoInfo(country_code="AU", country_name="Australia", asn=None, org=None), + "2.2.2.2": GeoInfo(country_code="JP", country_name="Japan", asn=None, org=None), + "3.3.3.3": GeoInfo(country_code=None, country_name=None, asn=None, org=None), + } + app = _make_app(unresolved_ips=ips, lookup_result=result) + + with patch("app.tasks.geo_re_resolve.geo_service") as mock_geo: + mock_geo.lookup_batch = AsyncMock(return_value=result) + + await _run_re_resolve(app) + + # Verify lookup_batch was called (the logging assertions rely on + # structlog which is hard to capture in caplog; instead we verify + # the function ran to completion and the counts are correct by + # checking that lookup_batch received the right number of IPs). + call_args = mock_geo.lookup_batch.call_args + assert len(call_args[0][0]) == 3 + + +@pytest.mark.asyncio +async def test_run_re_resolve_handles_all_resolved() -> None: + """When every IP resolves successfully the task should complete normally.""" + ips = ["4.4.4.4"] + result: dict[str, GeoInfo] = { + "4.4.4.4": GeoInfo(country_code="GB", country_name="United Kingdom", asn=None, org=None), + } + app = _make_app(unresolved_ips=ips, lookup_result=result) + + with patch("app.tasks.geo_re_resolve.geo_service") as mock_geo: + mock_geo.lookup_batch = AsyncMock(return_value=result) + + await _run_re_resolve(app) + + mock_geo.clear_neg_cache.assert_called_once() + mock_geo.lookup_batch.assert_called_once() diff --git a/backend/tests/test_tasks/test_health_check.py b/backend/tests/test_tasks/test_health_check.py new file mode 100644 index 0000000..4a8512b --- /dev/null +++ b/backend/tests/test_tasks/test_health_check.py @@ -0,0 +1,350 @@ +"""Tests for the health-check background task. + +Validates that :func:`~app.tasks.health_check._run_probe` correctly stores +the probe result on ``app.state.server_status``, logs online/offline +transitions, and that :func:`~app.tasks.health_check.register` configures +the scheduler and primes the initial status. +""" + +from __future__ import annotations + +import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.models.config import PendingRecovery +from app.models.server import ServerStatus +from app.tasks.health_check import HEALTH_CHECK_INTERVAL, _run_probe, register + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_app(prev_online: bool = False) -> MagicMock: + """Build a minimal mock ``app`` for health-check task tests. + + Args: + prev_online: Whether the previous ``server_status`` was online. + + Returns: + A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``. + """ + app = MagicMock() + app.state.settings.fail2ban_socket = "/var/run/fail2ban/fail2ban.sock" + app.state.server_status = ServerStatus(online=prev_online) + app.state.scheduler = MagicMock() + app.state.last_activation = None + app.state.pending_recovery = None + return app + + +# --------------------------------------------------------------------------- +# Tests for _run_probe +# --------------------------------------------------------------------------- + + +class TestRunProbe: + """Tests for :func:`~app.tasks.health_check._run_probe`.""" + + @pytest.mark.asyncio + async def test_run_probe_updates_server_status(self) -> None: + """``_run_probe`` must store the probe result on ``app.state.server_status``.""" + app = _make_app(prev_online=False) + new_status = ServerStatus(online=True, version="0.11.2", active_jails=3) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ): + await _run_probe(app) + + assert app.state.server_status is new_status + + @pytest.mark.asyncio + async def test_run_probe_logs_came_online_transition(self) -> None: + """When fail2ban comes online, ``"fail2ban_came_online"`` must be logged.""" + app = _make_app(prev_online=False) + new_status = ServerStatus(online=True, version="0.11.2", active_jails=2) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ), patch("app.tasks.health_check.log") as mock_log: + await _run_probe(app) + + online_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "fail2ban_came_online"] + assert len(online_calls) == 1 + + @pytest.mark.asyncio + async def test_run_probe_logs_went_offline_transition(self) -> None: + """When fail2ban goes offline, ``"fail2ban_went_offline"`` must be logged.""" + app = _make_app(prev_online=True) + new_status = ServerStatus(online=False) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ), patch("app.tasks.health_check.log") as mock_log: + await _run_probe(app) + + offline_calls = [c for c in mock_log.warning.call_args_list if c[0][0] == "fail2ban_went_offline"] + assert len(offline_calls) == 1 + + @pytest.mark.asyncio + async def test_run_probe_stable_online_no_transition_log(self) -> None: + """When status stays online, no transition events must be emitted.""" + app = _make_app(prev_online=True) + new_status = ServerStatus(online=True, version="0.11.2", active_jails=1) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ), patch("app.tasks.health_check.log") as mock_log: + await _run_probe(app) + + transition_calls = [ + c + for c in mock_log.info.call_args_list + if c[0][0] in ("fail2ban_came_online", "fail2ban_went_offline") + ] + transition_calls += [ + c + for c in mock_log.warning.call_args_list + if c[0][0] in ("fail2ban_came_online", "fail2ban_went_offline") + ] + assert transition_calls == [] + + @pytest.mark.asyncio + async def test_run_probe_stable_offline_no_transition_log(self) -> None: + """When status stays offline, no transition events must be emitted.""" + app = _make_app(prev_online=False) + new_status = ServerStatus(online=False) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ), patch("app.tasks.health_check.log") as mock_log: + await _run_probe(app) + + transition_calls = [ + c + for c in mock_log.info.call_args_list + if c[0][0] == "fail2ban_came_online" + ] + transition_calls += [ + c + for c in mock_log.warning.call_args_list + if c[0][0] == "fail2ban_went_offline" + ] + assert transition_calls == [] + + @pytest.mark.asyncio + async def test_run_probe_uses_socket_path_from_settings(self) -> None: + """``_run_probe`` must pass the socket path from ``app.state.settings``.""" + expected_socket = "/custom/fail2ban.sock" + app = _make_app() + app.state.settings.fail2ban_socket = expected_socket + new_status = ServerStatus(online=False) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ) as mock_probe: + await _run_probe(app) + + mock_probe.assert_awaited_once_with(expected_socket) + + @pytest.mark.asyncio + async def test_run_probe_uses_default_offline_status_when_state_missing(self) -> None: + """``_run_probe`` must handle missing ``server_status`` on first run.""" + app = _make_app() + # Simulate first run: no previous server_status attribute set yet. + del app.state.server_status + new_status = ServerStatus(online=True, version="0.11.2", active_jails=0) + + with ( + patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=new_status, + ), + patch("app.tasks.health_check.log"), + ): + # Must not raise even with no prior status. + await _run_probe(app) + + assert app.state.server_status is new_status + + +# --------------------------------------------------------------------------- +# Tests for register +# --------------------------------------------------------------------------- + + +class TestRegister: + """Tests for :func:`~app.tasks.health_check.register`.""" + + def test_register_adds_interval_job_to_scheduler(self) -> None: + """``register`` must add a job with an ``"interval"`` trigger.""" + app = _make_app() + + register(app) + + app.state.scheduler.add_job.assert_called_once() + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["trigger"] == "interval" + assert kwargs["seconds"] == HEALTH_CHECK_INTERVAL + + def test_register_primes_offline_server_status(self) -> None: + """``register`` must set an initial offline status before the first probe fires.""" + app = _make_app() + # Reset any value set by _make_app. + del app.state.server_status + + register(app) + + assert isinstance(app.state.server_status, ServerStatus) + assert app.state.server_status.online is False + + def test_register_uses_stable_job_id(self) -> None: + """``register`` must register the job under the stable id ``"health_check"``.""" + app = _make_app() + + register(app) + + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["id"] == "health_check" + + def test_register_sets_replace_existing(self) -> None: + """``register`` must use ``replace_existing=True`` to avoid duplicate jobs.""" + app = _make_app() + + register(app) + + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["replace_existing"] is True + + def test_register_passes_app_in_kwargs(self) -> None: + """The scheduled job must receive ``app`` as a kwarg for state access.""" + app = _make_app() + + register(app) + + _, kwargs = app.state.scheduler.add_job.call_args + assert kwargs["kwargs"] == {"app": app} + + def test_register_initialises_last_activation_none(self) -> None: + """``register`` must set ``app.state.last_activation = None``.""" + app = _make_app() + + register(app) + + assert app.state.last_activation is None + + def test_register_initialises_pending_recovery_none(self) -> None: + """``register`` must set ``app.state.pending_recovery = None``.""" + app = _make_app() + + register(app) + + assert app.state.pending_recovery is None + + +# --------------------------------------------------------------------------- +# Crash detection (Task 3) +# --------------------------------------------------------------------------- + + +class TestCrashDetection: + """Tests for activation-crash detection in _run_probe.""" + + @pytest.mark.asyncio + async def test_crash_within_window_creates_pending_recovery(self) -> None: + """An online→offline transition within 60 s of activation must set pending_recovery.""" + app = _make_app(prev_online=True) + now = datetime.datetime.now(tz=datetime.timezone.utc) + app.state.last_activation = { + "jail_name": "sshd", + "at": now - datetime.timedelta(seconds=10), + } + app.state.pending_recovery = None + + offline_status = ServerStatus(online=False) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=offline_status, + ): + await _run_probe(app) + + assert app.state.pending_recovery is not None + assert isinstance(app.state.pending_recovery, PendingRecovery) + assert app.state.pending_recovery.jail_name == "sshd" + assert app.state.pending_recovery.recovered is False + + @pytest.mark.asyncio + async def test_crash_outside_window_does_not_create_pending_recovery(self) -> None: + """A crash more than 60 s after activation must NOT set pending_recovery.""" + app = _make_app(prev_online=True) + app.state.last_activation = { + "jail_name": "sshd", + "at": datetime.datetime.now(tz=datetime.timezone.utc) + - datetime.timedelta(seconds=120), + } + app.state.pending_recovery = None + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=ServerStatus(online=False), + ): + await _run_probe(app) + + assert app.state.pending_recovery is None + + @pytest.mark.asyncio + async def test_came_online_marks_pending_recovery_resolved(self) -> None: + """An offline→online transition must mark an existing pending_recovery as recovered.""" + app = _make_app(prev_online=False) + activated_at = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(seconds=30) + detected_at = datetime.datetime.now(tz=datetime.timezone.utc) + app.state.pending_recovery = PendingRecovery( + jail_name="sshd", + activated_at=activated_at, + detected_at=detected_at, + recovered=False, + ) + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=ServerStatus(online=True), + ): + await _run_probe(app) + + assert app.state.pending_recovery.recovered is True + + @pytest.mark.asyncio + async def test_crash_without_recent_activation_does_nothing(self) -> None: + """A crash when last_activation is None must not create a pending_recovery.""" + app = _make_app(prev_online=True) + app.state.last_activation = None + app.state.pending_recovery = None + + with patch( + "app.tasks.health_check.health_service.probe", + new_callable=AsyncMock, + return_value=ServerStatus(online=False), + ): + await _run_probe(app) + + assert app.state.pending_recovery is None diff --git a/backend/tests/test_utils/__init__.py b/backend/tests/test_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_utils/test_config_parser.py b/backend/tests/test_utils/test_config_parser.py new file mode 100644 index 0000000..3ae2343 --- /dev/null +++ b/backend/tests/test_utils/test_config_parser.py @@ -0,0 +1,473 @@ +"""Tests for app.utils.config_parser.Fail2BanConfigParser.""" + +from __future__ import annotations + +from pathlib import Path + +from app.utils.config_parser import Fail2BanConfigParser + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + +_FILTER_CONF = """\ +[INCLUDES] +before = common.conf + +[Definition] +failregex = ^%(host)s .*$ +ignoreregex = +""" + +_COMMON_CONF = """\ +[DEFAULT] +host = +""" + +_FILTER_LOCAL = """\ +[Definition] +failregex = ^OVERRIDE %(host)s$ +""" + +_ACTION_CONF = """\ +[Definition] +actionstart = iptables -N f2b- +actionstop = iptables -X f2b- +actionban = iptables -I INPUT -s -j DROP +actionunban = iptables -D INPUT -s -j DROP + +[Init] +name = default +ip = 1.2.3.4 +""" + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + """Write *content* to *tmp_path/name* and return the path.""" + p = tmp_path / name + p.write_text(content, encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# TestOrderedConfFiles +# --------------------------------------------------------------------------- + + +class TestOrderedConfFiles: + def test_empty_dir_returns_empty(self, tmp_path: Path) -> None: + result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail") + assert result == [] + + def test_conf_only(self, tmp_path: Path) -> None: + conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n") + result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail") + assert result == [conf] + + def test_conf_then_local(self, tmp_path: Path) -> None: + conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n") + local = _write(tmp_path, "jail.local", "[DEFAULT]\n") + result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail") + assert result == [conf, local] + + def test_d_dir_overrides_appended(self, tmp_path: Path) -> None: + conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n") + local = _write(tmp_path, "jail.local", "[DEFAULT]\n") + d_dir = tmp_path / "jail.d" + d_dir.mkdir() + d_conf = _write(d_dir, "extra.conf", "[DEFAULT]\n") + d_local = _write(d_dir, "extra.local", "[DEFAULT]\n") + result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail") + assert result == [conf, local, d_conf, d_local] + + def test_missing_local_skipped(self, tmp_path: Path) -> None: + conf = _write(tmp_path, "jail.conf", "[DEFAULT]\n") + result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail") + assert conf in result + assert len(result) == 1 + + def test_d_dir_sorted(self, tmp_path: Path) -> None: + d_dir = tmp_path / "jail.d" + d_dir.mkdir() + _write(d_dir, "zzz.conf", "[DEFAULT]\n") + _write(d_dir, "aaa.conf", "[DEFAULT]\n") + result = Fail2BanConfigParser.ordered_conf_files(tmp_path, "jail") + names = [p.name for p in result] + assert names == ["aaa.conf", "zzz.conf"] + + +# --------------------------------------------------------------------------- +# TestReadFile +# --------------------------------------------------------------------------- + + +class TestReadFile: + def test_reads_single_file(self, tmp_path: Path) -> None: + _write(tmp_path, "common.conf", _COMMON_CONF) + p = _write(tmp_path, "filter.conf", _FILTER_CONF) + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.has_section("Definition") + + def test_before_include_loaded(self, tmp_path: Path) -> None: + _write(tmp_path, "common.conf", _COMMON_CONF) + p = _write(tmp_path, "filter.conf", _FILTER_CONF) + parser = Fail2BanConfigParser() + parser.read_file(p) + # DEFAULT from common.conf should be merged. + defaults = parser.defaults() + assert "host" in defaults + + def test_missing_file_is_silent(self, tmp_path: Path) -> None: + parser = Fail2BanConfigParser() + parser.read_file(tmp_path / "nonexistent.conf") + assert parser.sections() == [] + + def test_after_include_overrides(self, tmp_path: Path) -> None: + after_content = """\ +[Definition] +key = after_value +""" + after = _write(tmp_path, "after.conf", after_content) + _ = after # used via [INCLUDES] + main_content = """\ +[INCLUDES] +after = after.conf + +[Definition] +key = main_value +""" + p = _write(tmp_path, "main.conf", main_content) + parser = Fail2BanConfigParser() + parser.read_file(p) + # 'after' was loaded last → highest priority. + assert parser.get("Definition", "key") == "after_value" + + def test_cycle_detection(self, tmp_path: Path) -> None: + # A includes B, B includes A. + a_content = """\ +[INCLUDES] +before = b.conf + +[Definition] +key = from_a +""" + b_content = """\ +[INCLUDES] +before = a.conf + +[Definition] +key = from_b +""" + _write(tmp_path, "a.conf", a_content) + _write(tmp_path, "b.conf", b_content) + parser = Fail2BanConfigParser() + # Should not infinite-loop; terminates via cycle detection. + parser.read_file(tmp_path / "a.conf") + assert parser.has_section("Definition") + + def test_max_depth_guard(self, tmp_path: Path) -> None: + # Create a chain: 0→1→2→…→max+1 + max_depth = 3 + for i in range(max_depth + 2): + content = f"[INCLUDES]\nbefore = {i + 1}.conf\n\n[s{i}]\nk = v\n" + _write(tmp_path, f"{i}.conf", content) + parser = Fail2BanConfigParser(max_include_depth=max_depth) + parser.read_file(tmp_path / "0.conf") + # Should complete without recursion error; some sections will be missing. + assert isinstance(parser.sections(), list) + + def test_invalid_ini_is_ignored(self, tmp_path: Path) -> None: + bad = _write(tmp_path, "bad.conf", "this is not valid [[[ini\nstuff") + parser = Fail2BanConfigParser() + # Should not raise; parser logs and continues. + parser.read_file(bad) + + +# --------------------------------------------------------------------------- +# TestReadWithOverrides +# --------------------------------------------------------------------------- + + +class TestReadWithOverrides: + def test_local_overrides_conf(self, tmp_path: Path) -> None: + _write(tmp_path, "sshd.conf", "[Definition]\nfailregex = original\n") + _write(tmp_path, "sshd.local", "[Definition]\nfailregex = overridden\n") + parser = Fail2BanConfigParser() + parser.read_with_overrides(tmp_path / "sshd.conf") + assert parser.get("Definition", "failregex") == "overridden" + + def test_no_local_just_reads_conf(self, tmp_path: Path) -> None: + _write(tmp_path, "sshd.conf", "[Definition]\nfailregex = only_conf\n") + parser = Fail2BanConfigParser() + parser.read_with_overrides(tmp_path / "sshd.conf") + assert parser.get("Definition", "failregex") == "only_conf" + + def test_local_adds_new_key(self, tmp_path: Path) -> None: + _write(tmp_path, "sshd.conf", "[Definition]\nfailregex = orig\n") + _write(tmp_path, "sshd.local", "[Definition]\nextrakey = newval\n") + parser = Fail2BanConfigParser() + parser.read_with_overrides(tmp_path / "sshd.conf") + assert parser.get("Definition", "extrakey") == "newval" + + def test_conf_keys_preserved_when_local_overrides_other( + self, tmp_path: Path + ) -> None: + _write( + tmp_path, + "sshd.conf", + "[Definition]\nfailregex = orig\nignoreregex = keep_me\n", + ) + _write(tmp_path, "sshd.local", "[Definition]\nfailregex = new\n") + parser = Fail2BanConfigParser() + parser.read_with_overrides(tmp_path / "sshd.conf") + assert parser.get("Definition", "ignoreregex") == "keep_me" + assert parser.get("Definition", "failregex") == "new" + + +# --------------------------------------------------------------------------- +# TestSections +# --------------------------------------------------------------------------- + + +class TestSections: + def test_sections_excludes_default(self, tmp_path: Path) -> None: + content = "[DEFAULT]\nfoo = bar\n\n[Definition]\nbaz = qux\n" + p = _write(tmp_path, "x.conf", content) + parser = Fail2BanConfigParser() + parser.read_file(p) + secs = parser.sections() + assert "DEFAULT" not in secs + assert "Definition" in secs + + def test_has_section_true(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Init]\nname = test\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.has_section("Init") is True + + def test_has_section_false(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Init]\nname = test\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.has_section("Nonexistent") is False + + def test_get_returns_none_for_missing_section(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Init]\nname = test\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.get("NoSection", "key") is None + + def test_get_returns_none_for_missing_key(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Init]\nname = test\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.get("Init", "nokey") is None + + +# --------------------------------------------------------------------------- +# TestSectionDict +# --------------------------------------------------------------------------- + + +class TestSectionDict: + def test_returns_all_keys(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Definition]\na = 1\nb = 2\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + d = parser.section_dict("Definition") + assert d == {"a": "1", "b": "2"} + + def test_empty_for_missing_section(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Definition]\na = 1\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.section_dict("Init") == {} + + def test_skip_excludes_keys(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Definition]\na = 1\nb = 2\nc = 3\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + d = parser.section_dict("Definition", skip=frozenset({"b"})) + assert "b" not in d + assert d["a"] == "1" + + def test_dunder_keys_excluded(self, tmp_path: Path) -> None: + # configparser can inject __name__, __add__ etc. from DEFAULT. + content = "[DEFAULT]\n__name__ = foo\n\n[Definition]\nreal = val\n" + p = _write(tmp_path, "x.conf", content) + parser = Fail2BanConfigParser() + parser.read_file(p) + d = parser.section_dict("Definition") + assert "__name__" not in d + assert "real" in d + + +# --------------------------------------------------------------------------- +# TestDefaults +# --------------------------------------------------------------------------- + + +class TestDefaults: + def test_defaults_from_default_section(self, tmp_path: Path) -> None: + content = "[DEFAULT]\nhost = \n\n[Definition]\nfailregex = ^\n" + p = _write(tmp_path, "x.conf", content) + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.defaults().get("host") == "" + + def test_defaults_empty_when_no_default_section(self, tmp_path: Path) -> None: + p = _write(tmp_path, "x.conf", "[Definition]\nfailregex = ^\n") + parser = Fail2BanConfigParser() + parser.read_file(p) + assert parser.defaults() == {} + + +# --------------------------------------------------------------------------- +# TestInterpolate +# --------------------------------------------------------------------------- + + +class TestInterpolate: + def _parser_with(self, tmp_path: Path, content: str) -> Fail2BanConfigParser: + p = _write(tmp_path, "x.conf", content) + parser = Fail2BanConfigParser() + parser.read_file(p) + return parser + + def test_substitutes_default_var(self, tmp_path: Path) -> None: + parser = self._parser_with( + tmp_path, + "[DEFAULT]\nhost = \n\n[Definition]\nrule = match %(host)s\n", + ) + assert parser.interpolate("match %(host)s") == "match " + + def test_substitutes_init_var(self, tmp_path: Path) -> None: + parser = self._parser_with( + tmp_path, + "[Init]\nname = sshd\n", + ) + assert parser.interpolate("f2b-%(name)s") == "f2b-sshd" + + def test_extra_vars_highest_priority(self, tmp_path: Path) -> None: + parser = self._parser_with( + tmp_path, + "[DEFAULT]\nname = default_name\n", + ) + result = parser.interpolate("%(name)s", extra_vars={"name": "override"}) + assert result == "override" + + def test_unresolvable_left_unchanged(self, tmp_path: Path) -> None: + parser = Fail2BanConfigParser() + result = parser.interpolate("value %(unknown)s end") + assert result == "value %(unknown)s end" + + def test_nested_interpolation(self, tmp_path: Path) -> None: + # %(outer)s → %(inner)s → final + parser = self._parser_with( + tmp_path, + "[DEFAULT]\ninner = final\nouter = %(inner)s\n", + ) + assert parser.interpolate("%(outer)s") == "final" + + def test_no_references_returned_unchanged(self, tmp_path: Path) -> None: + parser = Fail2BanConfigParser() + assert parser.interpolate("plain value") == "plain value" + + def test_empty_string(self, tmp_path: Path) -> None: + parser = Fail2BanConfigParser() + assert parser.interpolate("") == "" + + +# --------------------------------------------------------------------------- +# TestSplitMultiline +# --------------------------------------------------------------------------- + + +class TestSplitMultiline: + def test_strips_blank_lines(self) -> None: + raw = "line1\n\nline2\n\n" + assert Fail2BanConfigParser.split_multiline(raw) == ["line1", "line2"] + + def test_strips_comment_lines(self) -> None: + raw = "line1\n# comment\nline2" + assert Fail2BanConfigParser.split_multiline(raw) == ["line1", "line2"] + + def test_strips_leading_whitespace(self) -> None: + raw = " line1\n line2" + assert Fail2BanConfigParser.split_multiline(raw) == ["line1", "line2"] + + def test_empty_input(self) -> None: + assert Fail2BanConfigParser.split_multiline("") == [] + + def test_all_comments(self) -> None: + raw = "# first\n# second" + assert Fail2BanConfigParser.split_multiline(raw) == [] + + def test_single_line(self) -> None: + assert Fail2BanConfigParser.split_multiline("single") == ["single"] + + def test_preserves_internal_spaces(self) -> None: + raw = "iptables -I INPUT -s -j DROP" + assert Fail2BanConfigParser.split_multiline(raw) == [ + "iptables -I INPUT -s -j DROP" + ] + + def test_multiline_regex_list(self) -> None: + raw = ( + "\n" + " ^%(__prefix_line)s Authentication failure for .* from \n" + " # inline comment, skip\n" + " ^%(__prefix_line)s BREAK-IN ATTEMPT by \n" + ) + result = Fail2BanConfigParser.split_multiline(raw) + assert len(result) == 2 + assert all("HOST" in r for r in result) + + +# --------------------------------------------------------------------------- +# TestMultipleFiles (integration-style tests) +# --------------------------------------------------------------------------- + + +class TestMultipleFilesIntegration: + """Tests that combine several files to verify merge order.""" + + def test_local_only_override(self, tmp_path: Path) -> None: + _write(tmp_path, "test.conf", "[Definition]\nfailregex = base\n") + _write(tmp_path, "test.local", "[Definition]\nfailregex = local\n") + parser = Fail2BanConfigParser() + parser.read_with_overrides(tmp_path / "test.conf") + assert parser.get("Definition", "failregex") == "local" + + def test_before_then_conf_then_local(self, tmp_path: Path) -> None: + # before.conf → test.conf → test.local (ascending priority) + _write(tmp_path, "before.conf", "[Definition]\nsource = before\n") + _write( + tmp_path, + "test.conf", + "[INCLUDES]\nbefore = before.conf\n\n[Definition]\nsource = conf\n", + ) + _write(tmp_path, "test.local", "[Definition]\nsource = local\n") + parser = Fail2BanConfigParser() + parser.read_with_overrides(tmp_path / "test.conf") + assert parser.get("Definition", "source") == "local" + + def test_before_key_preserved_if_not_overridden(self, tmp_path: Path) -> None: + _write(tmp_path, "common.conf", "[DEFAULT]\nhost = \n") + _write( + tmp_path, + "filter.conf", + "[INCLUDES]\nbefore = common.conf\n\n[Definition]\nfailregex = ^%(host)s\n", + ) + parser = Fail2BanConfigParser() + parser.read_file(tmp_path / "filter.conf") + assert parser.defaults().get("host") == "" + assert parser.get("Definition", "failregex") == "^%(host)s" + + def test_fresh_parser_has_no_state(self) -> None: + p1 = Fail2BanConfigParser() + p2 = Fail2BanConfigParser() + assert p1.sections() == [] + assert p2.sections() == [] + assert p1 is not p2 diff --git a/backend/tests/test_utils/test_config_writer.py b/backend/tests/test_utils/test_config_writer.py new file mode 100644 index 0000000..f749d21 --- /dev/null +++ b/backend/tests/test_utils/test_config_writer.py @@ -0,0 +1,290 @@ +"""Tests for app.utils.config_writer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest # noqa: F401 — used by pytest.raises + +from app.utils.config_writer import ( + _get_file_lock, + delete_local_file, + remove_local_key, + write_local_override, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write(tmp_path: Path, name: str, content: str) -> Path: + p = tmp_path / name + p.write_text(content, encoding="utf-8") + return p + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# TestGetFileLock +# --------------------------------------------------------------------------- + + +class TestGetFileLock: + def test_same_path_returns_same_lock(self, tmp_path: Path) -> None: + path = tmp_path / "test.local" + lock_a = _get_file_lock(path) + lock_b = _get_file_lock(path) + assert lock_a is lock_b + + def test_different_paths_return_different_locks(self, tmp_path: Path) -> None: + lock_a = _get_file_lock(tmp_path / "a.local") + lock_b = _get_file_lock(tmp_path / "b.local") + assert lock_a is not lock_b + + +# --------------------------------------------------------------------------- +# TestWriteLocalOverride +# --------------------------------------------------------------------------- + + +class TestWriteLocalOverride: + def test_creates_new_file(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + write_local_override(path, "Definition", {"failregex": "^bad$"}) + assert path.is_file() + + def test_file_contains_written_key(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + write_local_override(path, "Definition", {"failregex": "^bad$"}) + content = _read(path) + assert "failregex" in content + assert "^bad$" in content + + def test_creates_parent_directory(self, tmp_path: Path) -> None: + path = tmp_path / "subdir" / "sshd.local" + write_local_override(path, "Definition", {"key": "val"}) + assert path.is_file() + + def test_updates_existing_key(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + write_local_override(path, "Definition", {"failregex": "original"}) + write_local_override(path, "Definition", {"failregex": "updated"}) + content = _read(path) + assert "updated" in content + assert "original" not in content + + def test_preserves_other_sections(self, tmp_path: Path) -> None: + existing = "[Init]\nname = sshd\n\n[Definition]\nfailregex = orig\n" + path = _write(tmp_path, "sshd.local", existing) + write_local_override(path, "Definition", {"failregex": "new"}) + content = _read(path) + assert "Init" in content + assert "name" in content + + def test_preserves_other_keys_in_section(self, tmp_path: Path) -> None: + existing = "[Definition]\nfailregex = orig\nignoreregex = keep\n" + path = _write(tmp_path, "sshd.local", existing) + write_local_override(path, "Definition", {"failregex": "new"}) + content = _read(path) + assert "ignoreregex" in content + assert "keep" in content + + def test_adds_new_section(self, tmp_path: Path) -> None: + existing = "[Definition]\nfailregex = orig\n" + path = _write(tmp_path, "sshd.local", existing) + write_local_override(path, "Init", {"name": "sshd"}) + content = _read(path) + assert "[Init]" in content + assert "name" in content + + def test_writes_multiple_keys(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + write_local_override(path, "Definition", {"a": "1", "b": "2", "c": "3"}) + content = _read(path) + assert "a" in content + assert "b" in content + assert "c" in content + + def test_raises_for_conf_path(self, tmp_path: Path) -> None: + bad = tmp_path / "sshd.conf" + with pytest.raises(ValueError, match=r"\.local"): + write_local_override(bad, "Definition", {"key": "val"}) + + def test_raises_for_non_local_extension(self, tmp_path: Path) -> None: + bad = tmp_path / "sshd.ini" + with pytest.raises(ValueError): + write_local_override(bad, "Definition", {"key": "val"}) + + +# --------------------------------------------------------------------------- +# TestRemoveLocalKey +# --------------------------------------------------------------------------- + + +class TestRemoveLocalKey: + def test_removes_existing_key(self, tmp_path: Path) -> None: + path = _write( + tmp_path, + "sshd.local", + "[Definition]\nfailregex = bad\nignoreregex = keep\n", + ) + remove_local_key(path, "Definition", "failregex") + content = _read(path) + assert "failregex" not in content + assert "ignoreregex" in content + + def test_noop_for_missing_key(self, tmp_path: Path) -> None: + path = _write(tmp_path, "sshd.local", "[Definition]\nother = val\n") + # Should not raise. + remove_local_key(path, "Definition", "nonexistent") + assert path.is_file() + + def test_noop_for_missing_section(self, tmp_path: Path) -> None: + path = _write(tmp_path, "sshd.local", "[Definition]\nother = val\n") + remove_local_key(path, "Init", "name") + assert path.is_file() + + def test_noop_for_missing_file(self, tmp_path: Path) -> None: + path = tmp_path / "missing.local" + # Should not raise even if file doesn't exist. + remove_local_key(path, "Definition", "key") + + def test_removes_empty_section(self, tmp_path: Path) -> None: + # [Definition] will become empty and be removed; [Init] keeps the file. + path = _write( + tmp_path, + "sshd.local", + "[Definition]\nonly_key = val\n\n[Init]\nname = sshd\n", + ) + remove_local_key(path, "Definition", "only_key") + content = _read(path) + assert "[Definition]" not in content + assert "[Init]" in content + + def test_deletes_file_when_no_sections_remain(self, tmp_path: Path) -> None: + path = _write(tmp_path, "sshd.local", "[Definition]\nonly_key = val\n") + remove_local_key(path, "Definition", "only_key") + assert not path.exists() + + def test_preserves_other_sections_after_removal(self, tmp_path: Path) -> None: + path = _write( + tmp_path, + "sshd.local", + "[Definition]\nkey = val\n\n[Init]\nname = sshd\n", + ) + remove_local_key(path, "Definition", "key") + content = _read(path) + assert "[Init]" in content + assert "name" in content + + def test_raises_for_conf_path(self, tmp_path: Path) -> None: + bad = tmp_path / "sshd.conf" + with pytest.raises(ValueError, match=r"\.local"): + remove_local_key(bad, "Definition", "key") + + +# --------------------------------------------------------------------------- +# TestDeleteLocalFile +# --------------------------------------------------------------------------- + + +class TestDeleteLocalFile: + def test_deletes_existing_local_with_conf(self, tmp_path: Path) -> None: + _write(tmp_path, "sshd.conf", "[Definition]\n") + path = _write(tmp_path, "sshd.local", "[Definition]\nkey = val\n") + delete_local_file(path) + assert not path.exists() + + def test_raises_file_not_found(self, tmp_path: Path) -> None: + _write(tmp_path, "sshd.conf", "[Definition]\n") + missing = tmp_path / "sshd.local" + with pytest.raises(FileNotFoundError): + delete_local_file(missing) + + def test_raises_oserror_for_orphan_without_flag(self, tmp_path: Path) -> None: + path = _write(tmp_path, "orphan.local", "[Definition]\nkey = val\n") + with pytest.raises(OSError, match="No corresponding .conf"): + delete_local_file(path) + + def test_allow_orphan_deletes_local_only_file(self, tmp_path: Path) -> None: + path = _write(tmp_path, "orphan.local", "[Definition]\nkey = val\n") + delete_local_file(path, allow_orphan=True) + assert not path.exists() + + def test_raises_for_conf_path(self, tmp_path: Path) -> None: + bad = _write(tmp_path, "sshd.conf", "[Definition]\n") + with pytest.raises(ValueError, match=r"\.local"): + delete_local_file(bad) + + def test_raises_for_non_local_extension(self, tmp_path: Path) -> None: + bad = tmp_path / "sshd.ini" + bad.write_text("x", encoding="utf-8") + with pytest.raises(ValueError): + delete_local_file(bad) + + +# --------------------------------------------------------------------------- +# TestAtomicWrite (integration) +# --------------------------------------------------------------------------- + + +class TestAtomicWrite: + def test_no_temp_files_left_after_write(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + write_local_override(path, "Definition", {"key": "val"}) + files = list(tmp_path.iterdir()) + # Only the target file should exist. + assert len(files) == 1 + assert files[0].name == "sshd.local" + + def test_write_is_idempotent(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + for _ in range(5): + write_local_override(path, "Definition", {"key": "val"}) + content = _read(path) + # 'key' should appear exactly once. + assert content.count("key") == 1 + + +# --------------------------------------------------------------------------- +# TestEdgeCases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_write_empty_key_values_creates_empty_section( + self, tmp_path: Path + ) -> None: + path = tmp_path / "sshd.local" + write_local_override(path, "Definition", {}) + content = _read(path) + assert "[Definition]" in content + + def test_remove_key_with_unicode_value(self, tmp_path: Path) -> None: + path = _write( + tmp_path, + "sshd.local", + "[Definition]\nkey = 日本語\nother = keep\n", + ) + remove_local_key(path, "Definition", "key") + content = _read(path) + assert "日本語" not in content + assert "other" in content + + def test_write_value_with_newlines(self, tmp_path: Path) -> None: + path = tmp_path / "sshd.local" + # configparser stores multi-line values with continuation indent. + multiline = "line1\n line2\n line3" + write_local_override(path, "Definition", {"failregex": multiline}) + assert path.is_file() + + def test_remove_last_key_of_last_section_deletes_file( + self, tmp_path: Path + ) -> None: + path = _write(tmp_path, "sshd.local", "[Definition]\nlast_key = val\n") + remove_local_key(path, "Definition", "last_key") + assert not path.exists() diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a9851f4 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,40 @@ +# ───────────────────────────────────────────── +# frontend/.gitignore (React / Vite / TypeScript) +# ───────────────────────────────────────────── + +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build output +dist/ +.vite/ + +# TypeScript incremental build info +*.tsbuildinfo + +# Testing +coverage/ +.vitest/ + +# Env +.env +.env.* +!.env.example + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +.idea/ +*.iml diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..b9ac3df --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts new file mode 100644 index 0000000..47ba3b6 --- /dev/null +++ b/frontend/eslint.config.ts @@ -0,0 +1,34 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import prettierConfig from "eslint-config-prettier"; + +export default tseslint.config( + { ignores: ["dist", "eslint.config.ts"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked], + files: ["**/*.{ts,tsx}"], + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.node.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "react-hooks": reactHooks, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/explicit-function-return-type": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + }, + }, + prettierConfig, + { + files: ["src/**/*.test.{ts,tsx}", "src/setupTests.ts"], + rules: { + "@typescript-eslint/explicit-function-return-type": "off", + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7beab00 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + BanGUI + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0c058e6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7557 @@ +{ + "name": "bangui-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bangui-frontend", + "version": "0.1.0", + "dependencies": { + "@fluentui/react-components": "^9.55.0", + "@fluentui/react-icons": "^2.0.257", + "@types/react-simple-maps": "^3.0.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "react-simple-maps": "^3.0.0", + "recharts": "^3.8.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.3.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "@vitejs/plugin-react": "^4.3.3", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9.13.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^5.0.0", + "jiti": "^2.6.1", + "jsdom": "^28.1.0", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "typescript-eslint": "^8.56.1", + "vite": "^5.4.11", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/devtools": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@floating-ui/devtools/-/devtools-0.2.3.tgz", + "integrity": "sha512-ZTcxTvgo9CRlP7vJV62yCxdqmahHTGpSTi5QaTDgGoyQq0OyjaVZhUhXv/qdkQFOI3Sxlfmz0XGG4HaZMsDf8Q==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/dom": "^1.0.0" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@fluentui/keyboard-keys": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@fluentui/keyboard-keys/-/keyboard-keys-9.0.8.tgz", + "integrity": "sha512-iUSJUUHAyTosnXK8O2Ilbfxma+ZyZPMua5vB028Ys96z80v+LFwntoehlFsdH3rMuPsA8GaC1RE7LMezwPBPdw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/priority-overflow": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fluentui/priority-overflow/-/priority-overflow-9.3.0.tgz", + "integrity": "sha512-yaBC0R4e+4ZlCWDulB5S+xBrlnLwfzdg68GaarCqQO8OHjLg7Ah05xTj7PsAYcoHeEg/9vYeBwGXBpRO8+Tjqw==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-accordion": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-accordion/-/react-accordion-9.9.1.tgz", + "integrity": "sha512-gM7okIjOd3HaCMt7wTN7pnsMzXT6r/M5rVlCZbOtmkzBEJPHRoNeO+cYWS7ttvlcdpvP2nQzbFyb3Vt7HYzmWg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-alert": { + "version": "9.0.0-beta.134", + "resolved": "https://registry.npmjs.org/@fluentui/react-alert/-/react-alert-9.0.0-beta.134.tgz", + "integrity": "sha512-uXAEL8KkjHE7SYyr2GM1H8t5pe9FYfjUcWt6odX135e9SvHwD0w8dd0wVToyvABi5PsKaRHAWY3JHsfnam4r4w==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.10.1", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-icons": "^2.0.239", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-aria": { + "version": "9.17.10", + "resolved": "https://registry.npmjs.org/@fluentui/react-aria/-/react-aria-9.17.10.tgz", + "integrity": "sha512-KqS2XcdN84XsgVG4fAESyOBfixN7zbObWfQVLNZ2gZrp2b1hPGVYfQ6J4WOO0vXMKYp0rre/QMOgDm6/srL0XQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-avatar": { + "version": "9.10.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-avatar/-/react-avatar-9.10.1.tgz", + "integrity": "sha512-rrb4v7impHzpohwWnqOemRO6WC16RbfAMwarc6TwJVC1NXC92YOlkpCDhgHqQHY51oM49fVIIPgAqi44jKZipw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-badge": "^9.4.15", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-popover": "^9.13.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.2", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-badge": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-badge/-/react-badge-9.4.15.tgz", + "integrity": "sha512-KgFUJHBHP76vE3EDuPg/ml7lGqxs9zJ634e+vtxn8D7ghCZ6h9P6A0WbmgsPcN6MZoBZYLzzYT3OJ6Vmu3BM8g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-breadcrumb": { + "version": "9.3.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-breadcrumb/-/react-breadcrumb-9.3.17.tgz", + "integrity": "sha512-POnwCFyvXabq7lNtJRslASNkrm0iRoXpnrWwh0LyBTFZRDiGDKaV18Bpk0UiuQNTUurVQiH513164XKHIP+d7Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-link": "^9.7.4", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-button": { + "version": "9.8.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-button/-/react-button-9.8.2.tgz", + "integrity": "sha512-T2xBn6s6DRNH17Y+kLO+uEOaRe89Q20WP1Rs6OzC45cSpOGc+q9ogbPbYBqU7Tr1fur+Xd8LRHxdQJ3j5ufbdw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-card": { + "version": "9.5.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-card/-/react-card-9.5.11.tgz", + "integrity": "sha512-0W3BmDER/aKx+7+ttGy+M6LO09DW7DkJlO8F0x13L1ssOVxJ0OhyhSGiCF0cJliOK1tiGPveYf6+X2xMq2MT6g==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-text": "^9.6.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-carousel": { + "version": "9.9.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-carousel/-/react-carousel-9.9.3.tgz", + "integrity": "sha512-qcVJAEg6f8ZQD3afaksZ2mo5Uyue4IJan4cUhWPLYCrkqgOS4WsvJ+7CyH3k3KLi2mR6x9Y/7OE2OwqaN4ASew==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.2", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "embla-carousel": "^8.5.1", + "embla-carousel-autoplay": "^8.5.1", + "embla-carousel-fade": "^8.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-checkbox": { + "version": "9.5.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-checkbox/-/react-checkbox-9.5.15.tgz", + "integrity": "sha512-ZXvuZo8HvBLvsd74foI/p/YkxKRmruQLhleeQRMqyNKMbytFcYZ8rHmAN492tNMjmWxGIfZHv5Oh7Ds6poNmJg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-color-picker": { + "version": "9.2.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-color-picker/-/react-color-picker-9.2.15.tgz", + "integrity": "sha512-RMmawl7g4gUYLuTQG2QwCcR9fGC+vDD+snsBlXtObpj/cKpeDmYif46g88pYv86jeIXY1zsjINmLpELmz+uFmw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.3.4", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-combobox": { + "version": "9.16.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-combobox/-/react-combobox-9.16.16.tgz", + "integrity": "sha512-CeAC2di3xiTRB5h5XpyF+blLc6NR5VHPG+rHLRNoLjQhn9frQK3HdHGxpBVYCzx9BUU6V2IhvIcPAGgz97XHIQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.21.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-components": { + "version": "9.73.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-components/-/react-components-9.73.1.tgz", + "integrity": "sha512-Ss323tSsAErf+dAk8rEt8aPClNRqRdK8AKyhrkz9OG6kHJbT/ST7+2rRT6e5lFl0XKc4EOAEalNrIAZIs4teSw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-accordion": "^9.9.1", + "@fluentui/react-alert": "9.0.0-beta.134", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.1", + "@fluentui/react-badge": "^9.4.15", + "@fluentui/react-breadcrumb": "^9.3.17", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-card": "^9.5.11", + "@fluentui/react-carousel": "^9.9.3", + "@fluentui/react-checkbox": "^9.5.15", + "@fluentui/react-color-picker": "^9.2.15", + "@fluentui/react-combobox": "^9.16.16", + "@fluentui/react-dialog": "^9.17.1", + "@fluentui/react-divider": "^9.6.2", + "@fluentui/react-drawer": "^9.11.4", + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-image": "^9.3.15", + "@fluentui/react-infobutton": "9.0.0-beta.111", + "@fluentui/react-infolabel": "^9.4.16", + "@fluentui/react-input": "^9.7.15", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-link": "^9.7.4", + "@fluentui/react-list": "^9.6.10", + "@fluentui/react-menu": "^9.21.2", + "@fluentui/react-message-bar": "^9.6.19", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-nav": "^9.3.19", + "@fluentui/react-overflow": "^9.7.1", + "@fluentui/react-persona": "^9.6.1", + "@fluentui/react-popover": "^9.13.2", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.21.0", + "@fluentui/react-progress": "^9.4.15", + "@fluentui/react-provider": "^9.22.15", + "@fluentui/react-radio": "^9.5.15", + "@fluentui/react-rating": "^9.3.15", + "@fluentui/react-search": "^9.3.15", + "@fluentui/react-select": "^9.4.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-skeleton": "^9.4.15", + "@fluentui/react-slider": "^9.5.15", + "@fluentui/react-spinbutton": "^9.5.15", + "@fluentui/react-spinner": "^9.7.15", + "@fluentui/react-swatch-picker": "^9.4.15", + "@fluentui/react-switch": "^9.5.4", + "@fluentui/react-table": "^9.19.9", + "@fluentui/react-tabs": "^9.11.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-tag-picker": "^9.8.0", + "@fluentui/react-tags": "^9.7.16", + "@fluentui/react-teaching-popover": "^9.6.17", + "@fluentui/react-text": "^9.6.15", + "@fluentui/react-textarea": "^9.6.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-toast": "^9.7.13", + "@fluentui/react-toolbar": "^9.7.3", + "@fluentui/react-tooltip": "^9.9.2", + "@fluentui/react-tree": "^9.15.11", + "@fluentui/react-utilities": "^9.26.2", + "@fluentui/react-virtualizer": "9.0.0-alpha.111", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-context-selector": { + "version": "9.2.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-context-selector/-/react-context-selector-9.2.15.tgz", + "integrity": "sha512-QymBntFLJNZ9VfTOaBn2ApUSSSC5UuDW8ZcgPJPA+06XEFH+U9Zny2d9QAg1xYNYwIGWahWGQ+7ATOuLxtB8Jw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0", + "scheduler": ">=0.19.0" + } + }, + "node_modules/@fluentui/react-dialog": { + "version": "9.17.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-dialog/-/react-dialog-9.17.1.tgz", + "integrity": "sha512-7jFcSceAqGw5nU/Fjq3s+yZJFqCY5YUI3XKKwhcqq9XwmgXvwNnh6FYCBdbcv69IXqxYsugBcCPC78C/cUDb8A==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-divider": { + "version": "9.6.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-divider/-/react-divider-9.6.2.tgz", + "integrity": "sha512-jfHlpSoJys78STe/SSjqdcn+W7QjEO1xCGiedWp/MdTBi3pH5vEeYbt2u8RU+zP32IF0Clta85KsUEEG0DYELQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-drawer": { + "version": "9.11.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-drawer/-/react-drawer-9.11.4.tgz", + "integrity": "sha512-9+xPxdHj9Bfe2Oq4juBGzHRjMaMSpK/4nMysgpmne9nJ+xju8dQxBEbOCklpXOUOToY+Y6IBrhDkBXz4arbPsg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-dialog": "^9.17.1", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-field": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-field/-/react-field-9.4.15.tgz", + "integrity": "sha512-hKdl+ncnT1C3vX8zQ4LqNGUk6TiatDOAW49dr18RkONcScg2staAaDme977Iozj6+AW7AJsDfkNxq/lwHhe/pg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-icons": { + "version": "2.0.320", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.320.tgz", + "integrity": "sha512-NU4gErPeaTD/T6Z9g3Uvp898lIFS6fDLr3++vpT8pcI4Ds0fZqQdrwNi3dF0R/SVws8DXQaRYiGlPHxszo4J4g==", + "license": "MIT", + "dependencies": { + "@griffel/react": "^1.0.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-image": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-image/-/react-image-9.3.15.tgz", + "integrity": "sha512-k8ftGUc5G3Hj5W9nOFnWEKZ1oXmoZE3EvAEdyI6Cn9R8E6zW2PZ1+cug0p6rr01JCDG8kbry1LAITcObMrlPdw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infobutton": { + "version": "9.0.0-beta.111", + "resolved": "https://registry.npmjs.org/@fluentui/react-infobutton/-/react-infobutton-9.0.0-beta.111.tgz", + "integrity": "sha512-rPQUY+FzRfXiY/0If9Bp57/ZdpBeR7u4NWcRWnfOmvkc1YVIYXagYzrAhMnNHQ2o418XNYZr5gG3aE+LLbTbJQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.237", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-popover": "^9.13.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-infolabel": { + "version": "9.4.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-infolabel/-/react-infolabel-9.4.16.tgz", + "integrity": "sha512-/VykpbidhS0G5t2PGXmGbXXgCiOmeIxlQCqfpKZF2ZWx3fQpqriMGXBMSsVDsqTasLmUDdmz3/OWI/rp/Wy+GQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-popover": "^9.13.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-input": { + "version": "9.7.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-input/-/react-input-9.7.15.tgz", + "integrity": "sha512-pzGF1mOenV03RhIy+km8GrqCfahDSLm6YG7wxpE1m2q2fY73cyLZPuMbK7Kz27oaoyUI37v4Pa4612zl12228A==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-jsx-runtime": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-jsx-runtime/-/react-jsx-runtime-9.4.1.tgz", + "integrity": "sha512-ZodSm7jRa4kaLKDi+emfHFMP/IDnYwFQQAI2BdtKbVrvfwvzPRprGcnTgivnqKBT1ROvKOCY2ddz7+yZzesnNw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-label": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-label/-/react-label-9.3.15.tgz", + "integrity": "sha512-ycmaQwC4tavA8WeDfgcay1Ywu/4goHq1NOeVxkyzWTPGA7rs+tdCgdZBQZLAsBK2XFaZiHs7l+KG9r1oIRKolA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-link": { + "version": "9.7.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-link/-/react-link-9.7.4.tgz", + "integrity": "sha512-ILKFpo/QH1SRsLN9gopAyZT/b/xsGcdO4JxthEeuTRvpLD6gImvRplum8ySIlbTskVVzog6038bHUSYLMdN7OA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-list": { + "version": "9.6.10", + "resolved": "https://registry.npmjs.org/@fluentui/react-list/-/react-list-9.6.10.tgz", + "integrity": "sha512-NTAWYL8Z4h9N9N1b39H9xqfTyhfGkhlNTc3higpoIS/6jgEf6GMNF8iwvAyhB++hFdjBd27c+NbDl4MCwHhGiA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-checkbox": "^9.5.15", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-menu": { + "version": "9.21.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-menu/-/react-menu-9.21.2.tgz", + "integrity": "sha512-n/GmEppa1h7FWn3iKDWFK7Oj7ww65e+FKyvQb7BtqkTRJXtcQ1eTR7upFOhoEf5AE5PN/5hL19/BDf+f+3GMqw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.21.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-message-bar": { + "version": "9.6.19", + "resolved": "https://registry.npmjs.org/@fluentui/react-message-bar/-/react-message-bar-9.6.19.tgz", + "integrity": "sha512-NgWLLUfulxwF+WF8jFqIV3n/2bv3ZG23n9zVp+3Vejmu7XfIVJ+5dhh/l4Y/hSlKuRgNieq8nu/EMLbRLn2zKQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-link": "^9.7.4", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion/-/react-motion-9.12.0.tgz", + "integrity": "sha512-+SBpgKLj4nXLqaulqa7LNP1bRsGO6zNesCs7ixHANFn/bGMOzET8Y3w0o522jVGZpzabEYQN7GotQy2QjT2IJg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-motion-components-preview": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-motion-components-preview/-/react-motion-components-preview-0.15.1.tgz", + "integrity": "sha512-JA1CfznIme/YD5axU3iqYCoCpBqNDbql0k6CSB6niZ2YNo5md8J+/0qHjB9B5KmA1X35+0qmSSgu4G1SOqSvfw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-motion": "*", + "@fluentui/react-utilities": "*", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-nav": { + "version": "9.3.19", + "resolved": "https://registry.npmjs.org/@fluentui/react-nav/-/react-nav-9.3.19.tgz", + "integrity": "sha512-nEoHY/lMvWhiz6Udj7Hxvoz/R3WEafwQoedJqjeiLm+4vfoVaEEzGcC81jgbefnYdtRX19s90WIBkbcwWp/T4g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-divider": "^9.6.2", + "@fluentui/react-drawer": "^9.11.4", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-tooltip": "^9.9.2", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-overflow": { + "version": "9.7.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-overflow/-/react-overflow-9.7.1.tgz", + "integrity": "sha512-Ml1GlcLrAUv31d9WN15WGOZv32gzDtZD5Mp1MOQ3ichDfTtxrswIch7MDzZ8hLMGf/7Y2IzBpV8iFR1XdSrGBA==", + "license": "MIT", + "dependencies": { + "@fluentui/priority-overflow": "^9.3.0", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-persona": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-persona/-/react-persona-9.6.1.tgz", + "integrity": "sha512-KQqtvd+IVdf/XsAU8e4WcOJaHBhe6Oj83w7ZVq/7xpXzbHZsTvBPUhdcnbo9/hjSf2UYh6Duu2mnOuH8ksjfdw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-avatar": "^9.10.1", + "@fluentui/react-badge": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-popover": { + "version": "9.13.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-popover/-/react-popover-9.13.2.tgz", + "integrity": "sha512-FtAesk3RecprQAgmh4raFP0GICWl250itCfB3AUb75b+1onPfTsZcdhfOiumRmU6smQy0N9w7HG2ZxHgl5jvSA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.21.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-portal": { + "version": "9.8.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-portal/-/react-portal-9.8.11.tgz", + "integrity": "sha512-2eg4MdW7e2UGRYWPg05GCytAjWYNd55YOP9+iUDINoQwwto9oeFTtZRyn08HYw37cSNqoH24qGz/VBctzTkqDA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-positioning": { + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-positioning/-/react-positioning-9.21.0.tgz", + "integrity": "sha512-1hkzaEQszS3ZTAIL8m/tV6c8sFaLBjp0EFo1UO+RvF/JmIrg64RagsIcc5k/SZ0d6oBp04zJlNN8gNPnxFJUpQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/devtools": "^0.2.3", + "@floating-ui/dom": "^1.6.12", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-progress": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-progress/-/react-progress-9.4.15.tgz", + "integrity": "sha512-U2dqtEtov7FoeIGSAEqdFV2O2pjx3gFzbCWpPkpuLCshOSGjCPPeLV3iiTGP1WFrGCcpwFoz5O2YmsnA3wf4oQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-provider": { + "version": "9.22.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-provider/-/react-provider-9.22.15.tgz", + "integrity": "sha512-a+ImgL9DOlylDM4UYPnxQTA3yXxbVj+O0iNEyTZ6fMzdMsHzpALU4GAq6tOyW4L7RaQtRBmNpVfwTCEKpqaTJQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/core": "^1.16.0", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-radio": { + "version": "9.5.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-radio/-/react-radio-9.5.15.tgz", + "integrity": "sha512-47Zhe1Ec02QXczoPNLTFwcvCQFGoXInEiXhsQYF0tD+XAX6Q675j/z6gsIItc8V+avvD0IITsDPpqQ09wfNYkQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-rating": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-rating/-/react-rating-9.3.15.tgz", + "integrity": "sha512-MH/Jgoco8p+haf1d5Gi+d5VCjwd0qE6y/uP0YJsB9m11+DFnDxgKhzJKIiIzs3yzB2M4bMM8z9SqEHzQGCQEPg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-search": { + "version": "9.3.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-search/-/react-search-9.3.15.tgz", + "integrity": "sha512-xm9YveJM4aXAn/XjG3GMHpXxLO53Nz2mmuJpc80WXaYqQwesGSS0YfMSTbjM04RkvMsjmQM/dwWcudV9JQ0//g==", + "license": "MIT", + "dependencies": { + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-input": "^9.7.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-select": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-select/-/react-select-9.4.15.tgz", + "integrity": "sha512-NWoDzf3H7mu8fXBCR3YIlumMb7lDElsbmcCSIlUz70n2cPTNXcNEQm4ERWiGAmxf8xoAfgfDWc5rYnRWAFi2fA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-shared-contexts": { + "version": "9.26.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-shared-contexts/-/react-shared-contexts-9.26.2.tgz", + "integrity": "sha512-upKXkwlIp5oIhELr4clAZXQkuCd4GDXM6GZEz8BOmRO+PnxyqmycCXvxDxsmi6XN+0vkGM4joiIgkB14o/FctQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-theme": "^9.2.1", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-skeleton": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-skeleton/-/react-skeleton-9.4.15.tgz", + "integrity": "sha512-QUVxZ5pYbIprCY1G5sJYDGvuvM1TNFl3vPkME8r/nD7pKXwxaZYJoob2L0DQ9OdnOeHgO8yTOgOgZEU+Km89dA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-slider": { + "version": "9.5.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-slider/-/react-slider-9.5.15.tgz", + "integrity": "sha512-lFDkyYYAUUGwbg1UJqjsuQ2tQUBFjxzv2Bpyr1StyAoS91q8skTUDyZxamJTJ0K6Ox/nhkfg+Wzz2aVg9kkF4Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinbutton": { + "version": "9.5.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinbutton/-/react-spinbutton-9.5.15.tgz", + "integrity": "sha512-0NNfaXm8TJWHlillg6FPgJ1Ph7iO9ez+Gz4TSFYm1u+zF8RNsSGoplCf40U6gcKX8GkAHBwQ5vBZUbBK7syDng==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-spinner": { + "version": "9.7.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-spinner/-/react-spinner-9.7.15.tgz", + "integrity": "sha512-ZMJ7y08yvVXL9HuiMLLCy1cRn8plR9A4mL57CM2/otaXVWQbOwRaFD0/+Dx3u9A8sEtdYLo6O9gJIjU8fZGaYw==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-swatch-picker": { + "version": "9.4.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-swatch-picker/-/react-swatch-picker-9.4.15.tgz", + "integrity": "sha512-jeYSEDwLbQAW/UoTP15EZpVm2Z+UpPSjkgJaKk73UxX1+rD/JIzpxrN3FfEfkn3/uTZUQkd/SE4NQrilu1OMZQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-switch": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/@fluentui/react-switch/-/react-switch-9.5.4.tgz", + "integrity": "sha512-h5EosIApoz4bwgX6yKzKSf2ewTI21ghRZwyOhWOBmMc3g6Kt4kJU7gOyOtiRkoBcTE6tCpSKcrkhqeTM8G08IA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-label": "^9.3.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-table": { + "version": "9.19.9", + "resolved": "https://registry.npmjs.org/@fluentui/react-table/-/react-table-9.19.9.tgz", + "integrity": "sha512-CatOI+zE1/xGfhxSlYPklLwVgUQqvOhTNaqL3l8Wpe5omre/v+D5nQdTA9x9xKD+c2J4IZl3r4btOttwYJsDtA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.1", + "@fluentui/react-checkbox": "^9.5.15", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-radio": "^9.5.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabs": { + "version": "9.11.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabs/-/react-tabs-9.11.2.tgz", + "integrity": "sha512-zmWzySlPM9EwHJNW0/JhyxBCqBvmfZIj1OZLdRDpbPDsKjhO0aGZV6WjLHFYJmq58kbN0wHKUbxc7LfafHHUwA==", + "license": "MIT", + "dependencies": { + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tabster": { + "version": "9.26.13", + "resolved": "https://registry.npmjs.org/@fluentui/react-tabster/-/react-tabster-9.26.13.tgz", + "integrity": "sha512-uOuJj7jn1ME52Vc685/Ielf6srK/sfFQA5zBIbXIvy2Eisfp7R1RmJe2sXWoszz/Fu/XDkPwdM/GLv23N3vrvQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "keyborg": "^2.6.0", + "tabster": "^8.5.5" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tag-picker": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/@fluentui/react-tag-picker/-/react-tag-picker-9.8.0.tgz", + "integrity": "sha512-LQk+BFfKHYqVFCgIPbMtcQFpceeeF2Dk2HLTLnzlgt9AjavqevpWUgbjvjOHLMJ5rkn8y5un/bnD0iXiRVutgQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-combobox": "^9.16.16", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.21.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-tags": "^9.7.16", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tags": { + "version": "9.7.16", + "resolved": "https://registry.npmjs.org/@fluentui/react-tags/-/react-tags-9.7.16.tgz", + "integrity": "sha512-EgxFGG7nFtBJq3EbQyzhhxtZSSFckcHPeC9fiT9hY3GhfDwr/SYwh3jt4FiW/MY3hRjaU9EeRjkGNaVVQpA5tw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.1", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-teaching-popover": { + "version": "9.6.17", + "resolved": "https://registry.npmjs.org/@fluentui/react-teaching-popover/-/react-teaching-popover-9.6.17.tgz", + "integrity": "sha512-1edb0zk6AuK9OrUVmFOIbZb0yzuMpcSmasfXDxdMiNP/q/44iD/4Ab0LfGYChaLDHk3Vx9x0MMrzD9nX+ImRUQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-popover": "^9.13.2", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <20.0.0", + "@types/react-dom": ">=16.8.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-text": { + "version": "9.6.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-text/-/react-text-9.6.15.tgz", + "integrity": "sha512-YB1azhq8MGfnYTGlEAX1mzcFZ6CvqkkaxaCogU4TM9BtPgQ1YUAxE01RMenl8VVi8W9hNbJKkuc8R8GzYwzT4Q==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-textarea": { + "version": "9.6.15", + "resolved": "https://registry.npmjs.org/@fluentui/react-textarea/-/react-textarea-9.6.15.tgz", + "integrity": "sha512-yGYW3d+t21qJXlVsbAHz07RR/YxVw5b56483nFAbqGP3RpPG8ert8q9Ci2mldI9LpjYTG5deXUHqfcVGJ7qDAg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-field": "^9.4.15", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-theme": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-theme/-/react-theme-9.2.1.tgz", + "integrity": "sha512-lJxfz7LmmglFz+c9C41qmMqaRRZZUPtPPl9DWQ79vH+JwZd4dkN7eA78OTRwcGCOTPEKoLTX72R+EFaWEDlX+w==", + "license": "MIT", + "dependencies": { + "@fluentui/tokens": "1.0.0-alpha.23", + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/react-toast": { + "version": "9.7.13", + "resolved": "https://registry.npmjs.org/@fluentui/react-toast/-/react-toast-9.7.13.tgz", + "integrity": "sha512-mUJExTNcaeJkVugiMObfHb313y3Qntdzmhbf2R6x0q9lVp7oleYi8KLxmZRHD713q0KpAI4o0ZjIbo0c+9EvzQ==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-toolbar": { + "version": "9.7.3", + "resolved": "https://registry.npmjs.org/@fluentui/react-toolbar/-/react-toolbar-9.7.3.tgz", + "integrity": "sha512-h9mXLrQ55SFd2YXJXQOtpC+MJ3SckyGB5lWqFkQxqExFZkkeCL1u1bRf2/YFjNj8gbivVMwKmozzWeccexPeyQ==", + "license": "MIT", + "dependencies": { + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-divider": "^9.6.2", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-radio": "^9.5.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tooltip": { + "version": "9.9.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-tooltip/-/react-tooltip-9.9.2.tgz", + "integrity": "sha512-LcYQyOqUxAq/FZX4BzMMVA2aX5wkyEZGzoIguehedZClIwQFZT/DeQ2RPNIXOfpmDTs0hcb4MFb3gknFPHigBA==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-portal": "^9.8.11", + "@fluentui/react-positioning": "^9.21.0", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-tree": { + "version": "9.15.11", + "resolved": "https://registry.npmjs.org/@fluentui/react-tree/-/react-tree-9.15.11.tgz", + "integrity": "sha512-bQBa+MTAr04LIRVHsRiaG3q4DPVdyMx4VvnpiKT09eGTsVfNysXi+t65qdGfUMW7+Ppp4RlXZ6hWI3kdbWRdyw==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/react-avatar": "^9.10.1", + "@fluentui/react-button": "^9.8.2", + "@fluentui/react-checkbox": "^9.5.15", + "@fluentui/react-context-selector": "^9.2.15", + "@fluentui/react-icons": "^2.0.245", + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-motion": "^9.12.0", + "@fluentui/react-motion-components-preview": "^0.15.1", + "@fluentui/react-radio": "^9.5.15", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-tabster": "^9.26.13", + "@fluentui/react-theme": "^9.2.1", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-utilities": { + "version": "9.26.2", + "resolved": "https://registry.npmjs.org/@fluentui/react-utilities/-/react-utilities-9.26.2.tgz", + "integrity": "sha512-Yp2GGNoWifj8Z/VVir4HyRumRsqXnLJd4IP/Y70vEm9ruAvyqUvfn+1lQUuA+k/Reqw8GI+Ix7FTo3rogixZBg==", + "license": "MIT", + "dependencies": { + "@fluentui/keyboard-keys": "^9.0.8", + "@fluentui/react-shared-contexts": "^9.26.2", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/react-virtualizer": { + "version": "9.0.0-alpha.111", + "resolved": "https://registry.npmjs.org/@fluentui/react-virtualizer/-/react-virtualizer-9.0.0-alpha.111.tgz", + "integrity": "sha512-yku++0779Ve1RNz6y/HWjlXKd2x1wCSbWMydT2IdCICBVwolXjPYMpkqqZUSjbJ0N9gl6BfsCBpU9Dfe2bR8Zg==", + "license": "MIT", + "dependencies": { + "@fluentui/react-jsx-runtime": "^9.4.1", + "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-utilities": "^9.26.2", + "@griffel/react": "^1.5.32", + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@fluentui/tokens": { + "version": "1.0.0-alpha.23", + "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.23.tgz", + "integrity": "sha512-uxrzF9Z+J10naP0pGS7zPmzSkspSS+3OJDmYIK3o1nkntQrgBXq3dBob4xSlTDm5aOQ0kw6EvB9wQgtlyy4eKQ==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@griffel/core": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.19.2.tgz", + "integrity": "sha512-WkB/QQkjy9dE4vrNYGhQvRRUHFkYVOuaznVOMNTDT4pS9aTJ9XPrMTXXlkpcwaf0D3vNKoerj4zAwnU2lBzbOg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@griffel/style-types": "^1.3.0", + "csstype": "^3.1.3", + "rtl-css-js": "^1.16.1", + "stylis": "^4.2.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@griffel/react": { + "version": "1.5.32", + "resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.5.32.tgz", + "integrity": "sha512-jN3SmSwAUcWFUQuQ9jlhqZ5ELtKY21foaUR0q1mJtiAeSErVgjkpKJyMLRYpvaFGWrDql0Uz23nXUogXbsS2wQ==", + "license": "MIT", + "dependencies": { + "@griffel/core": "^1.19.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@griffel/style-types": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.3.0.tgz", + "integrity": "sha512-bHwD3sUE84Xwv4dH011gOKe1jul77M1S6ZFN9Tnq8pvZ48UMdY//vtES6fv7GRS5wXYT4iqxQPBluAiYAfkpmw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz", + "integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", + "integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz", + "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "^2" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz", + "integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-zoom": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz", + "integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "^2", + "@types/d3-selection": "^2" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", + "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-simple-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", + "integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", + "license": "MIT", + "dependencies": { + "@types/d3-geo": "^2", + "@types/d3-zoom": "^2", + "@types/geojson": "*", + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-drag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", + "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-selection": "2" + } + }, + "node_modules/d3-ease": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", + "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", + "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^2.5.0" + } + }, + "node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", + "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-transition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", + "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2", + "d3-dispatch": "1 - 2", + "d3-ease": "1 - 2", + "d3-interpolate": "1 - 2", + "d3-timer": "1 - 2" + }, + "peerDependencies": { + "d3-selection": "2" + } + }, + "node_modules/d3-zoom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", + "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-drag": "2", + "d3-interpolate": "1 - 2", + "d3-selection": "2", + "d3-transition": "2" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz", + "integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/embla-carousel-fade": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-fade/-/embla-carousel-fade-8.6.0.tgz", + "integrity": "sha512-qaYsx5mwCz72ZrjlsXgs1nKejSrW+UhkbOMwLgfRT7w2LtdEB03nPRI06GHuHv5ac2USvbEiX2/nAHctcDwvpg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyborg": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/keyborg/-/keyborg-2.6.0.tgz", + "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==", + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-simple-maps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", + "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", + "license": "MIT", + "dependencies": { + "d3-geo": "^2.0.2", + "d3-selection": "^2.0.0", + "d3-zoom": "^2.0.0", + "topojson-client": "^3.1.0" + }, + "peerDependencies": { + "prop-types": "^15.7.2", + "react": "^16.8.0 || 17.x || 18.x", + "react-dom": "^16.8.0 || 17.x || 18.x" + } + }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tabster": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/tabster/-/tabster-8.7.0.tgz", + "integrity": "sha512-AKYquti8AdWzuqJdQo4LUMQDZrHoYQy6V+8yUq2PmgLZV10EaB+8BD0nWOfC/3TBp4mPNg4fbHkz6SFtkr0PpA==", + "license": "MIT", + "dependencies": { + "keyborg": "2.6.0", + "tslib": "^2.8.1" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.53.3" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/victory-vendor/node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/victory-vendor/node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/victory-vendor/node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b62ef87 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "bangui-frontend", + "private": true, + "version": "0.1.0", + "description": "BanGUI frontend — fail2ban web management interface", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc --noEmit", + "format": "prettier --write 'src/**/*.{ts,tsx,css}'", + "test": "vitest run --config vitest.config.ts", + "coverage": "vitest run --config vitest.config.ts --coverage" + }, + "dependencies": { + "@fluentui/react-components": "^9.55.0", + "@fluentui/react-icons": "^2.0.257", + "@types/react-simple-maps": "^3.0.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", + "react-simple-maps": "^3.0.0", + "recharts": "^3.8.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.3.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "@vitejs/plugin-react": "^4.3.3", + "@vitest/coverage-v8": "^4.0.18", + "eslint": "^9.13.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^5.0.0", + "jiti": "^2.6.1", + "jsdom": "^28.1.0", + "prettier": "^3.3.3", + "typescript": "^5.6.3", + "typescript-eslint": "^8.56.1", + "vite": "^5.4.11", + "vitest": "^4.0.18" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ae418b7 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,92 @@ +/** + * Application root component. + * + * Wraps the entire application in: + * 1. `FluentProvider` — supplies the Fluent UI theme and design tokens. + * 2. `BrowserRouter` — enables client-side routing via React Router. + * 3. `AuthProvider` — manages session state and exposes `useAuth()`. + * + * Routes: + * - `/setup` — first-run setup wizard (always accessible; redirects to /login if already done) + * - `/login` — master password login (redirects to /setup if not done) + * - `/` — dashboard (protected, inside MainLayout) + * - `/map` — world map (protected) + * - `/jails` — jail list (protected) + * - `/jails/:name` — jail detail (protected) + * - `/config` — configuration editor (protected) + * - `/history` — event history (protected) + * - `/blocklists` — blocklist management (protected) + * All unmatched paths redirect to `/`. + */ + +import { FluentProvider } from "@fluentui/react-components"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { lightTheme } from "./theme/customTheme"; +import { AuthProvider } from "./providers/AuthProvider"; +import { TimezoneProvider } from "./providers/TimezoneProvider"; +import { RequireAuth } from "./components/RequireAuth"; +import { SetupGuard } from "./components/SetupGuard"; +import { MainLayout } from "./layouts/MainLayout"; +import { SetupPage } from "./pages/SetupPage"; +import { LoginPage } from "./pages/LoginPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { MapPage } from "./pages/MapPage"; +import { JailsPage } from "./pages/JailsPage"; +import { JailDetailPage } from "./pages/JailDetailPage"; +import { ConfigPage } from "./pages/ConfigPage"; +import { HistoryPage } from "./pages/HistoryPage"; +import { BlocklistsPage } from "./pages/BlocklistsPage"; + +/** + * Root application component — mounts providers and top-level routes. + */ +function App(): React.JSX.Element { + return ( + + + + + {/* Setup wizard — always accessible; redirects to /login if already done */} + } /> + + {/* Login — requires setup to be complete */} + + + + } + /> + + {/* Protected routes — require setup AND authentication */} + + + + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Fallback — redirect unknown paths to dashboard */} + } /> + + + + + ); +} + +export default App; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..6fc8266 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,35 @@ +/** + * Authentication API functions. + * + * Wraps calls to POST /api/auth/login and POST /api/auth/logout + * using the central typed fetch client. + */ + +import { api } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth"; +import { sha256Hex } from "../utils/crypto"; + +/** + * Authenticate with the master password. + * + * The password is SHA-256 hashed client-side before transmission so that + * the plaintext never leaves the browser. The backend bcrypt-verifies the + * received hash against the stored bcrypt(sha256) digest. + * + * @param password - The master password entered by the user. + * @returns The login response containing the session token. + */ +export async function login(password: string): Promise { + const body: LoginRequest = { password: await sha256Hex(password) }; + return api.post(ENDPOINTS.authLogin, body); +} + +/** + * Log out and invalidate the current session. + * + * @returns The logout confirmation message. + */ +export async function logout(): Promise { + return api.post(ENDPOINTS.authLogout, {}); +} diff --git a/frontend/src/api/blocklist.ts b/frontend/src/api/blocklist.ts new file mode 100644 index 0000000..0e9f0cd --- /dev/null +++ b/frontend/src/api/blocklist.ts @@ -0,0 +1,97 @@ +/** + * API functions for the blocklist management endpoints. + */ + +import { del, get, post, put } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { + BlocklistListResponse, + BlocklistSource, + BlocklistSourceCreate, + BlocklistSourceUpdate, + ImportLogListResponse, + ImportRunResult, + PreviewResponse, + ScheduleConfig, + ScheduleInfo, +} from "../types/blocklist"; + +// --------------------------------------------------------------------------- +// Sources +// --------------------------------------------------------------------------- + +/** Fetch all configured blocklist sources. */ +export async function fetchBlocklists(): Promise { + return get(ENDPOINTS.blocklists); +} + +/** Create a new blocklist source. */ +export async function createBlocklist( + payload: BlocklistSourceCreate, +): Promise { + return post(ENDPOINTS.blocklists, payload); +} + +/** Update a blocklist source. */ +export async function updateBlocklist( + id: number, + payload: BlocklistSourceUpdate, +): Promise { + return put(ENDPOINTS.blocklist(id), payload); +} + +/** Delete a blocklist source. */ +export async function deleteBlocklist(id: number): Promise { + await del(ENDPOINTS.blocklist(id)); +} + +// --------------------------------------------------------------------------- +// Preview +// --------------------------------------------------------------------------- + +/** Preview the contents of a blocklist source URL. */ +export async function previewBlocklist(id: number): Promise { + return get(ENDPOINTS.blocklistPreview(id)); +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +/** Trigger a manual import of all enabled sources. */ +export async function runImportNow(): Promise { + return post(ENDPOINTS.blocklistsImport, {}); +} + +// --------------------------------------------------------------------------- +// Schedule +// --------------------------------------------------------------------------- + +/** Fetch the current schedule config and next/last run times. */ +export async function fetchSchedule(): Promise { + return get(ENDPOINTS.blocklistsSchedule); +} + +/** Update the import schedule. */ +export async function updateSchedule(config: ScheduleConfig): Promise { + return put(ENDPOINTS.blocklistsSchedule, config); +} + +// --------------------------------------------------------------------------- +// Import log +// --------------------------------------------------------------------------- + +/** Fetch a paginated import log. */ +export async function fetchImportLog( + page = 1, + pageSize = 50, + sourceId?: number, +): Promise { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("page_size", String(pageSize)); + if (sourceId !== undefined) params.set("source_id", String(sourceId)); + return get( + `${ENDPOINTS.blocklistsLog}?${params.toString()}`, + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..2030ca7 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,137 @@ +/** + * Central typed API client. + * + * This is the single point of contact between the frontend and the BanGUI + * backend. Components and hooks never call `fetch` directly — they use the + * functions exported from domain-specific API modules (e.g. `api/bans.ts`), + * which call the helpers exported from this file. + * + * All request and response types are defined in `src/types/` and used here + * to guarantee type safety at the API boundary. + */ + +import { ENDPOINTS } from "./endpoints"; + +/** Base URL for all API calls. Falls back to `/api` in production. */ +const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api"; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/** Thrown by the API client when the server returns a non-2xx response. */ +export class ApiError extends Error { + /** HTTP status code returned by the server. */ + public readonly status: number; + + /** Raw response body text as returned by the server. */ + public readonly body: string; + + /** + * @param status - The HTTP status code. + * @param body - The raw response body text. + */ + constructor(status: number, body: string) { + super(`API error ${String(status)}: ${body}`); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Execute a `fetch` call and return the parsed JSON body as `T`. + * + * @param url - Fully-qualified URL. + * @param options - Standard `RequestInit` options. + * @returns Parsed JSON response cast to `T`. + * @throws {ApiError} When the server returns a non-2xx status code. + */ +async function request(url: string, options: RequestInit = {}): Promise { + const response: Response = await fetch(url, { + ...options, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(options.headers as Record | undefined), + }, + }); + + if (!response.ok) { + const body: string = await response.text(); + throw new ApiError(response.status, body); + } + + // 204 No Content — return undefined cast to T. + if (response.status === 204) { + return undefined as unknown as T; + } + + return (await response.json()) as T; +} + +// --------------------------------------------------------------------------- +// Public HTTP verb helpers +// --------------------------------------------------------------------------- + +/** + * Perform a GET request to the given path. + * + * @param path - API path relative to `BASE_URL`, e.g. `"/jails"`. + * @returns Parsed response body typed as `T`. + */ +export async function get(path: string): Promise { + return request(`${BASE_URL}${path}`); +} + +/** + * Perform a POST request with a JSON body. + * + * @param path - API path relative to `BASE_URL`. + * @param body - Request payload to serialise as JSON. + * @returns Parsed response body typed as `T`. + */ +export async function post(path: string, body: unknown): Promise { + return request(`${BASE_URL}${path}`, { + method: "POST", + body: JSON.stringify(body), + }); +} + +/** + * Perform a PUT request with a JSON body. + * + * @param path - API path relative to `BASE_URL`. + * @param body - Request payload to serialise as JSON. + * @returns Parsed response body typed as `T`. + */ +export async function put(path: string, body: unknown): Promise { + return request(`${BASE_URL}${path}`, { + method: "PUT", + body: JSON.stringify(body), + }); +} + +/** + * Perform a DELETE request, optionally with a JSON body. + * + * @param path - API path relative to `BASE_URL`. + * @param body - Optional request payload. + * @returns Parsed response body typed as `T`. + */ +export async function del(path: string, body?: unknown): Promise { + return request(`${BASE_URL}${path}`, { + method: "DELETE", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +/** Convenience namespace bundling all HTTP helpers. */ +export const api = { get, post, put, del } as const; + +// Re-export endpoints so callers only need to import from `client.ts` if desired. +export { ENDPOINTS }; diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 0000000..430849a --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,608 @@ +/** + * API functions for the configuration and server settings endpoints. + */ + +import { del, get, post, put } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { + ActionConfig, + ActionConfigUpdate, + ActionCreateRequest, + ActionListResponse, + ActionUpdateRequest, + ActivateJailRequest, + AddLogPathRequest, + AssignActionRequest, + AssignFilterRequest, + ConfFileContent, + ConfFileCreateRequest, + ConfFilesResponse, + ConfFileUpdateRequest, + Fail2BanLogResponse, + FilterConfig, + FilterConfigUpdate, + FilterCreateRequest, + FilterListResponse, + FilterUpdateRequest, + GlobalConfig, + GlobalConfigUpdate, + InactiveJailListResponse, + JailActivationResponse, + JailConfigFileContent, + JailConfigFileEnabledUpdate, + JailConfigFilesResponse, + JailConfigListResponse, + JailConfigResponse, + JailConfigUpdate, + JailValidationResult, + LogPreviewRequest, + LogPreviewResponse, + MapColorThresholdsResponse, + MapColorThresholdsUpdate, + PendingRecovery, + RegexTestRequest, + RegexTestResponse, + RollbackResponse, + ServerSettingsResponse, + ServerSettingsUpdate, + JailFileConfig, + JailFileConfigUpdate, + ServiceStatusResponse, +} from "../types/config"; + +// --------------------------------------------------------------------------- +// Jail configuration +// --------------------------------------------------------------------------- + +export async function fetchJailConfigs( +): Promise { + return get(ENDPOINTS.configJails); +} + +export async function fetchJailConfig( + name: string +): Promise { + return get(ENDPOINTS.configJail(name)); +} + +export async function updateJailConfig( + name: string, + update: JailConfigUpdate +): Promise { + await put(ENDPOINTS.configJail(name), update); +} + +// --------------------------------------------------------------------------- +// Global configuration +// --------------------------------------------------------------------------- + +export async function fetchGlobalConfig( +): Promise { + return get(ENDPOINTS.configGlobal); +} + +export async function updateGlobalConfig( + update: GlobalConfigUpdate +): Promise { + await put(ENDPOINTS.configGlobal, update); +} + +// --------------------------------------------------------------------------- +// Reload +// --------------------------------------------------------------------------- + +export async function reloadConfig( +): Promise { + await post(ENDPOINTS.configReload, undefined); +} + +// --------------------------------------------------------------------------- +// Regex tester +// --------------------------------------------------------------------------- + +export async function testRegex( + req: RegexTestRequest +): Promise { + return post(ENDPOINTS.configRegexTest, req); +} + +// --------------------------------------------------------------------------- +// Log path management +// --------------------------------------------------------------------------- + +export async function addLogPath( + jailName: string, + req: AddLogPathRequest +): Promise { + await post(ENDPOINTS.configJailLogPath(jailName), req); +} + +export async function deleteLogPath( + jailName: string, + logPath: string +): Promise { + await del( + `${ENDPOINTS.configJailLogPath(jailName)}?log_path=${encodeURIComponent(logPath)}` + ); +} + +// --------------------------------------------------------------------------- +// Log preview +// --------------------------------------------------------------------------- + +export async function previewLog( + req: LogPreviewRequest +): Promise { + return post(ENDPOINTS.configPreviewLog, req); +} + +// --------------------------------------------------------------------------- +// Server settings +// --------------------------------------------------------------------------- + +export async function fetchServerSettings( +): Promise { + return get(ENDPOINTS.serverSettings); +} + +export async function updateServerSettings( + update: ServerSettingsUpdate +): Promise { + await put(ENDPOINTS.serverSettings, update); +} + +export async function flushLogs( +): Promise { + const resp = await post<{ message: string }>( + ENDPOINTS.serverFlushLogs, + undefined, + ); + return resp.message; +} + +// --------------------------------------------------------------------------- +// Map color thresholds +// --------------------------------------------------------------------------- + +export async function fetchMapColorThresholds( +): Promise { + return get(ENDPOINTS.configMapColorThresholds); +} + +export async function updateMapColorThresholds( + update: MapColorThresholdsUpdate +): Promise { + return put( + ENDPOINTS.configMapColorThresholds, + update, + ); +} + +// --------------------------------------------------------------------------- +// Jail config files (Task 4a) +// --------------------------------------------------------------------------- + +export async function fetchJailConfigFiles(): Promise { + return get(ENDPOINTS.configJailFiles); +} + +export async function createJailConfigFile( + req: ConfFileCreateRequest +): Promise { + return post(ENDPOINTS.configJailFiles, req); +} + +export async function fetchJailConfigFileContent( + filename: string +): Promise { + return get(ENDPOINTS.configJailFile(filename)); +} + +export async function updateJailConfigFile( + filename: string, + req: ConfFileUpdateRequest +): Promise { + await put(ENDPOINTS.configJailFile(filename), req); +} + +export async function setJailConfigFileEnabled( + filename: string, + update: JailConfigFileEnabledUpdate +): Promise { + await put(ENDPOINTS.configJailFileEnabled(filename), update); +} + +// --------------------------------------------------------------------------- +// Filter files (Task 4d) — raw file management +// --------------------------------------------------------------------------- + +/** + * Return a lightweight name/filename list of all filter files. + * + * Internally calls the enriched ``GET /config/filters`` endpoint (which also + * returns active-status data) and maps the result down to the simpler + * ``ConfFilesResponse`` shape expected by the raw-file editor and export tab. + */ +export async function fetchFilterFiles(): Promise { + const result = await fetchFilters(); + return { + files: result.filters.map((f) => ({ name: f.name, filename: f.filename })), + total: result.total, + }; +} + +/** Fetch the raw content of a filter definition file for the raw editor. */ +export async function fetchFilterFile(name: string): Promise { + return get(ENDPOINTS.configFilterRaw(name)); +} + +/** Save raw content to a filter definition file (``PUT /filters/{name}/raw``). */ +export async function updateFilterFile( + name: string, + req: ConfFileUpdateRequest +): Promise { + await put(ENDPOINTS.configFilterRaw(name), req); +} + +/** Create a new raw filter file (``POST /filters/raw``). */ +export async function createFilterFile( + req: ConfFileCreateRequest +): Promise { + return post(ENDPOINTS.configFiltersRaw, req); +} + +// --------------------------------------------------------------------------- +// Action files (Task 4e) +// --------------------------------------------------------------------------- + +export async function fetchActionFiles(): Promise { + return get(ENDPOINTS.configActions); +} + +export async function fetchActionFile(name: string): Promise { + return get(ENDPOINTS.configAction(name)); +} + +export async function updateActionFile( + name: string, + req: ConfFileUpdateRequest +): Promise { + await put(ENDPOINTS.configAction(name), req); +} + +export async function createActionFile( + req: ConfFileCreateRequest +): Promise { + return post(ENDPOINTS.configActions, req); +} + +// --------------------------------------------------------------------------- +// Parsed filter config (Task 2.2 / legacy /parsed endpoint) +// --------------------------------------------------------------------------- + +export async function fetchParsedFilter(name: string): Promise { + return get(ENDPOINTS.configFilterParsed(name)); +} + +export async function updateParsedFilter( + name: string, + update: FilterConfigUpdate +): Promise { + await put(ENDPOINTS.configFilterParsed(name), update); +} + +// --------------------------------------------------------------------------- +// Filter structured update / create / delete (Task 2.3) +// --------------------------------------------------------------------------- + +/** + * Update a filter's editable fields via the structured endpoint. + * + * Writes only the supplied fields to the ``.local`` override. Fields set + * to ``null`` are cleared; omitted fields are left unchanged. + * + * @param name - Filter base name (e.g. ``"sshd"``) + * @param req - Partial update payload. + */ +export async function updateFilter( + name: string, + req: FilterUpdateRequest +): Promise { + await put(ENDPOINTS.configFilter(name), req); +} + +/** + * Create a brand-new user-defined filter in ``filter.d/{name}.local``. + * + * @param req - Name and optional regex patterns. + * @returns The newly created FilterConfig. + */ +export async function createFilter( + req: FilterCreateRequest +): Promise { + return post(ENDPOINTS.configFilters, req); +} + +/** + * Delete a filter's ``.local`` override file. + * + * Only custom ``.local``-only filters can be deleted. Attempting to delete a + * filter that is backed by a shipped ``.conf`` file returns 409. + * + * @param name - Filter base name. + */ +export async function deleteFilter(name: string): Promise { + await del(ENDPOINTS.configFilter(name)); +} + +/** + * Assign a filter to a jail by writing ``filter = {filter_name}`` to the + * jail's ``.local`` config file. + * + * @param jailName - Jail name. + * @param req - The filter to assign. + * @param reload - When ``true``, trigger a fail2ban reload after writing. + */ +export async function assignFilterToJail( + jailName: string, + req: AssignFilterRequest, + reload = false +): Promise { + const url = reload + ? `${ENDPOINTS.configJailFilter(jailName)}?reload=true` + : ENDPOINTS.configJailFilter(jailName); + await post(url, req); +} + +// --------------------------------------------------------------------------- +// Filter discovery with active/inactive status (Task 2.1) +// --------------------------------------------------------------------------- + +/** + * Fetch all filters from filter.d/ with active/inactive status. + * + * Active filters (those referenced by running jails) are returned first, + * followed by inactive ones. Both groups are sorted alphabetically. + * + * @returns FilterListResponse with all discovered filters and status. + */ +export async function fetchFilters(): Promise { + return get(ENDPOINTS.configFilters); +} + +/** + * Fetch full parsed detail for a single filter with active/inactive status. + * + * @param name - Filter base name (e.g. "sshd" or "sshd.conf"). + * @returns FilterConfig with active, used_by_jails, source_file populated. + */ +export async function fetchFilter(name: string): Promise { + return get(ENDPOINTS.configFilter(name)); +} + +// --------------------------------------------------------------------------- +// Parsed action config (Task 3.2) +// --------------------------------------------------------------------------- + +export async function fetchParsedAction(name: string): Promise { + return get(ENDPOINTS.configActionParsed(name)); +} + +export async function updateParsedAction( + name: string, + update: ActionConfigUpdate +): Promise { + await put(ENDPOINTS.configActionParsed(name), update); +} + +// --------------------------------------------------------------------------- +// Action discovery with active/inactive status (Task 3.1 / 3.2) +// --------------------------------------------------------------------------- + +/** + * Fetch all actions from action.d/ with active/inactive status. + * + * Active actions (those referenced by running jails) are returned first, + * followed by inactive ones. Both groups are sorted alphabetically. + * + * @returns ActionListResponse with all discovered actions and status. + */ +export async function fetchActions(): Promise { + return get(ENDPOINTS.configActions); +} + +/** + * Fetch full parsed detail for a single action. + * + * @param name - Action base name (e.g. "iptables" or "iptables.conf"). + * @returns ActionConfig with active, used_by_jails, source_file populated. + */ +export async function fetchAction(name: string): Promise { + return get(ENDPOINTS.configAction(name)); +} + +/** + * Update an action's editable fields via the structured endpoint. + * + * Writes only the supplied fields to the ``.local`` override. Fields set + * to ``null`` are cleared; omitted fields are left unchanged. + * + * @param name - Action base name (e.g. ``"iptables"``) + * @param req - Partial update payload. + */ +export async function updateAction( + name: string, + req: ActionUpdateRequest +): Promise { + await put(ENDPOINTS.configAction(name), req); +} + +/** + * Create a brand-new user-defined action in ``action.d/{name}.local``. + * + * @param req - Name and optional lifecycle commands. + * @returns The newly created ActionConfig. + */ +export async function createAction( + req: ActionCreateRequest +): Promise { + return post(ENDPOINTS.configActions, req); +} + +/** + * Delete an action's ``.local`` override file. + * + * Only custom ``.local``-only actions can be deleted. Attempting to delete an + * action backed by a shipped ``.conf`` file returns 409. + * + * @param name - Action base name. + */ +export async function deleteAction(name: string): Promise { + await del(ENDPOINTS.configAction(name)); +} + +/** + * Assign an action to a jail by appending it to the jail's action list. + * + * @param jailName - Jail name. + * @param req - The action to assign with optional parameters. + * @param reload - When ``true``, trigger a fail2ban reload after writing. + */ +export async function assignActionToJail( + jailName: string, + req: AssignActionRequest, + reload = false +): Promise { + const url = reload + ? `${ENDPOINTS.configJailAction(jailName)}?reload=true` + : ENDPOINTS.configJailAction(jailName); + await post(url, req); +} + +/** + * Remove an action from a jail's action list. + * + * @param jailName - Jail name. + * @param actionName - Action base name to remove. + * @param reload - When ``true``, trigger a fail2ban reload after writing. + */ +export async function removeActionFromJail( + jailName: string, + actionName: string, + reload = false +): Promise { + const url = reload + ? `${ENDPOINTS.configJailActionName(jailName, actionName)}?reload=true` + : ENDPOINTS.configJailActionName(jailName, actionName); + await del(url); +} + +// --------------------------------------------------------------------------- +// Parsed jail file config (Task 6.1 / 6.2) +// --------------------------------------------------------------------------- + +export async function fetchParsedJailFile(filename: string): Promise { + return get(ENDPOINTS.configJailFileParsed(filename)); +} + +export async function updateParsedJailFile( + filename: string, + update: JailFileConfigUpdate +): Promise { + await put(ENDPOINTS.configJailFileParsed(filename), update); +} + +// --------------------------------------------------------------------------- +// Inactive jails (Stage 1) +// --------------------------------------------------------------------------- + +/** Fetch all inactive jails from config files. */ +export async function fetchInactiveJails(): Promise { + return get(ENDPOINTS.configJailsInactive); +} + +/** + * Activate an inactive jail, optionally providing override values. + * + * @param name - The jail name. + * @param overrides - Optional parameter overrides (bantime, findtime, etc.). + */ +export async function activateJail( + name: string, + overrides?: ActivateJailRequest +): Promise { + return post( + ENDPOINTS.configJailActivate(name), + overrides ?? {} + ); +} + +/** Deactivate an active jail. */ +export async function deactivateJail( + name: string +): Promise { + return post( + ENDPOINTS.configJailDeactivate(name), + undefined + ); +} + +// --------------------------------------------------------------------------- +// fail2ban log viewer (Task 2) +// --------------------------------------------------------------------------- + +/** + * Fetch the tail of the fail2ban daemon log file. + * + * @param lines - Number of tail lines to return (1–2000, default 200). + * @param filter - Optional plain-text substring; only matching lines returned. + */ +export async function fetchFail2BanLog( + lines?: number, + filter?: string, +): Promise { + const params = new URLSearchParams(); + if (lines !== undefined) params.set("lines", String(lines)); + if (filter !== undefined && filter !== "") params.set("filter", filter); + const query = params.toString() ? `?${params.toString()}` : ""; + return get(`${ENDPOINTS.configFail2BanLog}${query}`); +} + +/** Fetch fail2ban service health status with current log configuration. */ +export async function fetchServiceStatus(): Promise { + return get(ENDPOINTS.configServiceStatus); +} + +// --------------------------------------------------------------------------- +// Jail config recovery (Task 3) +// --------------------------------------------------------------------------- + +/** + * Run pre-activation validation on a jail's config. + * + * Checks that referenced filter/action files exist, that all regex patterns + * compile, and that log paths are accessible on the server. + */ +export async function validateJailConfig( + name: string, +): Promise { + return post(ENDPOINTS.configJailValidate(name), undefined); +} + +/** + * Fetch the pending crash-recovery record, if any. + * + * Returns null when fail2ban is healthy and no recovery is pending. + */ +export async function fetchPendingRecovery(): Promise { + return get(ENDPOINTS.configPendingRecovery); +} + +/** + * Rollback a bad jail — disables it and attempts to restart fail2ban. + * + * @param name - Name of the jail to disable. + */ +export async function rollbackJail(name: string): Promise { + return post(ENDPOINTS.configJailRollback(name), undefined); +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..90309d8 --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,95 @@ +/** + * Dashboard API module. + * + * Wraps `GET /api/dashboard/status` and `GET /api/dashboard/bans`. + */ + +import { get } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { + BanOriginFilter, + BansByJailResponse, + BanTrendResponse, + DashboardBanListResponse, + TimeRange, +} from "../types/ban"; +import type { ServerStatusResponse } from "../types/server"; + +/** + * Fetch the cached fail2ban server status from the backend. + * + * @returns The server status response containing `online`, `version`, + * `active_jails`, `total_bans`, and `total_failures`. + * @throws {ApiError} When the server returns a non-2xx status. + */ +export async function fetchServerStatus(): Promise { + return get(ENDPOINTS.dashboardStatus); +} + +/** + * Fetch a paginated ban list for the selected time window. + * + * @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`. + * @param page - 1-based page number (default `1`). + * @param pageSize - Items per page (default `100`). + * @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"` + * (default `"all"`, which omits the parameter entirely). + * @returns Paginated {@link DashboardBanListResponse}. + * @throws {ApiError} When the server returns a non-2xx status. + */ +export async function fetchBans( + range: TimeRange, + page = 1, + pageSize = 100, + origin: BanOriginFilter = "all", +): Promise { + const params = new URLSearchParams({ + range, + page: String(page), + page_size: String(pageSize), + }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBans}?${params.toString()}`); +} + +/** + * Fetch ban counts grouped into equal-width time buckets for the trend chart. + * + * @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`. + * @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"` + * (default `"all"`, which omits the parameter entirely). + * @returns {@link BanTrendResponse} with the ordered bucket list. + * @throws {ApiError} When the server returns a non-2xx status. + */ +export async function fetchBanTrend( + range: TimeRange, + origin: BanOriginFilter = "all", +): Promise { + const params = new URLSearchParams({ range }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBansTrend}?${params.toString()}`); +} + +/** + * Fetch ban counts aggregated by jail for the selected time window. + * + * @param range - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`. + * @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"` + * (default `"all"`, which omits the parameter entirely). + * @returns {@link BansByJailResponse} with jails sorted by ban count descending. + * @throws {ApiError} When the server returns a non-2xx status. + */ +export async function fetchBansByJail( + range: TimeRange, + origin: BanOriginFilter = "all", +): Promise { + const params = new URLSearchParams({ range }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBansByJail}?${params.toString()}`); +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 0000000..c3f7037 --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,135 @@ +/** + * API endpoint path constants. + * + * Every backend path used by the frontend is defined here. + * Components and API modules import from this file rather than + * hard-coding URL strings, so renaming an endpoint requires only one change. + */ + +export const ENDPOINTS = { + // ------------------------------------------------------------------------- + // Health + // ------------------------------------------------------------------------- + health: "/health", + + // ------------------------------------------------------------------------- + // Setup wizard + // ------------------------------------------------------------------------- + setup: "/setup", + setupTimezone: "/setup/timezone", + + // ------------------------------------------------------------------------- + // Authentication + // ------------------------------------------------------------------------- + authLogin: "/auth/login", + authLogout: "/auth/logout", + + // ------------------------------------------------------------------------- + // Dashboard + // ------------------------------------------------------------------------- + dashboardStatus: "/dashboard/status", + dashboardBans: "/dashboard/bans", + dashboardBansByCountry: "/dashboard/bans/by-country", + dashboardBansTrend: "/dashboard/bans/trend", + dashboardBansByJail: "/dashboard/bans/by-jail", + + // ------------------------------------------------------------------------- + // Jails + // ------------------------------------------------------------------------- + jails: "/jails", + jail: (name: string): string => `/jails/${encodeURIComponent(name)}`, + jailBanned: (name: string): string => `/jails/${encodeURIComponent(name)}/banned`, + jailStart: (name: string): string => `/jails/${encodeURIComponent(name)}/start`, + jailStop: (name: string): string => `/jails/${encodeURIComponent(name)}/stop`, + jailIdle: (name: string): string => `/jails/${encodeURIComponent(name)}/idle`, + jailReload: (name: string): string => `/jails/${encodeURIComponent(name)}/reload`, + jailsReloadAll: "/jails/reload-all", + jailIgnoreIp: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreip`, + jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`, + + // ------------------------------------------------------------------------- + // Bans + // ------------------------------------------------------------------------- + bans: "/bans", + bansActive: "/bans/active", + bansAll: "/bans/all", + + // ------------------------------------------------------------------------- + // Geo / IP lookup + // ------------------------------------------------------------------------- + geoLookup: (ip: string): string => `/geo/lookup/${encodeURIComponent(ip)}`, + + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + configJails: "/config/jails", + configJailsInactive: "/config/jails/inactive", + configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`, + configJailLogPath: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/logpath`, + configJailActivate: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/activate`, + configJailDeactivate: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/deactivate`, + configJailValidate: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/validate`, + configJailRollback: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/rollback`, + configPendingRecovery: "/config/pending-recovery" as string, + configGlobal: "/config/global", + configReload: "/config/reload", + configRegexTest: "/config/regex-test", + configPreviewLog: "/config/preview-log", + configMapColorThresholds: "/config/map-color-thresholds", + + // File-based config (jail.d, filter.d, action.d) + configJailFiles: "/config/jail-files", + configJailFile: (filename: string): string => + `/config/jail-files/${encodeURIComponent(filename)}`, + configJailFileEnabled: (filename: string): string => + `/config/jail-files/${encodeURIComponent(filename)}/enabled`, + configJailFileParsed: (filename: string): string => + `/config/jail-files/${encodeURIComponent(filename)}/parsed`, + configFilters: "/config/filters", + configFiltersRaw: "/config/filters/raw", + configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`, + configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`, + configFilterParsed: (name: string): string => + `/config/filters/${encodeURIComponent(name)}/parsed`, + configJailFilter: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/filter`, + configJailAction: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/action`, + configJailActionName: (jailName: string, actionName: string): string => + `/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`, + configActions: "/config/actions", + configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`, + configActionParsed: (name: string): string => + `/config/actions/${encodeURIComponent(name)}/parsed`, + + // fail2ban log viewer (Task 2) + configFail2BanLog: "/config/fail2ban-log", + configServiceStatus: "/config/service-status", + + // ------------------------------------------------------------------------- + // Server settings + // ------------------------------------------------------------------------- + serverSettings: "/server/settings", + serverFlushLogs: "/server/flush-logs", + + // ------------------------------------------------------------------------- + // Ban history + // ------------------------------------------------------------------------- + history: "/history", + historyIp: (ip: string): string => `/history/${encodeURIComponent(ip)}`, + + // ------------------------------------------------------------------------- + // Blocklists + // ------------------------------------------------------------------------- + blocklists: "/blocklists", + blocklist: (id: number): string => `/blocklists/${String(id)}`, + blocklistPreview: (id: number): string => `/blocklists/${String(id)}/preview`, + blocklistsImport: "/blocklists/import", + blocklistsSchedule: "/blocklists/schedule", + blocklistsLog: "/blocklists/log", +} as const; diff --git a/frontend/src/api/history.ts b/frontend/src/api/history.ts new file mode 100644 index 0000000..eadc34c --- /dev/null +++ b/frontend/src/api/history.ts @@ -0,0 +1,53 @@ +/** + * API functions for the ban history endpoints. + */ + +import { get } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { + HistoryListResponse, + HistoryQuery, + IpDetailResponse, +} from "../types/history"; + +/** + * Fetch a paginated list of historical bans with optional filters. + */ +export async function fetchHistory( + query: HistoryQuery = {}, +): Promise { + const params = new URLSearchParams(); + if (query.range) params.set("range", query.range); + if (query.jail) params.set("jail", query.jail); + if (query.ip) params.set("ip", query.ip); + if (query.page !== undefined) params.set("page", String(query.page)); + if (query.page_size !== undefined) + params.set("page_size", String(query.page_size)); + + const qs = params.toString(); + const url = qs + ? `${ENDPOINTS.history}?${qs}` + : ENDPOINTS.history; + return get(url); +} + +/** + * Fetch the full ban history for a single IP address. + * + * @returns null when the server returns 404 (no history for this IP). + */ +export async function fetchIpHistory(ip: string): Promise { + try { + return await get(ENDPOINTS.historyIp(ip)); + } catch (err: unknown) { + if ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ) { + return null; + } + throw err; + } +} diff --git a/frontend/src/api/jails.ts b/frontend/src/api/jails.ts new file mode 100644 index 0000000..c141c32 --- /dev/null +++ b/frontend/src/api/jails.ts @@ -0,0 +1,279 @@ +/** + * Jails API module. + * + * Wraps all backend endpoints under `/api/jails`, `/api/bans`, and + * `/api/geo` that relate to jail management. + */ + +import { del, get, post } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { + ActiveBanListResponse, + IpLookupResponse, + JailBannedIpsResponse, + JailCommandResponse, + JailDetailResponse, + JailListResponse, + UnbanAllResponse, +} from "../types/jail"; + +// --------------------------------------------------------------------------- +// Jail overview +// --------------------------------------------------------------------------- + +/** + * Fetch the list of all fail2ban jails. + * + * @returns A {@link JailListResponse} containing summary info for each jail. + * @throws {ApiError} On non-2xx responses. + */ +export async function fetchJails(): Promise { + return get(ENDPOINTS.jails); +} + +/** + * Fetch full detail for a single jail. + * + * @param name - Jail name (e.g. `"sshd"`). + * @returns A {@link JailDetailResponse} with config, ignore list, and status. + * @throws {ApiError} On non-2xx responses (404 if the jail does not exist). + */ +export async function fetchJail(name: string): Promise { + return get(ENDPOINTS.jail(name)); +} + +// --------------------------------------------------------------------------- +// Jail controls +// --------------------------------------------------------------------------- + +/** + * Start a stopped jail. + * + * @param name - Jail name. + * @returns A {@link JailCommandResponse} confirming the operation. + * @throws {ApiError} On non-2xx responses. + */ +export async function startJail(name: string): Promise { + return post(ENDPOINTS.jailStart(name), {}); +} + +/** + * Stop a running jail. + * + * @param name - Jail name. + * @returns A {@link JailCommandResponse} confirming the operation. + * @throws {ApiError} On non-2xx responses. + */ +export async function stopJail(name: string): Promise { + return post(ENDPOINTS.jailStop(name), {}); +} + +/** + * Toggle idle mode for a jail. + * + * @param name - Jail name. + * @param on - `true` to enable idle mode, `false` to disable. + * @returns A {@link JailCommandResponse} confirming the toggle. + * @throws {ApiError} On non-2xx responses. + */ +export async function setJailIdle( + name: string, + on: boolean, +): Promise { + return post(ENDPOINTS.jailIdle(name), on); +} + +/** + * Reload configuration for a single jail. + * + * @param name - Jail name. + * @returns A {@link JailCommandResponse} confirming the reload. + * @throws {ApiError} On non-2xx responses. + */ +export async function reloadJail(name: string): Promise { + return post(ENDPOINTS.jailReload(name), {}); +} + +/** + * Reload configuration for **all** jails at once. + * + * @returns A {@link JailCommandResponse} confirming the operation. + * @throws {ApiError} On non-2xx responses. + */ +export async function reloadAllJails(): Promise { + return post(ENDPOINTS.jailsReloadAll, {}); +} + +// --------------------------------------------------------------------------- +// Ignore list +// --------------------------------------------------------------------------- + +/** + * Return the ignore list for a jail. + * + * @param name - Jail name. + * @returns Array of IP addresses / CIDR networks on the ignore list. + * @throws {ApiError} On non-2xx responses. + */ +export async function fetchIgnoreList(name: string): Promise { + return get(ENDPOINTS.jailIgnoreIp(name)); +} + +/** + * Add an IP or CIDR network to a jail's ignore list. + * + * @param name - Jail name. + * @param ip - IP address or CIDR network to add. + * @returns A {@link JailCommandResponse} confirming the addition. + * @throws {ApiError} On non-2xx responses. + */ +export async function addIgnoreIp( + name: string, + ip: string, +): Promise { + return post(ENDPOINTS.jailIgnoreIp(name), { ip }); +} + +/** + * Remove an IP or CIDR network from a jail's ignore list. + * + * @param name - Jail name. + * @param ip - IP address or CIDR network to remove. + * @returns A {@link JailCommandResponse} confirming the removal. + * @throws {ApiError} On non-2xx responses. + */ +export async function delIgnoreIp( + name: string, + ip: string, +): Promise { + return del(ENDPOINTS.jailIgnoreIp(name), { ip }); +} + +/** + * Enable or disable the `ignoreself` flag for a jail. + * + * When enabled, fail2ban automatically adds the server's own IP addresses to + * the ignore list so the host can never ban itself. + * + * @param name - Jail name. + * @param on - `true` to enable, `false` to disable. + * @returns A {@link JailCommandResponse} confirming the change. + * @throws {ApiError} On non-2xx responses. + */ +export async function toggleIgnoreSelf( + name: string, + on: boolean, +): Promise { + return post(ENDPOINTS.jailIgnoreSelf(name), on); +} + +// --------------------------------------------------------------------------- +// Ban / unban +// --------------------------------------------------------------------------- + +/** + * Manually ban an IP address in a specific jail. + * + * @param jail - Jail name. + * @param ip - IP address to ban. + * @returns A {@link JailCommandResponse} confirming the ban. + * @throws {ApiError} On non-2xx responses. + */ +export async function banIp( + jail: string, + ip: string, +): Promise { + return post(ENDPOINTS.bans, { jail, ip }); +} + +/** + * Unban an IP address from a specific jail or all jails. + * + * @param ip - IP address to unban. + * @param jail - Target jail name, or `undefined` to unban from all jails. + * @param unbanAll - When `true`, remove the IP from every jail. + * @returns A {@link JailCommandResponse} confirming the unban. + * @throws {ApiError} On non-2xx responses. + */ +export async function unbanIp( + ip: string, + jail?: string, + unbanAll = false, +): Promise { + return del(ENDPOINTS.bans, { ip, jail, unban_all: unbanAll }); +} + +// --------------------------------------------------------------------------- +// Active bans +// --------------------------------------------------------------------------- + +/** + * Fetch all currently active bans across every jail. + * + * @returns An {@link ActiveBanListResponse} with geo-enriched entries. + * @throws {ApiError} On non-2xx responses. + */ +export async function fetchActiveBans(): Promise { + return get(ENDPOINTS.bansActive); +} + +/** + * Unban every currently banned IP across all jails in a single operation. + * + * Uses fail2ban's global `unban --all` command. + * + * @returns An {@link UnbanAllResponse} with the count of unbanned IPs. + * @throws {ApiError} On non-2xx responses. + */ +export async function unbanAllBans(): Promise { + return del(ENDPOINTS.bansAll); +} + +// --------------------------------------------------------------------------- +// Geo / IP lookup +// --------------------------------------------------------------------------- + +/** + * Look up ban status and geo-location for an IP address. + * + * @param ip - IP address to look up. + * @returns An {@link IpLookupResponse} with ban history and geo info. + * @throws {ApiError} On non-2xx responses (400 for invalid IP). + */ +export async function lookupIp(ip: string): Promise { + return get(ENDPOINTS.geoLookup(ip)); +} + +// --------------------------------------------------------------------------- +// Jail-specific paginated bans +// --------------------------------------------------------------------------- + +/** + * Fetch the currently banned IPs for a specific jail, paginated. + * + * Only the requested page is geo-enriched on the backend, so this call + * remains fast even when a jail has thousands of banned IPs. + * + * @param jailName - Jail name (e.g. `"sshd"`). + * @param page - 1-based page number (default 1). + * @param pageSize - Items per page; max 100 (default 25). + * @param search - Optional case-insensitive IP substring filter. + * @returns A {@link JailBannedIpsResponse} with paginated ban entries. + * @throws {ApiError} On non-2xx responses (404 if jail unknown, 502 if fail2ban down). + */ +export async function fetchJailBannedIps( + jailName: string, + page = 1, + pageSize = 25, + search?: string, +): Promise { + const params: Record = { + page: String(page), + page_size: String(pageSize), + }; + if (search !== undefined && search !== "") { + params.search = search; + } + const query = new URLSearchParams(params).toString(); + return get(`${ENDPOINTS.jailBanned(jailName)}?${query}`); +} diff --git a/frontend/src/api/map.ts b/frontend/src/api/map.ts new file mode 100644 index 0000000..e6b8eda --- /dev/null +++ b/frontend/src/api/map.ts @@ -0,0 +1,26 @@ +/** + * API functions for the world map / bans-by-country endpoint. + */ + +import { get } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { BansByCountryResponse, TimeRange } from "../types/map"; +import type { BanOriginFilter } from "../types/ban"; + +/** + * Fetch ban counts aggregated by country for the given time window. + * + * @param range - Time-range preset. + * @param origin - Origin filter: `"blocklist"`, `"selfblock"`, or `"all"` + * (default `"all"`, which omits the parameter entirely). + */ +export async function fetchBansByCountry( + range: TimeRange = "24h", + origin: BanOriginFilter = "all", +): Promise { + const params = new URLSearchParams({ range }); + if (origin !== "all") { + params.set("origin", origin); + } + return get(`${ENDPOINTS.dashboardBansByCountry}?${params.toString()}`); +} diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts new file mode 100644 index 0000000..2036390 --- /dev/null +++ b/frontend/src/api/setup.ts @@ -0,0 +1,45 @@ +/** + * Setup wizard API functions. + * + * Wraps calls to GET /api/setup and POST /api/setup. + */ + +import { api } from "./client"; +import { ENDPOINTS } from "./endpoints"; +import type { + SetupRequest, + SetupResponse, + SetupStatusResponse, + SetupTimezoneResponse, +} from "../types/setup"; + +/** + * Check whether the initial setup has been completed. + * + * @returns Setup status response with a `completed` boolean. + */ +export async function getSetupStatus(): Promise { + return api.get(ENDPOINTS.setup); +} + +/** + * Submit the initial setup configuration. + * + * @param data - Setup request payload. + * @returns Success message from the API. + */ +export async function submitSetup(data: SetupRequest): Promise { + return api.post(ENDPOINTS.setup, data); +} + +/** + * Fetch the IANA timezone configured during setup. + * + * Used by the frontend to convert UTC timestamps to the local timezone + * chosen by the administrator. + * + * @returns The configured timezone identifier (e.g. `"Europe/Berlin"`). + */ +export async function fetchTimezone(): Promise { + return api.get(ENDPOINTS.setupTimezone); +} diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 0000000..01d10b0 --- /dev/null +++ b/frontend/src/components/.gitkeep @@ -0,0 +1 @@ +/** Reusable UI component exports. Components are added here as they are implemented. */ diff --git a/frontend/src/components/BanTable.tsx b/frontend/src/components/BanTable.tsx new file mode 100644 index 0000000..4becf40 --- /dev/null +++ b/frontend/src/components/BanTable.tsx @@ -0,0 +1,298 @@ +/** + * `BanTable` component. + * + * Renders a Fluent UI v9 `DataGrid` for the dashboard ban-list view. + * Uses the {@link useBans} hook to fetch and manage paginated data from + * the backend. + * + * Columns: Time, IP, Service, Country, Jail, Ban Count. + */ + +import { + Badge, + Button, + DataGrid, + DataGridBody, + DataGridCell, + DataGridHeader, + DataGridHeaderCell, + DataGridRow, + Text, + Tooltip, + makeStyles, + tokens, + type TableColumnDefinition, + createTableColumn, +} from "@fluentui/react-components"; +import { PageEmpty, PageError, PageLoading } from "./PageFeedback"; +import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons"; +import { useBans } from "../hooks/useBans"; +import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Props for the {@link BanTable} component. */ +interface BanTableProps { + /** + * Active time-range preset — controlled by the parent `DashboardPage`. + * Changing this value triggers a re-fetch. + */ + timeRange: TimeRange; + /** + * Active origin filter — controlled by the parent `DashboardPage`. + * Changing this value triggers a re-fetch and resets to page 1. + */ + origin?: BanOriginFilter; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + root: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalS, + minHeight: "300px", + }, + centred: { + display: "flex", + justifyContent: "center", + alignItems: "center", + padding: tokens.spacingVerticalXXL, + }, + tableWrapper: { + overflowX: "auto", + }, + pagination: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: tokens.spacingHorizontalS, + paddingTop: tokens.spacingVerticalS, + }, + mono: { + fontFamily: "Consolas, 'Courier New', monospace", + fontSize: tokens.fontSizeBase200, + }, + truncate: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + maxWidth: "280px", + display: "inline-block", + }, + countBadge: { + fontVariantNumeric: "tabular-nums", + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Format an ISO 8601 timestamp for display. + * + * @param iso - ISO 8601 UTC string. + * @returns Localised date+time string. + */ +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return iso; + } +} + +// --------------------------------------------------------------------------- +// Column definitions +// --------------------------------------------------------------------------- + +/** Columns for the ban-list view (`mode === "bans"`). */ +function buildBanColumns(styles: ReturnType): TableColumnDefinition[] { + return [ + createTableColumn({ + columnId: "banned_at", + renderHeaderCell: () => "Time of Ban", + renderCell: (item) => ( + {formatTimestamp(item.banned_at)} + ), + }), + createTableColumn({ + columnId: "ip", + renderHeaderCell: () => "IP Address", + renderCell: (item) => ( + {item.ip} + ), + }), + createTableColumn({ + columnId: "service", + renderHeaderCell: () => "Service / URL", + renderCell: (item) => + item.service ? ( + + {item.service} + + ) : ( + + — + + ), + }), + createTableColumn({ + columnId: "country", + renderHeaderCell: () => "Country", + renderCell: (item) => + item.country_name ?? item.country_code ? ( + {item.country_name ?? item.country_code} + ) : ( + + + — + + + ), + }), + createTableColumn({ + columnId: "jail", + renderHeaderCell: () => "Jail", + renderCell: (item) => {item.jail}, + }), + createTableColumn({ + columnId: "origin", + renderHeaderCell: () => "Origin", + renderCell: (item) => ( + + {item.origin === "blocklist" ? "Blocklist" : "Selfblock"} + + ), + }), + createTableColumn({ + columnId: "ban_count", + renderHeaderCell: () => "Bans", + renderCell: (item) => ( + 1 ? "filled" : "outline"} + color={item.ban_count > 5 ? "danger" : item.ban_count > 1 ? "warning" : "informative"} + className={styles.countBadge} + > + {item.ban_count} + + ), + }), + ]; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Data table for the dashboard ban-list view. + * + * @param props.timeRange - Active time-range preset from the parent page. + * @param props.origin - Active origin filter from the parent page. + */ +export function BanTable({ timeRange, origin = "all" }: BanTableProps): React.JSX.Element { + const styles = useStyles(); + const { banItems, total, page, setPage, loading, error, refresh } = useBans(timeRange, origin); + + const banColumns = buildBanColumns(styles); + + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + if (loading) { + return ; + } + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + if (error) { + return ; + } + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + if (banItems.length === 0) { + return ; + } + + // -------------------------------------------------------------------------- + // Pagination helpers + // -------------------------------------------------------------------------- + const pageSize = 100; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const hasPrev = page > 1; + const hasNext = page < totalPages; + + // -------------------------------------------------------------------------- + // Render + // -------------------------------------------------------------------------- + return ( +
+
+ `${item.ip}:${item.jail}:${item.banned_at}`} + > + + + {({ renderHeaderCell }) => ( + {renderHeaderCell()} + )} + + + > + {({ item, rowId }) => ( + key={rowId}> + {({ renderCell }) => ( + {renderCell(item)} + )} + + )} + + +
+
+ + {total} total · Page {page} of {totalPages} + +
+
+ ); +} diff --git a/frontend/src/components/BanTrendChart.tsx b/frontend/src/components/BanTrendChart.tsx new file mode 100644 index 0000000..9f56586 --- /dev/null +++ b/frontend/src/components/BanTrendChart.tsx @@ -0,0 +1,249 @@ +/** + * BanTrendChart — area chart showing the number of bans over time. + * + * Calls `useBanTrend` internally and handles loading, error, and empty states. + */ + +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipContentProps } from "recharts/types/component/Tooltip"; +import { + tokens, + makeStyles, +} from "@fluentui/react-components"; +import { + CHART_AXIS_TEXT_TOKEN, + CHART_GRID_LINE_TOKEN, + CHART_PALETTE, + resolveFluentToken, +} from "../utils/chartTheme"; +import { ChartStateWrapper } from "./ChartStateWrapper"; +import { useBanTrend } from "../hooks/useBanTrend"; +import type { BanOriginFilter, TimeRange } from "../types/ban"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Minimum chart height in pixels. */ +const MIN_CHART_HEIGHT = 220; + +/** Number of X-axis ticks to skip per time-range preset. */ +const TICK_INTERVAL: Record = { + "24h": 3, // show every 4th tick → ~6 visible + "7d": 3, // show every 4th tick → 7 visible + "30d": 4, // show every 5th tick → 6 visible + "365d": 7, // show every 8th tick → ~7 visible +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Props for {@link BanTrendChart}. */ +interface BanTrendChartProps { + /** Time-range preset controlling the query window. */ + timeRange: TimeRange; + /** Origin filter controlling which bans are included. */ + origin: BanOriginFilter; +} + +/** Internal chart data point shape. */ +interface TrendEntry { + /** ISO 8601 UTC timestamp — used by the tooltip. */ + timestamp: string; + /** Formatted string shown on the X-axis tick. */ + label: string; + /** Number of bans in this bucket. */ + count: number; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + chartWrapper: { + width: "100%", + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Format an ISO 8601 timestamp as a compact human-readable X-axis label. + * + * @param timestamp - ISO 8601 UTC string. + * @param range - Active time-range preset. + * @returns Label string, e.g. `"Mon 14:00"`, `"Mar 5"`. + */ +function formatXLabel(timestamp: string, range: TimeRange): string { + const d = new Date(timestamp); + if (range === "24h") { + const day = d.toLocaleDateString("en-US", { weekday: "short" }); + const time = d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + return `${day} ${time}`; + } + if (range === "7d") { + const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const time = d.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + return `${date} ${time}`; + } + // 30d / 365d + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +/** + * Build the chart data array from the raw bucket list. + * + * @param buckets - Ordered list of `{timestamp, count}` buckets. + * @param timeRange - Active preset, used to format axis labels. + * @returns Array of `TrendEntry` objects ready for Recharts. + */ +function buildEntries( + buckets: Array<{ timestamp: string; count: number }>, + timeRange: TimeRange, +): TrendEntry[] { + return buckets.map((b) => ({ + timestamp: b.timestamp, + label: formatXLabel(b.timestamp, timeRange), + count: b.count, + })); +} + +// --------------------------------------------------------------------------- +// Custom tooltip +// --------------------------------------------------------------------------- + +function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null { + const { active, payload } = props; + if (!active || payload.length === 0) return null; + const entry = payload[0]; + if (entry == null) return null; + + const { timestamp, count } = entry.payload as TrendEntry; + const d = new Date(timestamp); + const label = d.toLocaleString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + return ( +
+ {label} +
+ {String(count)} ban{count === 1 ? "" : "s"} +
+ ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Area chart showing ban counts over time. + * + * Fetches data via `useBanTrend` and handles loading, error, and empty states + * inline so the parent only needs to pass filter props. + * +/** + * Area chart showing ban counts over time. + * + * Fetches data via `useBanTrend` and delegates loading, error, and empty states + * to `ChartStateWrapper`. + * + * @param props - `timeRange` and `origin` filter props. + */ +export function BanTrendChart({ + timeRange, + origin, +}: BanTrendChartProps): React.JSX.Element { + const styles = useStyles(); + const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin); + + const isEmpty = buckets.every((b) => b.count === 0); + const entries = buildEntries(buckets, timeRange); + const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? ""); + const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN); + const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN); + const tickInterval = TICK_INTERVAL[timeRange]; + + return ( + +
+ + + + + + + + + + + + + + + +
+
+ ); +} + diff --git a/frontend/src/components/ChartStateWrapper.tsx b/frontend/src/components/ChartStateWrapper.tsx new file mode 100644 index 0000000..3c49ce3 --- /dev/null +++ b/frontend/src/components/ChartStateWrapper.tsx @@ -0,0 +1,131 @@ +/** + * ChartStateWrapper — shared wrapper that handles loading, error, and empty + * states for all dashboard chart components. + * + * Renders the chart `children` only when the data is ready and non-empty. + * Otherwise displays the appropriate state UI with consistent styling. + */ + +import { + Button, + MessageBar, + MessageBarActions, + MessageBarBody, + Spinner, + Text, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { ArrowClockwiseRegular } from "@fluentui/react-icons"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Props for {@link ChartStateWrapper}. */ +interface ChartStateWrapperProps { + /** True while data is loading. Shows a `` when active. */ + isLoading: boolean; + /** Error message string, or `null` when no error has occurred. */ + error: string | null; + /** + * Callback invoked when the user clicks the "Retry" button in error state. + * Must trigger a re-fetch in the parent component or hook. + */ + onRetry: () => void; + /** True when data loaded successfully but there are zero records to display. */ + isEmpty: boolean; + /** Human-readable message shown in the empty state (default provided). */ + emptyMessage?: string; + /** Minimum height in pixels for the state placeholder (default: 200). */ + minHeight?: number; + /** The chart content to render when data is ready. */ + children: React.ReactNode; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_MIN_HEIGHT = 200; +const DEFAULT_EMPTY_MESSAGE = "No bans in this time range."; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + centred: { + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "100%", + }, + emptyText: { + color: tokens.colorNeutralForeground3, + }, +}); + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Wrap a chart component to provide consistent loading, error, and empty states. + * + * Usage: + * ```tsx + * + * + * + * ``` + * + * @param props - Loading/error/empty state flags and the chart children. + */ +export function ChartStateWrapper({ + isLoading, + error, + onRetry, + isEmpty, + emptyMessage = DEFAULT_EMPTY_MESSAGE, + minHeight = DEFAULT_MIN_HEIGHT, + children, +}: ChartStateWrapperProps): React.JSX.Element { + const styles = useStyles(); + const placeholderStyle = { minHeight: `${String(minHeight)}px` }; + + if (error != null) { + return ( + + {error} + + + + + ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isEmpty) { + return ( +
+ {emptyMessage} +
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/components/DashboardFilterBar.tsx b/frontend/src/components/DashboardFilterBar.tsx new file mode 100644 index 0000000..8ab9398 --- /dev/null +++ b/frontend/src/components/DashboardFilterBar.tsx @@ -0,0 +1,163 @@ +/** + * DashboardFilterBar — global filter toolbar for the dashboard. + * + * Renders the time-range presets and origin filter as two labelled groups of + * toggle buttons. The component is fully controlled: it owns no state and + * communicates changes through the provided callbacks. + */ + +import { + Divider, + Text, + ToggleButton, + Toolbar, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import type { BanOriginFilter, TimeRange } from "../types/ban"; +import { + BAN_ORIGIN_FILTER_LABELS, + TIME_RANGE_LABELS, +} from "../types/ban"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Props for {@link DashboardFilterBar}. */ +export interface DashboardFilterBarProps { + /** Currently selected time-range preset. */ + timeRange: TimeRange; + /** Called when the user selects a different time-range preset. */ + onTimeRangeChange: (value: TimeRange) => void; + /** Currently selected origin filter. */ + originFilter: BanOriginFilter; + /** Called when the user selects a different origin filter. */ + onOriginFilterChange: (value: BanOriginFilter) => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Ordered time-range presets rendered as toggle buttons. */ +const TIME_RANGES: TimeRange[] = ["24h", "7d", "30d", "365d"]; + +/** Ordered origin filter options rendered as toggle buttons. */ +const ORIGIN_FILTERS: BanOriginFilter[] = ["all", "blocklist", "selfblock"]; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + container: { + display: "flex", + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: tokens.spacingVerticalS, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + borderTopWidth: "1px", + borderTopStyle: "solid", + borderTopColor: tokens.colorNeutralStroke2, + borderRightWidth: "1px", + borderRightStyle: "solid", + borderRightColor: tokens.colorNeutralStroke2, + borderBottomWidth: "1px", + borderBottomStyle: "solid", + borderBottomColor: tokens.colorNeutralStroke2, + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderLeftColor: tokens.colorNeutralStroke2, + paddingTop: tokens.spacingVerticalS, + paddingBottom: tokens.spacingVerticalS, + paddingLeft: tokens.spacingHorizontalM, + paddingRight: tokens.spacingHorizontalM, + }, + group: { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: tokens.spacingHorizontalM, + }, + divider: { + height: "24px", + paddingLeft: tokens.spacingHorizontalXL, + paddingRight: tokens.spacingHorizontalXL, + }, +}); + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Renders a global filter bar with time-range and origin-filter toggle buttons. + * + * The bar is fully controlled — it does not maintain its own state. + * + * @param props - See {@link DashboardFilterBarProps}. + */ +export function DashboardFilterBar({ + timeRange, + onTimeRangeChange, + originFilter, + onOriginFilterChange, +}: DashboardFilterBarProps): React.JSX.Element { + const styles = useStyles(); + + return ( +
+ {/* Time-range group */} +
+ + Time Range + + + {TIME_RANGES.map((r) => ( + { + onTimeRangeChange(r); + }} + > + {TIME_RANGE_LABELS[r]} + + ))} + +
+ + {/* Visual separator */} +
+ +
+ + {/* Origin-filter group */} +
+ + Filter + + + {ORIGIN_FILTERS.map((f) => ( + { + onOriginFilterChange(f); + }} + > + {BAN_ORIGIN_FILTER_LABELS[f]} + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/JailDistributionChart.tsx b/frontend/src/components/JailDistributionChart.tsx new file mode 100644 index 0000000..ad78a83 --- /dev/null +++ b/frontend/src/components/JailDistributionChart.tsx @@ -0,0 +1,189 @@ +/** + * JailDistributionChart — horizontal bar chart showing ban counts per jail, + * sorted descending, for the selected time window. + * + * Calls `useJailDistribution` internally and handles loading, error, and + * empty states so the parent only needs to pass filter props. + */ + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipContentProps } from "recharts/types/component/Tooltip"; +import { + tokens, + makeStyles, +} from "@fluentui/react-components"; +import { + CHART_AXIS_TEXT_TOKEN, + CHART_GRID_LINE_TOKEN, + CHART_PALETTE, + resolveFluentToken, +} from "../utils/chartTheme"; +import { ChartStateWrapper } from "./ChartStateWrapper"; +import { useJailDistribution } from "../hooks/useJailDistribution"; +import type { BanOriginFilter, TimeRange } from "../types/ban"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum characters before truncating a jail name on the Y-axis. */ +const MAX_LABEL_LENGTH = 24; + +/** Height per bar row in pixels. */ +const BAR_HEIGHT_PX = 36; + +/** Minimum chart height in pixels. */ +const MIN_CHART_HEIGHT = 180; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Props for {@link JailDistributionChart}. */ +interface JailDistributionChartProps { + /** Time-range preset controlling the query window. */ + timeRange: TimeRange; + /** Origin filter controlling which bans are included. */ + origin: BanOriginFilter; +} + +/** Internal chart data point shape. */ +interface BarEntry { + /** Full jail name used by Tooltip. */ + fullName: string; + /** Truncated name displayed on the Y-axis. */ + name: string; + /** Ban count. */ + value: number; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + wrapper: { + width: "100%", + overflowX: "hidden", + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build the chart dataset from the raw jail list. + * + * @param jails - Ordered list of `{jail, count}` items from the API. + * @returns Array of `BarEntry` objects ready for Recharts. + */ +function buildEntries(jails: Array<{ jail: string; count: number }>): BarEntry[] { + return jails.map(({ jail, count }) => ({ + fullName: jail, + name: jail.length > MAX_LABEL_LENGTH ? `${jail.slice(0, MAX_LABEL_LENGTH)}…` : jail, + value: count, + })); +} + +// --------------------------------------------------------------------------- +// Custom tooltip +// --------------------------------------------------------------------------- + +function JailTooltip(props: TooltipContentProps): React.JSX.Element | null { + const { active, payload } = props; + if (!active || payload.length === 0) return null; + const entry = payload[0]; + if (entry == null) return null; + + const { fullName, value } = entry.payload as BarEntry; + return ( +
+ {fullName} +
+ {String(value)} ban{value === 1 ? "" : "s"} +
+ ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Horizontal bar chart showing ban counts per jail for the selected window. + * + * Fetches data via `useJailDistribution` and renders loading, error, and + * empty states inline. + * + * @param props - `timeRange` and `origin` filter props. + */ +export function JailDistributionChart({ + timeRange, + origin, +}: JailDistributionChartProps): React.JSX.Element { + const styles = useStyles(); + const { jails, isLoading, error, reload } = useJailDistribution(timeRange, origin); + + const entries = buildEntries(jails); + const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); + const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? ""); + const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN); + const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN); + + return ( + +
+ + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/PageFeedback.tsx b/frontend/src/components/PageFeedback.tsx new file mode 100644 index 0000000..4c7152f --- /dev/null +++ b/frontend/src/components/PageFeedback.tsx @@ -0,0 +1,139 @@ +/** + * Reusable page-level feedback components. + * + * Three shared building blocks for consistent data-loading UI across all pages: + * + * - {@link PageLoading} — Centred `Spinner` for full-region loading states. + * - {@link PageError} — `MessageBar` with an error message and a retry button. + * - {@link PageEmpty} — Centred neutral message for zero-result states. + */ + +import { + Button, + MessageBar, + MessageBarActions, + MessageBarBody, + MessageBarTitle, + Spinner, + Text, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { ArrowClockwiseRegular } from "@fluentui/react-icons"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + centred: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + minHeight: "120px", + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalL, + }, + emptyText: { + color: tokens.colorNeutralForeground3, + textAlign: "center", + }, +}); + +// --------------------------------------------------------------------------- +// PageLoading +// --------------------------------------------------------------------------- + +export interface PageLoadingProps { + /** Short description shown next to the spinner. */ + label?: string; +} + +/** + * Full-region loading indicator using a Fluent UI `Spinner`. + * + * @example + * ```tsx + * if (loading) return ; + * ``` + */ +export function PageLoading({ label = "Loading…" }: PageLoadingProps): React.JSX.Element { + const styles = useStyles(); + return ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// PageError +// --------------------------------------------------------------------------- + +export interface PageErrorProps { + /** Error message shown in the `MessageBar`. */ + message: string; + /** Optional callback invoked when the user clicks "Retry". */ + onRetry?: () => void; +} + +/** + * Error state `MessageBar` with an optional retry button. + * + * @example + * ```tsx + * if (error) return ; + * ``` + */ +export function PageError({ message, onRetry }: PageErrorProps): React.JSX.Element { + return ( + + + Error + {message} + + {onRetry != null && ( + + + + )} + + ); +} + +// --------------------------------------------------------------------------- +// PageEmpty +// --------------------------------------------------------------------------- + +export interface PageEmptyProps { + /** Message displayed to the user, e.g. "No bans found." */ + message: string; +} + +/** + * Centred empty-state message for tables or lists with zero results. + * + * @example + * ```tsx + * if (items.length === 0) return ; + * ``` + */ +export function PageEmpty({ message }: PageEmptyProps): React.JSX.Element { + const styles = useStyles(); + return ( +
+ + {message} + +
+ ); +} diff --git a/frontend/src/components/RequireAuth.tsx b/frontend/src/components/RequireAuth.tsx new file mode 100644 index 0000000..d5427a8 --- /dev/null +++ b/frontend/src/components/RequireAuth.tsx @@ -0,0 +1,37 @@ +/** + * Route guard component. + * + * Wraps protected routes. If the user is not authenticated they are + * redirected to `/login` and the intended destination is preserved so the + * user lands on it after a successful login. + */ + +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "../providers/AuthProvider"; + +interface RequireAuthProps { + /** The protected page content to render when authenticated. */ + children: React.JSX.Element; +} + +/** + * Render `children` only if the user is authenticated. + * + * Redirects to `/login?next=` otherwise so the intended destination is + * preserved and honoured after a successful login. + */ +export function RequireAuth({ children }: RequireAuthProps): React.JSX.Element { + const { isAuthenticated } = useAuth(); + const location = useLocation(); + + if (!isAuthenticated) { + return ( + + ); + } + + return children; +} diff --git a/frontend/src/components/ServerStatusBar.tsx b/frontend/src/components/ServerStatusBar.tsx new file mode 100644 index 0000000..4953abf --- /dev/null +++ b/frontend/src/components/ServerStatusBar.tsx @@ -0,0 +1,179 @@ +/** + * `ServerStatusBar` component. + * + * Displays a persistent bar at the top of the dashboard showing the + * fail2ban server health snapshot: connectivity status, version, active + * jail count, and aggregated ban/failure totals. + * + * Polls `GET /api/dashboard/status` every 30 seconds and on window focus + * via the {@link useServerStatus} hook. + */ + +import { + Badge, + Button, + makeStyles, + Spinner, + Text, + tokens, + Tooltip, +} from "@fluentui/react-components"; +import { ArrowClockwiseRegular, ShieldRegular } from "@fluentui/react-icons"; +import { useServerStatus } from "../hooks/useServerStatus"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + bar: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalL, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`, + backgroundColor: tokens.colorNeutralBackground1, + borderRadius: tokens.borderRadiusMedium, + borderTopWidth: "1px", + borderTopStyle: "solid", + borderTopColor: tokens.colorNeutralStroke2, + borderRightWidth: "1px", + borderRightStyle: "solid", + borderRightColor: tokens.colorNeutralStroke2, + borderBottomWidth: "1px", + borderBottomStyle: "solid", + borderBottomColor: tokens.colorNeutralStroke2, + borderLeftWidth: "1px", + borderLeftStyle: "solid", + borderLeftColor: tokens.colorNeutralStroke2, + marginBottom: tokens.spacingVerticalL, + flexWrap: "wrap", + }, + statusGroup: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalS, + }, + statGroup: { + display: "flex", + alignItems: "center", + gap: tokens.spacingHorizontalXS, + }, + statValue: { + fontVariantNumeric: "tabular-nums", + fontWeight: 600, + }, + spacer: { + flexGrow: 1, + }, + errorText: { + color: tokens.colorPaletteRedForeground1, + fontSize: "12px", + }, +}); + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Persistent bar displaying fail2ban server health. + * + * Render this at the top of the dashboard page (and any page that should + * show live server status). + */ +export function ServerStatusBar(): React.JSX.Element { + const styles = useStyles(); + const { status, loading, error, refresh } = useServerStatus(); + + return ( +
+ {/* ---------------------------------------------------------------- */} + {/* Online / Offline badge */} + {/* ---------------------------------------------------------------- */} +
+ + {loading && !status ? ( + + ) : ( + + {status?.online ? "Online" : "Offline"} + + )} +
+ + {/* ---------------------------------------------------------------- */} + {/* Version */} + {/* ---------------------------------------------------------------- */} + {status?.version != null && ( + + + v{status.version} + + + )} + + {/* ---------------------------------------------------------------- */} + {/* Stats (only when online) */} + {/* ---------------------------------------------------------------- */} + {status?.online === true && ( + <> + +
+ Jails: + + {status.active_jails} + +
+
+ + +
+ Bans: + + {status.total_bans} + +
+
+ + +
+ Failures: + + {status.total_failures} + +
+
+ + )} + + {/* ---------------------------------------------------------------- */} + {/* Error message */} + {/* ---------------------------------------------------------------- */} + {error != null && ( + + {error} + + )} + +
+ + {/* ---------------------------------------------------------------- */} + {/* Refresh button */} + {/* ---------------------------------------------------------------- */} + +
+ ); +} diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx new file mode 100644 index 0000000..f448f9d --- /dev/null +++ b/frontend/src/components/SetupGuard.tsx @@ -0,0 +1,65 @@ +/** + * Route guard component. + * + * Protects all routes by ensuring the initial setup wizard has been + * completed. If setup is not done yet, the user is redirected to `/setup`. + * While the status is loading a full-screen spinner is shown. + */ + +import { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { Spinner } from "@fluentui/react-components"; +import { getSetupStatus } from "../api/setup"; + +type Status = "loading" | "done" | "pending"; + +interface SetupGuardProps { + /** The protected content to render when setup is complete. */ + children: React.JSX.Element; +} + +/** + * Render `children` only when setup has been completed. + * + * Redirects to `/setup` if setup is still pending. + */ +export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element { + const [status, setStatus] = useState("loading"); + + useEffect(() => { + let cancelled = false; + getSetupStatus() + .then((res): void => { + if (!cancelled) setStatus(res.completed ? "done" : "pending"); + }) + .catch((): void => { + // If the check fails, optimistically allow through — the backend will + // redirect API calls to /api/setup anyway. + if (!cancelled) setStatus("done"); + }); + return (): void => { + cancelled = true; + }; + }, []); + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (status === "pending") { + return ; + } + + return children; +} diff --git a/frontend/src/components/TopCountriesBarChart.tsx b/frontend/src/components/TopCountriesBarChart.tsx new file mode 100644 index 0000000..d7fb8b0 --- /dev/null +++ b/frontend/src/components/TopCountriesBarChart.tsx @@ -0,0 +1,197 @@ +/** + * TopCountriesBarChart — horizontal bar chart showing the top 20 countries + * by ban count, sorted descending. + */ + +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipContentProps } from "recharts/types/component/Tooltip"; +import { tokens, makeStyles, Text } from "@fluentui/react-components"; +import { + CHART_PALETTE, + CHART_AXIS_TEXT_TOKEN, + CHART_GRID_LINE_TOKEN, + resolveFluentToken, +} from "../utils/chartTheme"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TOP_N = 20; + +/** Maximum chars before truncating a country name on the Y-axis. */ +const MAX_LABEL_LENGTH = 20; + +/** Height per bar row in pixels. */ +const BAR_HEIGHT_PX = 36; + +/** Minimum chart height in pixels. */ +const MIN_CHART_HEIGHT = 180; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TopCountriesBarChartProps { + /** ISO alpha-2 country code → ban count. */ + countries: Record; + /** ISO alpha-2 country code → human-readable country name. */ + countryNames: Record; +} + +interface BarEntry { + /** Full country name used by Tooltip. */ + fullName: string; + /** Truncated name displayed on the Y-axis. */ + name: string; + /** Ban count. */ + value: number; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + wrapper: { + width: "100%", + overflowX: "hidden", + }, + emptyWrapper: { + width: "100%", + minHeight: `${String(MIN_CHART_HEIGHT)}px`, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + emptyText: { + color: tokens.colorNeutralForeground3, + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build the sorted top-N dataset from raw country maps. */ +function buildEntries( + countries: Record, + countryNames: Record, +): BarEntry[] { + return Object.entries(countries) + .sort(([, a], [, b]) => b - a) + .slice(0, TOP_N) + .map(([code, count]) => { + const full = countryNames[code] ?? code; + return { + fullName: full, + name: + full.length > MAX_LABEL_LENGTH + ? `${full.slice(0, MAX_LABEL_LENGTH)}…` + : full, + value: count, + }; + }); +} + +// --------------------------------------------------------------------------- +// Custom tooltip +// --------------------------------------------------------------------------- + +function BarTooltip( + props: TooltipContentProps, +): React.JSX.Element | null { + const { active, payload } = props; + if (!active || payload.length === 0) return null; + const entry = payload[0]; + if (entry == null) return null; + // `fullName` is stored as an extra field on the payload item. + const fullName = (entry.payload as BarEntry).fullName; + return ( +
+ {fullName} +
+ {String(entry.value)} ban{entry.value === 1 ? "" : "s"} +
+ ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Horizontal bar chart showing the top 20 countries by ban count. + * + * @param props - `countries` map and `countryNames` map from the + * `/api/dashboard/bans/by-country` response. + */ +export function TopCountriesBarChart({ + countries, + countryNames, +}: TopCountriesBarChartProps): React.JSX.Element { + const styles = useStyles(); + + const entries = buildEntries(countries, countryNames); + + if (entries.length === 0) { + return ( +
+ No bans in this time range. +
+ ); + } + + const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); + + const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? ""); + const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN); + const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN); + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/frontend/src/components/TopCountriesPieChart.tsx b/frontend/src/components/TopCountriesPieChart.tsx new file mode 100644 index 0000000..2820033 --- /dev/null +++ b/frontend/src/components/TopCountriesPieChart.tsx @@ -0,0 +1,201 @@ +/** + * TopCountriesPieChart — shows the top 4 countries by ban count plus + * an "Other" slice aggregating all remaining countries. + */ + +import { + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, +} from "recharts"; +import type { PieLabelRenderProps } from "recharts"; +import type { LegendPayload } from "recharts/types/component/DefaultLegendContent"; +import type { TooltipContentProps } from "recharts/types/component/Tooltip"; +import { tokens, makeStyles, Text } from "@fluentui/react-components"; +import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TOP_N = 4; +const OTHER_LABEL = "Other"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TopCountriesPieChartProps { + /** ISO alpha-2 country code → ban count. */ + countries: Record; + /** ISO alpha-2 country code → human-readable country name. */ + countryNames: Record; +} + +interface SliceData { + name: string; + value: number; + /** Resolved fill colour for this slice. */ + fill: string; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + wrapper: { + width: "100%", + minHeight: "280px", + }, + emptyWrapper: { + width: "100%", + minHeight: "280px", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + emptyText: { + color: tokens.colorNeutralForeground3, + }, +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build the 5-slice dataset from raw country maps, with resolved colours. */ +function buildSlices( + countries: Record, + countryNames: Record, + palette: readonly string[], +): SliceData[] { + const entries = Object.entries(countries).sort(([, a], [, b]) => b - a); + const top = entries.slice(0, TOP_N); + const rest = entries.slice(TOP_N); + + const slices: SliceData[] = top.map(([code, count], index) => ({ + name: countryNames[code] ?? code, + value: count, + fill: palette[index % palette.length] ?? "", + })); + + if (rest.length > 0) { + const otherTotal = rest.reduce((sum, [, c]) => sum + c, 0); + slices.push({ + name: OTHER_LABEL, + value: otherTotal, + fill: palette[slices.length % palette.length] ?? "", + }); + } + + return slices; +} + +// --------------------------------------------------------------------------- +// Custom tooltip +// --------------------------------------------------------------------------- + +function PieTooltip(props: TooltipContentProps): React.JSX.Element | null { + const { active, payload } = props; + if (!active || payload.length === 0) return null; + const entry = payload[0]; + if (entry == null) return null; + const banCount = entry.value; + const displayName: string = entry.name?.toString() ?? ""; + return ( +
+ {displayName} +
+ {banCount != null + ? `${String(banCount)} ban${banCount === 1 ? "" : "s"}` + : ""} +
+ ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Pie chart showing the top 4 countries by ban count plus an "Other" slice. + * + * @param props - `countries` map and `countryNames` map from the + * `/api/dashboard/bans/by-country` response. + */ +export function TopCountriesPieChart({ + countries, + countryNames, +}: TopCountriesPieChartProps): React.JSX.Element { + const styles = useStyles(); + + const resolvedPalette = CHART_PALETTE.map(resolveFluentToken); + const slices = buildSlices(countries, countryNames, resolvedPalette); + const total = slices.reduce((sum, s) => sum + s.value, 0); + + if (slices.length === 0) { + return ( +
+ No bans in this time range. +
+ ); + } + + /** Format legend entries as "Country Name (xx%)" and colour them to match their slice. */ + const legendFormatter = ( + value: string, + entry: LegendPayload, + ): React.ReactNode => { + const slice = slices.find((s) => s.name === value); + if (slice == null || total === 0) return value; + const pct = ((slice.value / total) * 100).toFixed(1); + return ( + + {value} ({pct}%) + + ); + }; + + return ( +
+ + + { + const name = labelProps.name ?? ""; + const percent = labelProps.percent ?? 0; + return `${name}: ${(percent * 100).toFixed(0)}%`; + }} + labelLine={false} + > + {slices.map((slice, index) => ( + // eslint-disable-next-line @typescript-eslint/no-deprecated + + ))} + + + + + +
+ ); +} diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx new file mode 100644 index 0000000..2293631 --- /dev/null +++ b/frontend/src/components/WorldMap.tsx @@ -0,0 +1,299 @@ +/** + * WorldMap — SVG world map showing per-country ban counts. + * + * Uses react-simple-maps with the Natural Earth 110m TopoJSON data from + * jsDelivr CDN. For each country that has bans in the selected time window, + * the total count is displayed inside the country's borders. Clicking a + * country filters the companion table. + */ + +import { useCallback, useState } from "react"; +import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; +import { Button, makeStyles, tokens } from "@fluentui/react-components"; +import type { GeoPermissibleObjects } from "d3-geo"; +import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; +import { getBanCountColor } from "../utils/mapColors"; + +// --------------------------------------------------------------------------- +// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only) +// --------------------------------------------------------------------------- + +const GEO_URL = + "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + mapWrapper: { + width: "100%", + position: "relative", + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + border: `1px solid ${tokens.colorNeutralStroke1}`, + overflow: "hidden", + }, + countLabel: { + fontSize: "9px", + fontWeight: "600", + fill: tokens.colorNeutralForeground1, + pointerEvents: "none", + userSelect: "none", + }, + zoomControls: { + position: "absolute", + top: tokens.spacingVerticalM, + right: tokens.spacingHorizontalM, + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalXS, + zIndex: 10, + }, +}); + +// --------------------------------------------------------------------------- +// GeoLayer — must be rendered inside ComposableMap to access map context +// --------------------------------------------------------------------------- + +interface GeoLayerProps { + countries: Record; + selectedCountry: string | null; + onSelectCountry: (cc: string | null) => void; + thresholdLow: number; + thresholdMedium: number; + thresholdHigh: number; +} + +function GeoLayer({ + countries, + selectedCountry, + onSelectCountry, + thresholdLow, + thresholdMedium, + thresholdHigh, +}: GeoLayerProps): React.JSX.Element { + const styles = useStyles(); + const { geographies, path } = useGeographies({ geography: GEO_URL }); + + const handleClick = useCallback( + (cc: string | null): void => { + onSelectCountry(selectedCountry === cc ? null : cc); + }, + [selectedCountry, onSelectCountry], + ); + + if (geographies.length === 0) return <>; + + // react-simple-maps types declare path as always defined, but it can be null + // during initial render before MapProvider context initializes. Cast to reflect + // the true runtime type and allow safe null checking. + const safePath = path as unknown as typeof path | null; + + return ( + <> + {(geographies as { rsmKey: string; id: string | number }[]).map( + (geo) => { + const numericId = String(geo.id); + const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; + const count: number = cc !== null ? (countries[cc] ?? 0) : 0; + const isSelected = cc !== null && selectedCountry === cc; + + // Compute the fill color based on ban count + const fillColor = getBanCountColor( + count, + thresholdLow, + thresholdMedium, + thresholdHigh, + ); + + // Only calculate centroid if path is available + let cx: number | undefined; + let cy: number | undefined; + if (safePath != null) { + const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects); + [cx, cy] = centroid; + } + + return ( + { + if (cc) handleClick(cc); + }} + onKeyDown={(e): void => { + if (cc && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(cc); + } + }} + > + 0 + ? tokens.colorNeutralBackground3 + : fillColor, + stroke: tokens.colorNeutralStroke1, + strokeWidth: 1, + outline: "none", + }, + pressed: { + fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, + stroke: tokens.colorBrandStroke1, + strokeWidth: 1, + outline: "none", + }, + }} + /> + {count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && ( + + {count} + + )} + + ); + }, + )} + + ); +} + +// --------------------------------------------------------------------------- +// WorldMap — public component +// --------------------------------------------------------------------------- + +export interface WorldMapProps { + /** ISO alpha-2 country code → ban count. */ + countries: Record; + /** Currently selected country filter (null means no filter). */ + selectedCountry: string | null; + /** Called when the user clicks a country or deselects. */ + onSelectCountry: (cc: string | null) => void; + /** Ban count threshold for green coloring (default: 20). */ + thresholdLow?: number; + /** Ban count threshold for yellow coloring (default: 50). */ + thresholdMedium?: number; + /** Ban count threshold for red coloring (default: 100). */ + thresholdHigh?: number; +} + +export function WorldMap({ + countries, + selectedCountry, + onSelectCountry, + thresholdLow = 20, + thresholdMedium = 50, + thresholdHigh = 100, +}: WorldMapProps): React.JSX.Element { + const styles = useStyles(); + const [zoom, setZoom] = useState(1); + const [center, setCenter] = useState<[number, number]>([0, 0]); + + const handleZoomIn = (): void => { + setZoom((z) => Math.min(z + 0.5, 8)); + }; + + const handleZoomOut = (): void => { + setZoom((z) => Math.max(z - 0.5, 1)); + }; + + const handleResetView = (): void => { + setZoom(1); + setCenter([0, 0]); + }; + + return ( +
+ {/* Zoom controls */} +
+ + + +
+ + + { + setZoom(newZoom); + setCenter(coordinates); + }} + minZoom={1} + maxZoom={8} + > + + + +
+ ); +} diff --git a/frontend/src/components/__tests__/BanTrendChart.test.tsx b/frontend/src/components/__tests__/BanTrendChart.test.tsx new file mode 100644 index 0000000..372a606 --- /dev/null +++ b/frontend/src/components/__tests__/BanTrendChart.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { BanTrendChart } from "../BanTrendChart"; +import * as useBanTrendModule from "../../hooks/useBanTrend"; +import type { UseBanTrendResult } from "../../hooks/useBanTrend"; +import type { BanTrendBucket } from "../../types/ban"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AreaChart: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Area: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + defs: () => null, + linearGradient: () => null, + stop: () => null, +})); + +vi.mock("../../hooks/useBanTrend"); + +function wrap(ui: React.ReactElement) { + return render( + {ui}, + ); +} + +const defaultResult: UseBanTrendResult = { + buckets: [], + bucketSize: "1h", + isLoading: false, + error: null, + reload: vi.fn(), +}; + +function mockHook(overrides: Partial) { + vi.mocked(useBanTrendModule.useBanTrend).mockReturnValue({ + ...defaultResult, + ...overrides, + }); +} + +beforeEach(() => { + vi.mocked(useBanTrendModule.useBanTrend).mockReturnValue(defaultResult); +}); + +describe("BanTrendChart", () => { + it("shows a spinner while loading", () => { + mockHook({ isLoading: true }); + wrap(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("shows error message and retry button on error", async () => { + const reload = vi.fn(); + mockHook({ error: "Failed to fetch trend", reload }); + const user = userEvent.setup(); + wrap(); + expect(screen.getByText("Failed to fetch trend")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /retry/i })); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("shows empty state when all buckets have zero count", () => { + const emptyBuckets: BanTrendBucket[] = [ + { timestamp: "2024-01-01T00:00:00Z", count: 0 }, + { timestamp: "2024-01-01T01:00:00Z", count: 0 }, + ]; + mockHook({ buckets: emptyBuckets }); + wrap(); + expect(screen.getByText("No bans in this time range.")).toBeInTheDocument(); + }); + + it("renders chart when data is present", () => { + const buckets: BanTrendBucket[] = [ + { timestamp: "2024-01-01T00:00:00Z", count: 5 }, + { timestamp: "2024-01-01T01:00:00Z", count: 12 }, + ]; + mockHook({ buckets }); + wrap(); + expect(screen.getByTestId("area-chart")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/ChartStateWrapper.test.tsx b/frontend/src/components/__tests__/ChartStateWrapper.test.tsx new file mode 100644 index 0000000..4c68c65 --- /dev/null +++ b/frontend/src/components/__tests__/ChartStateWrapper.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, 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 { ChartStateWrapper } from "../ChartStateWrapper"; + +function wrap(ui: React.ReactElement) { + return render( + {ui}, + ); +} + +describe("ChartStateWrapper", () => { + it("renders a spinner when isLoading is true", () => { + wrap( + +
chart
+
, + ); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + expect(screen.queryByText("chart")).not.toBeInTheDocument(); + }); + + it("renders an error bar with retry button when error is set", () => { + const onRetry = vi.fn(); + wrap( + +
chart
+
, + ); + expect(screen.getByText("Network error")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + expect(screen.queryByText("chart")).not.toBeInTheDocument(); + }); + + it("calls onRetry when Retry button is clicked", async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + wrap( + +
chart
+
, + ); + await user.click(screen.getByRole("button", { name: /retry/i })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("renders the empty message when isEmpty is true", () => { + wrap( + +
chart
+
, + ); + expect(screen.getByText("Nothing here.")).toBeInTheDocument(); + expect(screen.queryByText("chart")).not.toBeInTheDocument(); + }); + + it("renders children when not loading, no error, and not empty", () => { + wrap( + +
chart content
+
, + ); + expect(screen.getByText("chart content")).toBeInTheDocument(); + }); + + it("prioritises error state over loading state", () => { + wrap( + +
chart
+
, + ); + expect(screen.getByText("Some error")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("uses default empty message when none is provided", () => { + wrap( + +
chart
+
, + ); + expect( + screen.getByText("No bans in this time range."), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx new file mode 100644 index 0000000..99d95f2 --- /dev/null +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -0,0 +1,264 @@ +/** + * Tests for the "Add Log Path" form inside JailAccordionPanel (ConfigPage). + * + * Verifies that: + * - The add-log-path input and button are rendered inside the jail accordion. + * - The Add button is disabled when the input is empty. + * - Submitting a valid path calls `addLogPath` and appends the path to the list. + * - An API error is surfaced as an error message bar. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { ConfigPage } from "../../pages/ConfigPage"; +import type { JailConfig } from "../../types/config"; + +// --------------------------------------------------------------------------- +// Module mocks — use vi.hoisted so refs are available when vi.mock runs +// --------------------------------------------------------------------------- + +const { + mockAddLogPath, + mockDeleteLogPath, + mockUpdateJailConfig, + mockReloadConfig, + mockFetchGlobalConfig, + mockFetchServerSettings, + mockFetchJailConfigs, + mockFetchMapColorThresholds, + mockFetchJailConfigFiles, + mockFetchFilterFiles, + mockFetchActionFiles, + mockUpdateMapColorThresholds, + mockUpdateGlobalConfig, + mockUpdateServerSettings, + mockFlushLogs, + mockSetJailConfigFileEnabled, + mockUpdateJailConfigFile, +} = vi.hoisted(() => ({ + mockAddLogPath: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockDeleteLogPath: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockUpdateJailConfig: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockReloadConfig: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockFetchGlobalConfig: vi.fn().mockResolvedValue({ + config: { + ban_time: 600, + max_retry: 5, + find_time: 300, + backend: "auto", + }, + }), + mockFetchServerSettings: vi.fn().mockResolvedValue({ + settings: { + log_level: "INFO", + log_target: "STDOUT", + syslog_socket: null, + db_path: "/var/lib/fail2ban/fail2ban.sqlite3", + db_purge_age: 86400, + db_max_matches: 10, + }, + }), + mockFetchJailConfigs: vi.fn(), + mockFetchMapColorThresholds: vi.fn().mockResolvedValue({ + threshold_high: 100, + threshold_medium: 50, + threshold_low: 20, + }), + mockFetchJailConfigFiles: vi.fn().mockResolvedValue({ files: [] }), + mockFetchFilterFiles: vi.fn().mockResolvedValue({ files: [] }), + mockFetchActionFiles: vi.fn().mockResolvedValue({ files: [] }), + mockUpdateMapColorThresholds: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockUpdateGlobalConfig: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockUpdateServerSettings: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockFlushLogs: vi.fn().mockResolvedValue({ message: "ok" }), + mockSetJailConfigFileEnabled: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockUpdateJailConfigFile: vi.fn<() => Promise>().mockResolvedValue(undefined), +})); + +vi.mock("../../api/config", () => ({ + addLogPath: mockAddLogPath, + deleteLogPath: mockDeleteLogPath, + fetchJailConfigs: mockFetchJailConfigs, + fetchJailConfig: vi.fn(), + updateJailConfig: mockUpdateJailConfig, + reloadConfig: mockReloadConfig, + fetchGlobalConfig: mockFetchGlobalConfig, + updateGlobalConfig: mockUpdateGlobalConfig, + fetchServerSettings: mockFetchServerSettings, + updateServerSettings: mockUpdateServerSettings, + flushLogs: mockFlushLogs, + fetchMapColorThresholds: mockFetchMapColorThresholds, + updateMapColorThresholds: mockUpdateMapColorThresholds, + fetchJailConfigFiles: mockFetchJailConfigFiles, + fetchJailConfigFileContent: vi.fn(), + updateJailConfigFile: mockUpdateJailConfigFile, + setJailConfigFileEnabled: mockSetJailConfigFileEnabled, + fetchFilterFiles: mockFetchFilterFiles, + fetchFilterFile: vi.fn(), + updateFilterFile: vi.fn(), + createFilterFile: vi.fn(), + fetchFilters: vi.fn().mockResolvedValue({ filters: [], total: 0 }), + fetchFilter: vi.fn(), + fetchActionFiles: mockFetchActionFiles, + fetchActionFile: vi.fn(), + updateActionFile: vi.fn(), + createActionFile: vi.fn(), + previewLog: vi.fn(), + testRegex: vi.fn(), + fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), + activateJail: vi.fn(), + deactivateJail: vi.fn(), + fetchParsedFilter: vi.fn(), + updateParsedFilter: vi.fn(), + fetchParsedAction: vi.fn(), + updateParsedAction: vi.fn(), + fetchParsedJailFile: vi.fn(), + updateParsedJailFile: vi.fn(), +})); + +vi.mock("../../api/jails", () => ({ + fetchJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), +})); + +/** Minimal jail fixture used across tests. */ +const MOCK_JAIL: JailConfig = { + name: "sshd", + ban_time: 600, + max_retry: 3, + find_time: 300, + fail_regex: [], + ignore_regex: [], + log_paths: ["/var/log/auth.log"], + date_pattern: null, + log_encoding: "UTF-8", + backend: "auto", + use_dns: "warn", + prefregex: "", + actions: [], + bantime_escalation: null, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderConfigPage() { + return render( + + + , + ); +} + +/** Waits for the sshd list item to appear and clicks it to open the detail pane. */ +async function openSshdAccordion(user: ReturnType) { + const listItem = await screen.findByRole("option", { name: /sshd/i }); + await user.click(listItem); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ConfigPage — Add Log Path", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchJailConfigs.mockResolvedValue({ jails: [MOCK_JAIL], total: 1 }); + mockAddLogPath.mockResolvedValue(undefined); + }); + + it("renders the existing log path and the add-log-path input inside the accordion", async () => { + const user = userEvent.setup(); + renderConfigPage(); + await openSshdAccordion(user); + + // Existing path from fixture — rendered as an value + expect(screen.getByDisplayValue("/var/log/auth.log")).toBeInTheDocument(); + + // Add-log-path input placeholder + expect( + screen.getByPlaceholderText("/var/log/example.log"), + ).toBeInTheDocument(); + }); + + it("disables the Add button when the path input is empty", async () => { + const user = userEvent.setup(); + renderConfigPage(); + await openSshdAccordion(user); + + const addBtn = screen.getByRole("button", { name: /add log path/i }); + expect(addBtn).toBeDisabled(); + }); + + it("enables the Add button when the path input has content", async () => { + const user = userEvent.setup(); + renderConfigPage(); + await openSshdAccordion(user); + + const input = screen.getByPlaceholderText("/var/log/example.log"); + await user.type(input, "/var/log/nginx/access.log"); + + const addBtn = screen.getByRole("button", { name: /add log path/i }); + expect(addBtn).not.toBeDisabled(); + }); + + it("calls addLogPath and appends the path on successful submission", async () => { + const user = userEvent.setup(); + renderConfigPage(); + await openSshdAccordion(user); + + const input = screen.getByPlaceholderText("/var/log/example.log"); + await user.type(input, "/var/log/nginx/access.log"); + + const addBtn = screen.getByRole("button", { name: /add log path/i }); + await user.click(addBtn); + + await waitFor(() => { + expect(mockAddLogPath).toHaveBeenCalledWith("sshd", { + log_path: "/var/log/nginx/access.log", + tail: true, + }); + }); + + // New path should appear in the list as an value + expect(screen.getByDisplayValue("/var/log/nginx/access.log")).toBeInTheDocument(); + + // Input should be cleared + expect(input).toHaveValue(""); + }); + + it("shows a success message after adding a log path", async () => { + const user = userEvent.setup(); + renderConfigPage(); + await openSshdAccordion(user); + + const input = screen.getByPlaceholderText("/var/log/example.log"); + await user.type(input, "/var/log/nginx/access.log"); + await user.click(screen.getByRole("button", { name: /add log path/i })); + + await waitFor(() => { + expect( + screen.getByText(/added log path.*\/var\/log\/nginx\/access\.log/i), + ).toBeInTheDocument(); + }); + }); + + it("shows an error message when addLogPath fails", async () => { + mockAddLogPath.mockRejectedValueOnce(new Error("Connection refused")); + const user = userEvent.setup(); + renderConfigPage(); + await openSshdAccordion(user); + + const input = screen.getByPlaceholderText("/var/log/example.log"); + await user.type(input, "/var/log/bad.log"); + await user.click(screen.getByRole("button", { name: /add log path/i })); + + await waitFor(() => { + expect( + screen.getByText("Failed to add log path."), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/__tests__/DashboardFilterBar.test.tsx b/frontend/src/components/__tests__/DashboardFilterBar.test.tsx new file mode 100644 index 0000000..512c342 --- /dev/null +++ b/frontend/src/components/__tests__/DashboardFilterBar.test.tsx @@ -0,0 +1,128 @@ +/** + * Tests for the DashboardFilterBar component. + * + * Covers rendering, active-state reflection, and callback invocation. + */ + +import { describe, it, expect, 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 { DashboardFilterBar } from "../DashboardFilterBar"; +import type { BanOriginFilter, TimeRange } from "../../types/ban"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface RenderProps { + timeRange?: TimeRange; + onTimeRangeChange?: (value: TimeRange) => void; + originFilter?: BanOriginFilter; + onOriginFilterChange?: (value: BanOriginFilter) => void; +} + +function renderBar({ + timeRange = "24h", + onTimeRangeChange = vi.fn<(value: TimeRange) => void>(), + originFilter = "all", + onOriginFilterChange = vi.fn<(value: BanOriginFilter) => void>(), +}: RenderProps = {}): { + onTimeRangeChange: (value: TimeRange) => void; + onOriginFilterChange: (value: BanOriginFilter) => void; +} { + render( + + + , + ); + return { onTimeRangeChange, onOriginFilterChange }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("DashboardFilterBar", () => { + // ------------------------------------------------------------------------- + // 1. Renders all time-range buttons + // ------------------------------------------------------------------------- + it("renders all four time-range buttons", () => { + renderBar(); + expect(screen.getByRole("button", { name: /last 24 h/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /last 7 days/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /last 30 days/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /last 365 days/i })).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // 2. Renders all origin-filter buttons + // ------------------------------------------------------------------------- + it("renders all three origin-filter buttons", () => { + renderBar(); + expect(screen.getByRole("button", { name: /^all$/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /blocklist/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /selfblock/i })).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // 3. Active state matches props + // ------------------------------------------------------------------------- + it("marks the correct time-range button as active", () => { + renderBar({ timeRange: "7d" }); + expect( + screen.getByRole("button", { name: /last 7 days/i }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("button", { name: /last 24 h/i }), + ).toHaveAttribute("aria-pressed", "false"); + }); + + it("marks the correct origin-filter button as active", () => { + renderBar({ originFilter: "blocklist" }); + expect( + screen.getByRole("button", { name: /blocklist/i }), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByRole("button", { name: /^all$/i }), + ).toHaveAttribute("aria-pressed", "false"); + }); + + // ------------------------------------------------------------------------- + // 4. Time-range click fires callback + // ------------------------------------------------------------------------- + it("calls onTimeRangeChange with the correct value when a time-range button is clicked", async () => { + const user = userEvent.setup(); + const { onTimeRangeChange } = renderBar({ timeRange: "24h" }); + await user.click(screen.getByRole("button", { name: /last 30 days/i })); + expect(onTimeRangeChange).toHaveBeenCalledOnce(); + expect(onTimeRangeChange).toHaveBeenCalledWith("30d"); + }); + + // ------------------------------------------------------------------------- + // 5. Origin-filter click fires callback + // ------------------------------------------------------------------------- + it("calls onOriginFilterChange with the correct value when an origin button is clicked", async () => { + const user = userEvent.setup(); + const { onOriginFilterChange } = renderBar({ originFilter: "all" }); + await user.click(screen.getByRole("button", { name: /selfblock/i })); + expect(onOriginFilterChange).toHaveBeenCalledOnce(); + expect(onOriginFilterChange).toHaveBeenCalledWith("selfblock"); + }); + + // ------------------------------------------------------------------------- + // 6. Already-active button click still fires callback + // ------------------------------------------------------------------------- + it("calls onTimeRangeChange even when the active button is clicked again", async () => { + const user = userEvent.setup(); + const { onTimeRangeChange } = renderBar({ timeRange: "24h" }); + await user.click(screen.getByRole("button", { name: /last 24 h/i })); + expect(onTimeRangeChange).toHaveBeenCalledOnce(); + expect(onTimeRangeChange).toHaveBeenCalledWith("24h"); + }); +}); diff --git a/frontend/src/components/__tests__/JailDistributionChart.test.tsx b/frontend/src/components/__tests__/JailDistributionChart.test.tsx new file mode 100644 index 0000000..6e84612 --- /dev/null +++ b/frontend/src/components/__tests__/JailDistributionChart.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { JailDistributionChart } from "../JailDistributionChart"; +import * as useJailDistributionModule from "../../hooks/useJailDistribution"; +import type { UseJailDistributionResult } from "../../hooks/useJailDistribution"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + BarChart: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Bar: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + Cell: () => null, +})); + +vi.mock("../../hooks/useJailDistribution"); + +function wrap(ui: React.ReactElement) { + return render( + {ui}, + ); +} + +const defaultResult: UseJailDistributionResult = { + jails: [], + total: 0, + isLoading: false, + error: null, + reload: vi.fn(), +}; + +function mockHook(overrides: Partial) { + vi.mocked(useJailDistributionModule.useJailDistribution).mockReturnValue({ + ...defaultResult, + ...overrides, + }); +} + +beforeEach(() => { + vi.mocked(useJailDistributionModule.useJailDistribution).mockReturnValue( + defaultResult, + ); +}); + +describe("JailDistributionChart", () => { + it("shows a spinner while loading", () => { + mockHook({ isLoading: true }); + wrap(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("shows error message and retry button on error", async () => { + const reload = vi.fn(); + mockHook({ error: "Request failed", reload }); + const user = userEvent.setup(); + wrap(); + expect(screen.getByText("Request failed")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /retry/i })); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("shows empty state when jails array is empty", () => { + mockHook({ jails: [], total: 0 }); + wrap(); + expect(screen.getByText(/no bans/i)).toBeInTheDocument(); + }); + + it("renders chart when jail data is present", () => { + mockHook({ + jails: [ + { jail: "sshd", count: 42 }, + { jail: "nginx-http-auth", count: 7 }, + ], + total: 49, + }); + wrap(); + expect(screen.getByTestId("bar-chart")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/TopCountriesBarChart.test.tsx b/frontend/src/components/__tests__/TopCountriesBarChart.test.tsx new file mode 100644 index 0000000..f6a1d2c --- /dev/null +++ b/frontend/src/components/__tests__/TopCountriesBarChart.test.tsx @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { TopCountriesBarChart } from "../TopCountriesBarChart"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + BarChart: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Bar: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + Cell: () => null, +})); + +function wrap(ui: React.ReactElement) { + return render( + {ui}, + ); +} + +describe("TopCountriesBarChart", () => { + it("shows empty state when countries is empty", () => { + wrap(); + expect(screen.getByText("No bans in this time range.")).toBeInTheDocument(); + expect(screen.queryByTestId("bar-chart")).not.toBeInTheDocument(); + }); + + it("renders bar chart when country data is provided", () => { + wrap( + , + ); + expect(screen.getByTestId("bar-chart")).toBeInTheDocument(); + }); + + it("does not render more than 20 bars (TOP_N limit)", () => { + // Build 30 countries — only top 20 should appear in the chart + const countries: Record = {}; + const countryNames: Record = {}; + for (let i = 0; i < 30; i++) { + const code = `C${String(i).padStart(2, "0")}`; + countries[code] = 30 - i; + countryNames[code] = `Country ${String(i)}`; + } + wrap(); + // Chart should render (not show empty state) with data present + expect(screen.getByTestId("bar-chart")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/TopCountriesPieChart.test.tsx b/frontend/src/components/__tests__/TopCountriesPieChart.test.tsx new file mode 100644 index 0000000..493ae7a --- /dev/null +++ b/frontend/src/components/__tests__/TopCountriesPieChart.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { TopCountriesPieChart } from "../TopCountriesPieChart"; + +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + PieChart: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Pie: () => null, + Cell: () => null, + Tooltip: () => null, + Legend: () => null, +})); + +function wrap(ui: React.ReactElement) { + return render( + {ui}, + ); +} + +describe("TopCountriesPieChart", () => { + it("shows empty state when countries is empty", () => { + wrap(); + expect(screen.getByText("No bans in this time range.")).toBeInTheDocument(); + expect(screen.queryByTestId("pie-chart")).not.toBeInTheDocument(); + }); + + it("renders pie chart when country data is provided", () => { + wrap( + , + ); + expect(screen.getByTestId("pie-chart")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/common/RecoveryBanner.tsx b/frontend/src/components/common/RecoveryBanner.tsx new file mode 100644 index 0000000..032f07f --- /dev/null +++ b/frontend/src/components/common/RecoveryBanner.tsx @@ -0,0 +1,136 @@ +/** + * RecoveryBanner — full-width warning shown when fail2ban stopped responding + * shortly after a jail was activated (indicating the new jail config may be + * invalid). + * + * Polls ``GET /api/config/pending-recovery`` every 10 seconds and renders a + * dismissible ``MessageBar`` when an unresolved crash record is present. + * The "Disable & Restart" button calls the rollback endpoint to disable the + * offending jail and attempt to restart fail2ban. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Button, + MessageBar, + MessageBarActions, + MessageBarBody, + MessageBarTitle, + Spinner, + tokens, +} from "@fluentui/react-components"; +import { useNavigate } from "react-router-dom"; +import { fetchPendingRecovery, rollbackJail } from "../../api/config"; +import type { PendingRecovery } from "../../types/config"; + +const POLL_INTERVAL_MS = 10_000; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Recovery banner that polls for pending crash-recovery records. + * + * Mount this once at the layout level so it is visible across all pages + * while a recovery is pending. + * + * @returns A MessageBar element, or null when nothing is pending. + */ +export function RecoveryBanner(): React.JSX.Element | null { + const navigate = useNavigate(); + const [pending, setPending] = useState(null); + const [rolling, setRolling] = useState(false); + const [rollbackError, setRollbackError] = useState(null); + const timerRef = useRef | null>(null); + + const poll = useCallback((): void => { + fetchPendingRecovery() + .then((record) => { + // Hide the banner once fail2ban has recovered on its own. + if (record?.recovered) { + setPending(null); + } else { + setPending(record); + } + }) + .catch(() => { /* ignore network errors — will retry */ }); + }, []); + + // Start polling on mount. + useEffect(() => { + poll(); + timerRef.current = setInterval(poll, POLL_INTERVAL_MS); + return (): void => { + if (timerRef.current !== null) clearInterval(timerRef.current); + }; + }, [poll]); + + const handleRollback = useCallback((): void => { + if (!pending || rolling) return; + setRolling(true); + setRollbackError(null); + rollbackJail(pending.jail_name) + .then(() => { + setPending(null); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + setRollbackError(msg); + }) + .finally(() => { + setRolling(false); + }); + }, [pending, rolling]); + + const handleViewDetails = useCallback((): void => { + navigate("/config"); + }, [navigate]); + + if (pending === null) return null; + + return ( +
+ + + fail2ban Stopped After Jail Activation + fail2ban stopped responding after activating jail{" "} + {pending.jail_name}. The jail's configuration + may be invalid. + {rollbackError && ( +
+ Rollback failed: {rollbackError} +
+ )} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/common/__tests__/RecoveryBanner.test.tsx b/frontend/src/components/common/__tests__/RecoveryBanner.test.tsx new file mode 100644 index 0000000..2ac52f3 --- /dev/null +++ b/frontend/src/components/common/__tests__/RecoveryBanner.test.tsx @@ -0,0 +1,141 @@ +/** + * Tests for RecoveryBanner (Task 3). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { MemoryRouter } from "react-router-dom"; +import { RecoveryBanner } from "../RecoveryBanner"; +import type { PendingRecovery } from "../../../types/config"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("../../../api/config", () => ({ + fetchPendingRecovery: vi.fn(), + rollbackJail: vi.fn(), +})); + +import { fetchPendingRecovery, rollbackJail } from "../../../api/config"; + +const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery); +const mockRollbackJail = vi.mocked(rollbackJail); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const pendingRecord: PendingRecovery = { + jail_name: "sshd", + activated_at: "2024-01-01T12:00:00Z", + detected_at: "2024-01-01T12:00:30Z", + recovered: false, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderBanner() { + return render( + + + + + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("RecoveryBanner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders nothing when pending recovery is null", async () => { + mockFetchPendingRecovery.mockResolvedValue(null); + + renderBanner(); + + await waitFor(() => { + expect(mockFetchPendingRecovery).toHaveBeenCalled(); + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("renders warning when there is an unresolved pending recovery", async () => { + mockFetchPendingRecovery.mockResolvedValue(pendingRecord); + + renderBanner(); + + await waitFor(() => { + expect(screen.getByText(/fail2ban stopped responding after activating jail/i)).toBeInTheDocument(); + }); + + expect(screen.getByText(/sshd/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /view logs/i })).toBeInTheDocument(); + }); + + it("hides the banner when recovery is marked as recovered", async () => { + const recoveredRecord: PendingRecovery = { ...pendingRecord, recovered: true }; + mockFetchPendingRecovery.mockResolvedValue(recoveredRecord); + + renderBanner(); + + await waitFor(() => { + expect(mockFetchPendingRecovery).toHaveBeenCalled(); + }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("calls rollbackJail and hides banner on successful rollback", async () => { + mockFetchPendingRecovery.mockResolvedValue(pendingRecord); + mockRollbackJail.mockResolvedValue({ + jail_name: "sshd", + disabled: true, + fail2ban_running: true, + active_jails: 0, + message: "Rolled back.", + }); + + renderBanner(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument(); + }); + + await userEvent.click( + screen.getByRole("button", { name: /disable & restart/i }), + ); + + expect(mockRollbackJail).toHaveBeenCalledWith("sshd"); + }); + + it("shows rollback error when rollbackJail fails", async () => { + mockFetchPendingRecovery.mockResolvedValue(pendingRecord); + mockRollbackJail.mockRejectedValue(new Error("Connection refused")); + + renderBanner(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument(); + }); + + await userEvent.click( + screen.getByRole("button", { name: /disable & restart/i }), + ); + + await waitFor(() => { + expect(screen.getByText(/rollback failed/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/config/ActionForm.tsx b/frontend/src/components/config/ActionForm.tsx new file mode 100644 index 0000000..8903744 --- /dev/null +++ b/frontend/src/components/config/ActionForm.tsx @@ -0,0 +1,328 @@ +/** + * ActionForm — structured form editor for a single ``action.d/*.conf`` file. + * + * Displays parsed fields grouped into collapsible sections: + * - Includes (before / after) + * - Lifecycle commands (actionstart, actionstop, actioncheck, actionban, + * actionunban, actionflush) + * - Definition variables (extra [Definition] key-value pairs) + * - Init variables ([Init] section key-value pairs) + * + * Provides a Save button and shows saving/error state. + */ + +import { useEffect, useMemo, useState } from "react"; +import { + Accordion, + AccordionHeader, + AccordionItem, + AccordionPanel, + Button, + Field, + Input, + MessageBar, + MessageBarBody, + Skeleton, + SkeletonItem, + Text, + Textarea, +} from "@fluentui/react-components"; +import { Add24Regular, Delete24Regular } from "@fluentui/react-icons"; +import type { ActionConfig, ActionConfigUpdate } from "../../types/config"; +import { useActionConfig } from "../../hooks/useActionConfig"; +import { useAutoSave } from "../../hooks/useAutoSave"; +import { AutoSaveIndicator } from "./AutoSaveIndicator"; +import { useConfigStyles } from "./configStyles"; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Editable key-value table for definition_vars / init_vars. */ +interface KVEditorProps { + entries: Record; + onChange: (next: Record) => void; +} + +function KVEditor({ entries, onChange }: KVEditorProps): React.JSX.Element { + const styles = useConfigStyles(); + const rows = Object.entries(entries); + + const handleKeyChange = (oldKey: string, newKey: string): void => { + const next: Record = {}; + for (const [k, v] of Object.entries(entries)) { + next[k === oldKey ? newKey : k] = v; + } + onChange(next); + }; + + const handleValueChange = (key: string, value: string): void => { + onChange({ ...entries, [key]: value }); + }; + + const handleDelete = (key: string): void => { + const { [key]: _removed, ...rest } = entries; + onChange(rest); + }; + + const handleAdd = (): void => { + let newKey = "new_var"; + let n = 1; + while (newKey in entries) { + newKey = `new_var_${String(n)}`; + n++; + } + onChange({ ...entries, [newKey]: "" }); + }; + + return ( +
+ {rows.map(([key, value]) => ( +
+ { handleKeyChange(key, d.value); }} + /> +