diff --git a/Docker/Dockerfile.backend b/Docker/Dockerfile.backend index e097e67..849e552 100644 --- a/Docker/Dockerfile.backend +++ b/Docker/Dockerfile.backend @@ -10,7 +10,7 @@ # ────────────────────────────────────────────────────────────── # ── Stage 1: build dependencies ────────────────────────────── -FROM python:3.12-slim AS builder +FROM docker.io/library/python:3.12-slim AS builder WORKDIR /build @@ -28,7 +28,7 @@ RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir . # ── Stage 2: runtime image ─────────────────────────────────── -FROM python:3.12-slim AS runtime +FROM docker.io/library/python:3.12-slim AS runtime LABEL maintainer="BanGUI" \ description="BanGUI backend — fail2ban web management API" diff --git a/Docker/Dockerfile.frontend b/Docker/Dockerfile.frontend index 0f2658c..bb24ecf 100644 --- a/Docker/Dockerfile.frontend +++ b/Docker/Dockerfile.frontend @@ -10,7 +10,7 @@ # ────────────────────────────────────────────────────────────── # ── Stage 1: install & build ───────────────────────────────── -FROM node:22-alpine AS builder +FROM docker.io/library/node:22-alpine AS builder WORKDIR /build @@ -23,7 +23,7 @@ COPY frontend/ /build/ RUN npm run build # ── Stage 2: serve with nginx ──────────────────────────────── -FROM nginx:1.27-alpine AS runtime +FROM docker.io/library/nginx:1.27-alpine AS runtime LABEL maintainer="BanGUI" \ description="BanGUI frontend — fail2ban web management UI" diff --git a/Docker/VERSION b/Docker/VERSION new file mode 100644 index 0000000..188bef5 --- /dev/null +++ b/Docker/VERSION @@ -0,0 +1 @@ +v0.9.3 diff --git a/Docker/check_ban_status.sh b/Docker/check_ban_status.sh index 74a10f1..8e036e6 100644 --- a/Docker/check_ban_status.sh +++ b/Docker/check_ban_status.sh @@ -2,7 +2,7 @@ # ────────────────────────────────────────────────────────────── # check_ban_status.sh # -# Queries the bangui-sim jail inside the running fail2ban +# Queries the manual-Jail jail inside the running fail2ban # container and optionally unbans a specific IP. # # Usage: @@ -17,7 +17,7 @@ set -euo pipefail readonly CONTAINER="bangui-fail2ban-dev" -readonly JAIL="bangui-sim" +readonly JAIL="manual-Jail" # ── Helper: run a fail2ban-client command inside the container ─ f2b() { diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml new file mode 100644 index 0000000..70ced48 --- /dev/null +++ b/Docker/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' +services: + fail2ban: + image: lscr.io/linuxserver/fail2ban:latest + container_name: fail2ban + cap_add: + - NET_ADMIN + - NET_RAW + network_mode: host + environment: + - PUID=1011 + - PGID=1001 + - TZ=Etc/UTC + - VERBOSITY=-vv #optional + + volumes: + - /server/server_fail2ban/config:/config + - /server/server_fail2ban/fail2ban-run:/var/run/fail2ban + - /var/log:/var/log + - /server/server_nextcloud/config/nextcloud.log:/remotelogs/nextcloud/nextcloud.log:ro #optional + - /server/server_nginx/data/logs:/remotelogs/nginx:ro #optional + - /server/server_gitea/log/gitea.log:/remotelogs/gitea/gitea.log:ro #optional + + + #- /path/to/homeassistant/log:/remotelogs/homeassistant:ro #optional + #- /path/to/unificontroller/log:/remotelogs/unificontroller:ro #optional + #- /path/to/vaultwarden/log:/remotelogs/vaultwarden:ro #optional + restart: unless-stopped + + backend: + image: git.lpl-mind.de/lukas.pupkalipinski/bangui/backend:latest + container_name: bangui-backend + restart: unless-stopped + depends_on: + fail2ban: + condition: service_started + environment: + - PUID=1011 + - PGID=1001 + - BANGUI_DATABASE_PATH=/data/bangui.db + - BANGUI_FAIL2BAN_SOCKET=/var/run/fail2ban/fail2ban.sock + - BANGUI_FAIL2BAN_CONFIG_DIR=/config/fail2ban + - BANGUI_LOG_LEVEL=info + - BANGUI_SESSION_SECRET=${BANGUI_SESSION_SECRET:?Set BANGUI_SESSION_SECRET} + - BANGUI_TIMEZONE=${BANGUI_TIMEZONE:-UTC} + volumes: + - /server/server_fail2ban/bangui-data:/data + - /server/server_fail2ban/fail2ban-run:/var/run/fail2ban:ro + - /server/server_fail2ban/config:/config:rw + expose: + - "8000" + networks: + - bangui-net + + # ── Frontend (nginx serving built SPA + API proxy) ────────── + frontend: + image: git.lpl-mind.de/lukas.pupkalipinski/bangui/frontend:latest + container_name: bangui-frontend + restart: unless-stopped + environment: + - PUID=1011 + - PGID=1001 + ports: + - "${BANGUI_PORT:-8080}:80" + depends_on: + backend: + condition: service_started + networks: + - bangui-net + +networks: + bangui-net: + name: bangui-net \ No newline at end of file diff --git a/Docker/fail2ban-dev-config/README.md b/Docker/fail2ban-dev-config/README.md index 8d41b71..6ecaf56 100644 --- a/Docker/fail2ban-dev-config/README.md +++ b/Docker/fail2ban-dev-config/README.md @@ -2,7 +2,7 @@ This directory contains the fail2ban configuration and supporting scripts for a self-contained development test environment. A simulation script writes fake -authentication-failure log lines, fail2ban detects them via the `bangui-sim` +authentication-failure log lines, fail2ban detects them via the `manual-Jail` jail, and bans the offending IP — giving a fully reproducible ban/unban cycle without a real service. @@ -71,14 +71,14 @@ Chains steps 1–3 automatically with appropriate sleep intervals. | File | Purpose | |------|---------| -| `fail2ban/filter.d/bangui-sim.conf` | Defines the `failregex` that matches simulation log lines | -| `fail2ban/jail.d/bangui-sim.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` | +| `fail2ban/filter.d/manual-Jail.conf` | Defines the `failregex` that matches simulation log lines | +| `fail2ban/jail.d/manual-Jail.conf` | Jail settings: `maxretry=3`, `bantime=60s`, `findtime=120s` | | `Docker/logs/auth.log` | Log file written by the simulation script (host path) | Inside the container the log file is mounted at `/remotelogs/bangui/auth.log` (see `fail2ban/paths-lsio.conf` — `remote_logs_path = /remotelogs`). -To change sensitivity, edit `fail2ban/jail.d/bangui-sim.conf`: +To change sensitivity, edit `fail2ban/jail.d/manual-Jail.conf`: ```ini maxretry = 3 # failures before a ban @@ -108,14 +108,14 @@ Test the regex manually: ```bash docker exec bangui-fail2ban-dev \ - fail2ban-regex /remotelogs/bangui/auth.log bangui-sim + fail2ban-regex /remotelogs/bangui/auth.log manual-Jail ``` The output should show matched lines. If nothing matches, check that the log lines match the corresponding `failregex` pattern: ``` -# bangui-sim (auth log): +# manual-Jail (auth log): YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from ``` @@ -132,7 +132,7 @@ sudo modprobe ip_tables ### IP not banned despite enough failures Check whether the source IP falls inside the `ignoreip` range defined in -`fail2ban/jail.d/bangui-sim.conf`: +`fail2ban/jail.d/manual-Jail.conf`: ```ini ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 diff --git a/Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf b/Docker/fail2ban-dev-config/fail2ban/filter.d/manual-Jail.conf similarity index 96% rename from Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf rename to Docker/fail2ban-dev-config/fail2ban/filter.d/manual-Jail.conf index 275b83f..48019ec 100644 --- a/Docker/fail2ban-dev-config/fail2ban/filter.d/bangui-sim.conf +++ b/Docker/fail2ban-dev-config/fail2ban/filter.d/manual-Jail.conf @@ -3,6 +3,7 @@ # # Matches lines written by Docker/simulate_failed_logins.sh # Format: bangui-auth: authentication failure from +# Jail: manual-Jail # ────────────────────────────────────────────────────────────── [Definition] diff --git a/Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf b/Docker/fail2ban-dev-config/fail2ban/jail.d/manual-Jail.conf similarity index 95% rename from Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf rename to Docker/fail2ban-dev-config/fail2ban/jail.d/manual-Jail.conf index 8137a82..00a9a82 100644 --- a/Docker/fail2ban-dev-config/fail2ban/jail.d/bangui-sim.conf +++ b/Docker/fail2ban-dev-config/fail2ban/jail.d/manual-Jail.conf @@ -5,10 +5,10 @@ # for lines produced by Docker/simulate_failed_logins.sh. # ────────────────────────────────────────────────────────────── -[bangui-sim] +[manual-Jail] enabled = true -filter = bangui-sim +filter = manual-Jail logpath = /remotelogs/bangui/auth.log backend = polling maxretry = 3 diff --git a/Docker/release.sh b/Docker/release.sh new file mode 100644 index 0000000..f275025 --- /dev/null +++ b/Docker/release.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Bump the project version and push images to the registry. +# +# Usage: +# ./release.sh +# +# The current version is stored in VERSION (next to this script). +# You will be asked whether to bump major, minor, or patch. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERSION_FILE="${SCRIPT_DIR}/VERSION" + +# --------------------------------------------------------------------------- +# Read current version +# --------------------------------------------------------------------------- +if [[ ! -f "${VERSION_FILE}" ]]; then + echo "0.0.0" > "${VERSION_FILE}" +fi + +CURRENT="$(cat "${VERSION_FILE}")" +# Strip leading 'v' for arithmetic +VERSION="${CURRENT#v}" + +IFS='.' read -r MAJOR MINOR PATCH <<< "${VERSION}" + +echo "============================================" +echo " BanGUI — Release" +echo " Current version: v${MAJOR}.${MINOR}.${PATCH}" +echo "============================================" +echo "" +echo "How would you like to bump the version?" +echo " 1) patch (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.${MINOR}.$((PATCH + 1)))" +echo " 2) minor (v${MAJOR}.${MINOR}.${PATCH} → v${MAJOR}.$((MINOR + 1)).0)" +echo " 3) major (v${MAJOR}.${MINOR}.${PATCH} → v$((MAJOR + 1)).0.0)" +echo "" +read -rp "Enter choice [1/2/3]: " CHOICE + +case "${CHOICE}" in + 1) NEW_TAG="v${MAJOR}.${MINOR}.$((PATCH + 1))" ;; + 2) NEW_TAG="v${MAJOR}.$((MINOR + 1)).0" ;; + 3) NEW_TAG="v$((MAJOR + 1)).0.0" ;; + *) + echo "Invalid choice. Aborting." >&2 + exit 1 + ;; +esac + +echo "" +echo "New version: ${NEW_TAG}" +read -rp "Confirm? [y/N]: " CONFIRM +if [[ ! "${CONFIRM}" =~ ^[yY]$ ]]; then + echo "Aborted." + exit 0 +fi + +# --------------------------------------------------------------------------- +# Write new version +# --------------------------------------------------------------------------- +echo "${NEW_TAG}" > "${VERSION_FILE}" +echo "Version file updated → ${VERSION_FILE}" + +# Keep frontend/package.json in sync so __APP_VERSION__ matches Docker/VERSION. +FRONT_VERSION="${NEW_TAG#v}" +FRONT_PKG="${SCRIPT_DIR}/../frontend/package.json" +sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${FRONT_VERSION}\"/" "${FRONT_PKG}" +echo "frontend/package.json version updated → ${FRONT_VERSION}" + +# --------------------------------------------------------------------------- +# Push +# --------------------------------------------------------------------------- +bash "${SCRIPT_DIR}/push.sh" "${NEW_TAG}" +bash "${SCRIPT_DIR}/push.sh" diff --git a/Docker/simulate_failed_logins.sh b/Docker/simulate_failed_logins.sh index 3a01691..a0ad9ac 100644 --- a/Docker/simulate_failed_logins.sh +++ b/Docker/simulate_failed_logins.sh @@ -3,7 +3,7 @@ # simulate_failed_logins.sh # # Writes synthetic authentication-failure log lines to a file -# that matches the bangui-sim fail2ban filter. +# that matches the manual-Jail fail2ban filter. # # Usage: # bash Docker/simulate_failed_logins.sh [COUNT] [SOURCE_IP] [LOG_FILE] @@ -13,7 +13,7 @@ # SOURCE_IP: 192.168.100.99 # LOG_FILE : Docker/logs/auth.log (relative to repo root) # -# Log line format (must match bangui-sim failregex exactly): +# Log line format (must match manual-Jail failregex exactly): # YYYY-MM-DD HH:MM:SS bangui-auth: authentication failure from # ────────────────────────────────────────────────────────────── diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 1756fff..c9d5b7a 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -4,136 +4,56 @@ This document breaks the entire BanGUI project into development stages, ordered --- -## Agent Operating Instructions +## Open Issues -These instructions apply to every AI agent working in this repository. Read them fully before touching any file. +### ~~1. Dashboard — Version Tag Mismatch~~ ✅ Done -### Before You Begin +**Implemented:** +- `frontend/vite.config.ts`: reads `package.json#version` at build time and injects it as the global `__APP_VERSION__` via Vite `define`. +- `frontend/src/vite-env.d.ts`: adds `declare const __APP_VERSION__: string` so TypeScript knows about the global. +- `frontend/src/layouts/MainLayout.tsx`: renders `BanGUI v{__APP_VERSION__}` in the sidebar footer when expanded (hidden when collapsed). +- `frontend/src/components/ServerStatusBar.tsx`: tooltip changed from `"fail2ban version"` to `"fail2ban daemon version"`. +- `Docker/release.sh`: after bumping `VERSION`, also updates `frontend/package.json#version` via `sed` to keep them in sync. +- `frontend/package.json`: version bumped from `0.9.0` to `0.9.3` to match `Docker/VERSION`. +- Tests added: `src/components/__tests__/ServerStatusBar.test.tsx`, `src/layouts/__tests__/MainLayout.test.tsx`. -1. Read [Instructions.md](Instructions.md) in full — it defines the project context, coding standards, and workflow rules. Every rule there is authoritative and takes precedence over any assumption you make. -2. Read [Architekture.md](Architekture.md) to understand the system structure before touching any component. -3. Read the development guide relevant to your task: [Backend-Development.md](Backend-Development.md) or [Web-Development.md](Web-Development.md) (or both). -4. Read [Features.md](Features.md) so you understand what the product is supposed to do and do not accidentally break intended behaviour. +**Problem:** The `ServerStatusBar` component on the Dashboard displays `v{status.version}`, which is the **fail2ban daemon version** (e.g. `v1.1.0`). The BanGUI application version lives in `Docker/VERSION` (e.g. `v0.9.3`) and is unrelated to the fail2ban version. Users see a version number they don't recognise and assume it reflects the BanGUI release. -### How to Work Through This Document +**Goal:** Make the distinction clear and expose the BanGUI application version. -- Tasks are grouped by feature area. Each group is self-contained. -- Work through tasks in the order they appear within a group; earlier tasks establish foundations for later ones. -- Mark a task **in-progress** before you start it and **completed** the moment it is done. Never batch completions. -- If a task depends on another task that is not yet complete, stop and complete the dependency first. -- If you are uncertain whether a change is correct, read the relevant documentation section again before proceeding. Do not guess. +**Suggested approach:** +1. Inject the BanGUI app version at build time — add a `define` entry in `frontend/vite.config.ts` that reads the `version` field from `frontend/package.json` (e.g. `__APP_VERSION__`). Keep `frontend/package.json` and `Docker/VERSION` in sync (update the release script `Docker/release.sh` or `Makefile` to write `package.json#version` from `VERSION`). +2. Show the BanGUI version in the sidebar footer inside `MainLayout.tsx` (collapsed view: show only when expanded, or via tooltip). This is the natural place for an "about" version tag. +3. Update the fail2ban version tooltip in `ServerStatusBar.tsx` from the generic `"fail2ban version"` to something like `"fail2ban daemon version"` so the two are no longer visually indistinguishable. -### Code Quality Rules (Summary) - -- No TODOs, no placeholders, no half-finished functions. -- Full type annotations on every function (Python) and full TypeScript types on every symbol (no `any`). -- Layered architecture: routers → services → repositories. No layer may skip another. -- All backend errors are raised as typed HTTP exceptions; all unexpected errors are logged via structlog before re-raising. -- All frontend state lives in typed hooks; no raw `fetch` calls outside of the `api/` layer. -- After every code change, run the full test suite (`make test`) and ensure it is green. - -### Definition of Done - -A task is done when: -- The code compiles and the test suite passes (`make test`). -- The feature works end-to-end in the dev stack (`make up`). -- No new lint errors are introduced. -- The change is consistent with all documentation rules. +**Files:** `frontend/vite.config.ts`, `frontend/package.json`, `Docker/VERSION`, `Docker/release.sh`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/components/ServerStatusBar.tsx`. --- -## Bug Fixes +### ~~2. Dashboard — Improve "Failures" Tooltip~~ ✅ Done + +**Implemented:** In `frontend/src/components/ServerStatusBar.tsx`, changed the `Failures:` label to `Failed Attempts:` and updated the tooltip from `"Currently failing IPs"` to `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Updated `ServerStatusBar.test.tsx` to assert the new label text. + +**Problem:** The `ServerStatusBar` shows a "Failures: 42" counter with the tooltip `"Currently failing IPs"`. In fail2ban terminology *failures* are individual **failed authentication attempts** tracked in the fail2ban DB, not the number of unique IPs that failed. The current wording is ambiguous and misleading — users may think it means broken connections or error states. + +**Goal:** Replace the tooltip with accurate, self-explanatory wording. + +**Suggested fix:** Change the `Tooltip` content for the Failures stat in `ServerStatusBar.tsx` from `"Currently failing IPs"` to something like `"Total failed authentication attempts currently tracked by fail2ban across all active jails"`. Additionally, consider renaming the label from `"Failures:"` to `"Failed Attempts:"` to match the tooltip language. + +**Files:** `frontend/src/components/ServerStatusBar.tsx`. --- -### BUG-001 — fail2ban: `bangui-sim` jail fails to start due to missing `banaction` +### ~~3. Config → Server Tab — Move "Service Health" to Top~~ ✅ Done -**Status:** Done +**Implemented:** In `frontend/src/components/config/ServerTab.tsx`, moved `` from the end of the JSX return to be the first element rendered inside the tab container, before all settings fields. -**Summary:** `jail.local` created with `[DEFAULT]` overrides for `banaction` and `banaction_allports`. The container init script (`init-fail2ban-config`) overwrites `jail.conf` from the image's `/defaults/` on every start, so modifying `jail.conf` directly is ineffective. `jail.local` is not in the container's defaults and thus persists correctly. Additionally fixed a `TypeError` in `config_file_service.py` where `except jail_service.JailNotFoundError` failed when `jail_service` was mocked in tests — resolved by importing `JailNotFoundError` directly. +**Problem:** In the Config page → Server tab, the `Service Health` panel (`ServerHealthSection`) is rendered at the bottom of the tab, after all settings sections (log level, log target, DB purge settings, map thresholds, reload/restart buttons). This means users must scroll past all editable fields to check service connectivity status, even though the health status is the most critical piece of context — it indicates whether the server is reachable at all. -#### Error +**Goal:** Move the `` block to the **top** of the `ServerTab` render output, before any settings fields. -``` -Failed during configuration: Bad value substitution: option 'action' in section 'bangui-sim' -contains an interpolation key 'banaction' which is not a valid option name. -Raw value: '%(action_)s' -``` +**Suggested fix:** In `frontend/src/components/config/ServerTab.tsx`, move the `{/* Service Health & Log Viewer section */}` block (currently at the end of the JSX return around line 415) to be the first section rendered inside the tab container. -#### Root Cause - -fail2ban's interpolation system resolves option values at configuration load time by -substituting `%(key)s` placeholders with values from the same section or from `[DEFAULT]`. - -The chain that fails is: - -1. Every jail inherits `action = %(action_)s` from `[DEFAULT]` (no override in `bangui-sim.conf`). -2. `action_` is defined in `[DEFAULT]` as `%(banaction)s[port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"]`. -3. `banaction` is **commented out** in `[DEFAULT]`: - ```ini - # Docker/fail2ban-dev-config/fail2ban/jail.conf [DEFAULT] - #banaction = iptables-multiport ← this line is disabled - ``` -4. Because `banaction` is absent from the interpolation namespace, fail2ban cannot resolve - `action_`, which makes it unable to resolve `action`, and the jail fails to load. - -The same root cause affects every jail in `jail.d/` that does not define its own `banaction`, -including `blocklist-import.conf`. - -#### Fix - -**File:** `Docker/fail2ban-dev-config/fail2ban/jail.conf` - -Uncomment the `banaction` line inside the `[DEFAULT]` section so the value is globally -available to all jails: - -```ini -banaction = iptables-multiport -banaction_allports = iptables-allports -``` - -This is safe: the dev compose (`Docker/compose.debug.yml`) already grants the fail2ban -container `NET_ADMIN` and `NET_RAW` capabilities, which are the prerequisites for -iptables-based banning. - -#### Tasks - -- [x] **BUG-001-T1 — Add `banaction` override via `jail.local` [DEFAULT]** - - Open `Docker/fail2ban-dev-config/fail2ban/jail.conf`. - Find the two commented-out lines near the `action_` definition: - ```ini - #banaction = iptables-multiport - #banaction_allports = iptables-allports - ``` - Remove the leading `#` from both lines so they become active options. - Do not change any other part of the file. - -- [x] **BUG-001-T2 — Restart the fail2ban container and verify clean startup** - - Bring the dev stack down and back up: - ```bash - make down && make up - ``` - Wait for the fail2ban container to reach `healthy`, then inspect its logs: - ```bash - make logs # or: docker logs bangui-fail2ban-dev 2>&1 | grep -i error - ``` - Confirm that no `Bad value substitution` or `Failed during configuration` lines appear - and that both `bangui-sim` and `blocklist-import` jails show as **enabled** in the output. - -- [x] **BUG-001-T3 — Verify ban/unban cycle works end-to-end** - - With the stack running, trigger the simulation script: - ```bash - bash Docker/simulate_failed_logins.sh - ``` - Then confirm fail2ban has recorded a ban: - ```bash - bash Docker/check_ban_status.sh - ``` - The script should report at least one banned IP in the `bangui-sim` jail. - Also verify that the BanGUI dashboard reflects the new ban entry. +**Files:** `frontend/src/components/config/ServerTab.tsx`. --- - diff --git a/backend/app/config.py b/backend/app/config.py index 0f73ce5..4e89da2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -85,4 +85,4 @@ def get_settings() -> Settings: A validated :class:`Settings` object. Raises :class:`pydantic.ValidationError` if required keys are absent or values fail validation. """ - return Settings() # type: ignore[call-arg] # pydantic-settings populates required fields from env vars + return Settings() # pydantic-settings populates required fields from env vars diff --git a/backend/app/main.py b/backend/app/main.py index a02c1c1..bb25f20 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -49,6 +49,7 @@ from app.routers import ( ) from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError +from app.utils.jail_config import ensure_jail_configs # --------------------------------------------------------------------------- # Ensure the bundled fail2ban package is importable from fail2ban-master/ @@ -137,7 +138,13 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: log.info("bangui_starting_up", database_path=settings.database_path) + # --- Ensure required jail config files are present --- + ensure_jail_configs(Path(settings.fail2ban_config_dir) / "jail.d") + # --- Application database --- + db_path: Path = Path(settings.database_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + log.debug("database_directory_ensured", directory=str(db_path.parent)) db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) db.row_factory = aiosqlite.Row await init_db(db) @@ -320,17 +327,15 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware): if path.startswith("/api") and not getattr( request.app.state, "_setup_complete_cached", False ): - db: aiosqlite.Connection | None = getattr(request.app.state, "db", None) - if db is not None: - from app.services import setup_service # noqa: PLC0415 + from app.services import setup_service # noqa: PLC0415 - if await setup_service.is_setup_complete(db): - request.app.state._setup_complete_cached = True - else: - return RedirectResponse( - url="/api/setup", - status_code=status.HTTP_307_TEMPORARY_REDIRECT, - ) + db: aiosqlite.Connection | None = getattr(request.app.state, "db", None) + if db is None or not await setup_service.is_setup_complete(db): + return RedirectResponse( + url="/api/setup", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) + request.app.state._setup_complete_cached = True return await call_next(request) diff --git a/backend/app/models/config.py b/backend/app/models/config.py index b336018..3f2569a 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -807,6 +807,14 @@ class InactiveJail(BaseModel): "inactive jails that appear in this list." ), ) + has_local_override: bool = Field( + default=False, + description=( + "``True`` when a ``jail.d/{name}.local`` file exists for this jail. " + "Only meaningful for inactive jails; indicates that a cleanup action " + "is available." + ), + ) class InactiveJailListResponse(BaseModel): diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 572d168..8bee91d 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -40,9 +40,12 @@ from __future__ import annotations import datetime from typing import Annotated +import structlog from fastapi import APIRouter, HTTPException, Path, Query, Request, status from app.dependencies import AuthDep + +log: structlog.stdlib.BoundLogger = structlog.get_logger() from app.models.config import ( ActionConfig, ActionCreateRequest, @@ -97,6 +100,7 @@ from app.services.config_service import ( ConfigValidationError, JailNotFoundError, ) +from app.services.jail_service import JailOperationError from app.tasks.health_check import _run_probe from app.utils.fail2ban_client import Fail2BanConnectionError @@ -357,11 +361,17 @@ async def reload_fail2ban( _auth: Validated session. Raises: + HTTPException: 409 when fail2ban reports the reload failed. HTTPException: 502 when fail2ban is unreachable. """ socket_path: str = request.app.state.settings.fail2ban_socket try: await jail_service.reload_all(socket_path) + except JailOperationError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"fail2ban reload failed: {exc}", + ) from exc except Fail2BanConnectionError as exc: raise _bad_gateway(exc) from exc @@ -381,24 +391,57 @@ async def restart_fail2ban( ) -> None: """Trigger a full fail2ban service restart. - The fail2ban daemon is completely stopped and then started again, - re-reading all configuration files in the process. + Stops the fail2ban daemon via the Unix domain socket, then starts it + again using the configured ``fail2ban_start_command``. After starting, + probes the socket for up to 10 seconds to confirm the daemon came back + online. Args: request: Incoming request. _auth: Validated session. Raises: - HTTPException: 502 when fail2ban is unreachable. + HTTPException: 409 when fail2ban reports the stop command failed. + HTTPException: 502 when fail2ban is unreachable for the stop command. + HTTPException: 503 when fail2ban does not come back online within + 10 seconds after being started. Check the fail2ban log for + initialisation errors. Use + ``POST /api/config/jails/{name}/rollback`` if a specific jail + is suspect. """ socket_path: str = request.app.state.settings.fail2ban_socket + start_cmd: str = request.app.state.settings.fail2ban_start_command + start_cmd_parts: list[str] = start_cmd.split() + + # Step 1: stop the daemon via socket. try: - # Perform restart by sending the restart command via the fail2ban socket. - # If fail2ban is not running, this will raise an exception, and we return 502. await jail_service.restart(socket_path) + except JailOperationError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"fail2ban stop command failed: {exc}", + ) from exc except Fail2BanConnectionError as exc: raise _bad_gateway(exc) from exc + # Step 2: start the daemon via subprocess. + await config_file_service.start_daemon(start_cmd_parts) + + # Step 3: probe the socket until fail2ban is responsive or the budget expires. + fail2ban_running: bool = await config_file_service.wait_for_fail2ban( + socket_path, max_wait_seconds=10.0 + ) + if not fail2ban_running: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "fail2ban was stopped but did not come back online within 10 seconds. " + "Check the fail2ban log for initialisation errors. " + "Use POST /api/config/jails/{name}/rollback if a specific jail is suspect." + ), + ) + log.info("fail2ban_restarted") + # --------------------------------------------------------------------------- # Regex tester (stateless) @@ -755,6 +798,60 @@ async def deactivate_jail( return result +@router.delete( + "/jails/{name}/local", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete the jail.d override file for an inactive jail", +) +async def delete_jail_local_override( + request: Request, + _auth: AuthDep, + name: _NamePath, +) -> None: + """Remove the ``jail.d/{name}.local`` override file for an inactive jail. + + This endpoint is the clean-up action for inactive jails that still carry + a ``.local`` override file (e.g. one written with ``enabled = false`` by a + previous deactivation). The file is deleted without modifying fail2ban's + running state, since the jail is already inactive. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Name of the jail whose ``.local`` file should be removed. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if *name* is not found in any config file. + HTTPException: 409 if the jail is currently active. + HTTPException: 500 if the file cannot be deleted. + HTTPException: 502 if fail2ban is unreachable. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + + try: + await config_file_service.delete_jail_local_override( + config_dir, socket_path, name + ) + except JailNameError as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except JailAlreadyActiveError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jail {name!r} is currently active; deactivate it first.", + ) from None + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete config override: {exc}", + ) from exc + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc + + # --------------------------------------------------------------------------- # Jail validation & rollback endpoints (Task 3) # --------------------------------------------------------------------------- diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index 54aee28..cb93f33 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -14,8 +14,8 @@ Endpoints: * ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model * ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model * ``GET /api/config/actions`` — list all action files -* ``GET /api/config/actions/{name}`` — get one action file (with content) -* ``PUT /api/config/actions/{name}`` — update an action file +* ``GET /api/config/actions/{name}/raw`` — get one action file (raw content) +* ``PUT /api/config/actions/{name}/raw`` — update an action file (raw content) * ``POST /api/config/actions`` — create a new action file * ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model * ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model @@ -460,7 +460,7 @@ async def list_action_files( @router.get( - "/actions/{name}", + "/actions/{name}/raw", response_model=ConfFileContent, summary="Return an action definition file with its content", ) @@ -496,7 +496,7 @@ async def get_action_file( @router.put( - "/actions/{name}", + "/actions/{name}/raw", status_code=status.HTTP_204_NO_CONTENT, summary="Update an action definition file", ) diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py index ab1c44c..b5dc1eb 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -429,6 +429,7 @@ def _build_inactive_jail( name: str, settings: dict[str, str], source_file: str, + config_dir: Path | None = None, ) -> InactiveJail: """Construct an :class:`~app.models.config.InactiveJail` from raw settings. @@ -436,6 +437,8 @@ def _build_inactive_jail( name: Jail section name. settings: Merged key→value dict (DEFAULT values already applied). source_file: Path of the file that last defined this section. + config_dir: Absolute path to the fail2ban configuration directory, used + to check whether a ``jail.d/{name}.local`` override file exists. Returns: Populated :class:`~app.models.config.InactiveJail`. @@ -513,6 +516,11 @@ def _build_inactive_jail( bantime_escalation=bantime_escalation, source_file=source_file, enabled=enabled, + has_local_override=( + (config_dir / "jail.d" / f"{name}.local").is_file() + if config_dir is not None + else False + ), ) @@ -740,7 +748,7 @@ async def _probe_fail2ban_running(socket_path: str) -> bool: return False -async def _wait_for_fail2ban( +async def wait_for_fail2ban( socket_path: str, max_wait_seconds: float = 10.0, poll_interval: float = 2.0, @@ -764,7 +772,7 @@ async def _wait_for_fail2ban( return False -async def _start_daemon(start_cmd_parts: list[str]) -> bool: +async def start_daemon(start_cmd_parts: list[str]) -> bool: """Start the fail2ban daemon using *start_cmd_parts*. Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation) @@ -1111,7 +1119,7 @@ async def list_inactive_jails( continue source = source_files.get(jail_name, config_dir) - inactive.append(_build_inactive_jail(jail_name, settings, source)) + inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir))) log.info( "inactive_jails_listed", @@ -1469,6 +1477,57 @@ async def deactivate_jail( ) +async def delete_jail_local_override( + config_dir: str, + socket_path: str, + name: str, +) -> None: + """Delete the ``jail.d/{name}.local`` override file for an inactive jail. + + This is the clean-up action shown in the config UI when an inactive jail + still has a ``.local`` override file (e.g. ``enabled = false``). The + file is deleted outright; no fail2ban reload is required because the jail + is already inactive. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Name of the jail whose ``.local`` file should be removed. + + Raises: + JailNameError: If *name* contains invalid characters. + JailNotFoundInConfigError: If *name* is not defined in any config file. + JailAlreadyActiveError: If the jail is currently active (refusing to + delete the live config file). + ConfigWriteError: If the file cannot be deleted. + """ + _safe_jail_name(name) + + loop = asyncio.get_event_loop() + all_jails, _source_files = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + + if name not in all_jails: + raise JailNotFoundInConfigError(name) + + active_names = await _get_active_jail_names(socket_path) + if name in active_names: + raise JailAlreadyActiveError(name) + + local_path = Path(config_dir) / "jail.d" / f"{name}.local" + try: + await loop.run_in_executor( + None, lambda: local_path.unlink(missing_ok=True) + ) + except OSError as exc: + raise ConfigWriteError( + f"Failed to delete {local_path}: {exc}" + ) from exc + + log.info("jail_local_override_deleted", jail=name, path=str(local_path)) + + async def validate_jail_config( config_dir: str, name: str, @@ -1541,11 +1600,11 @@ async def rollback_jail( log.info("jail_rolled_back_disabled", jail=name) # Attempt to start the daemon. - started = await _start_daemon(start_cmd_parts) + started = await start_daemon(start_cmd_parts) log.info("jail_rollback_start_attempted", jail=name, start_ok=started) # Wait for the socket to come back. - fail2ban_running = await _wait_for_fail2ban( + fail2ban_running = await wait_for_fail2ban( socket_path, max_wait_seconds=10.0, poll_interval=2.0 ) diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index fda532a..bc84d38 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -685,24 +685,29 @@ async def reload_all( async def restart(socket_path: str) -> None: - """Restart the fail2ban service (daemon). + """Stop the fail2ban daemon via the Unix socket. - Sends the 'restart' command to the fail2ban daemon via the Unix socket. - All jails are stopped and the daemon is restarted, re-reading all - configuration from scratch. + Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()`` + on the daemon side and tears down all jails. The caller is responsible + for starting the daemon again (e.g. via ``fail2ban-client start``). + + Note: + ``["restart"]`` is a *client-side* orchestration command that is not + handled by the fail2ban server transmitter — sending it to the socket + raises ``"Invalid command"`` in the daemon. Args: socket_path: Path to the fail2ban Unix domain socket. Raises: - JailOperationError: If fail2ban reports the operation failed. + JailOperationError: If fail2ban reports the stop command failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) try: - _ok(await client.send(["restart"])) - log.info("fail2ban_restarted") + _ok(await client.send(["stop"])) + log.info("fail2ban_stopped_for_restart") except ValueError as exc: raise JailOperationError(str(exc)) from exc diff --git a/backend/app/utils/jail_config.py b/backend/app/utils/jail_config.py new file mode 100644 index 0000000..c6eaf00 --- /dev/null +++ b/backend/app/utils/jail_config.py @@ -0,0 +1,93 @@ +"""Utilities for ensuring required fail2ban jail configuration files exist. + +BanGUI requires two custom jails — ``manual-Jail`` and ``blocklist-import`` +— to be present in the fail2ban ``jail.d`` directory. This module provides +:func:`ensure_jail_configs` which checks each of the four files +(``*.conf`` template + ``*.local`` override) and creates any that are missing +with the correct default content. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog + +if TYPE_CHECKING: + from pathlib import Path + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Default file contents +# --------------------------------------------------------------------------- + +_MANUAL_JAIL_CONF = """\ +[manual-Jail] + +enabled = false +filter = manual-Jail +logpath = /remotelogs/bangui/auth.log +backend = polling +maxretry = 3 +findtime = 120 +bantime = 60 +ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 +""" + +_MANUAL_JAIL_LOCAL = """\ +[manual-Jail] +enabled = true +""" + +_BLOCKLIST_IMPORT_CONF = """\ +[blocklist-import] + +enabled = false +filter = +logpath = /dev/null +backend = auto +maxretry = 1 +findtime = 1d +bantime = 1w +ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12 +""" + +_BLOCKLIST_IMPORT_LOCAL = """\ +[blocklist-import] +enabled = true +""" + +# --------------------------------------------------------------------------- +# File registry: (filename, default_content) +# --------------------------------------------------------------------------- + +_JAIL_FILES: list[tuple[str, str]] = [ + ("manual-Jail.conf", _MANUAL_JAIL_CONF), + ("manual-Jail.local", _MANUAL_JAIL_LOCAL), + ("blocklist-import.conf", _BLOCKLIST_IMPORT_CONF), + ("blocklist-import.local", _BLOCKLIST_IMPORT_LOCAL), +] + + +def ensure_jail_configs(jail_d_path: Path) -> None: + """Ensure the required fail2ban jail configuration files exist. + + Checks for ``manual-Jail.conf``, ``manual-Jail.local``, + ``blocklist-import.conf``, and ``blocklist-import.local`` inside + *jail_d_path*. Any file that is missing is created with its default + content. Existing files are **never** overwritten. + + Args: + jail_d_path: Path to the fail2ban ``jail.d`` directory. Will be + created (including all parents) if it does not already exist. + """ + jail_d_path.mkdir(parents=True, exist_ok=True) + + for filename, default_content in _JAIL_FILES: + file_path = jail_d_path / filename + if file_path.exists(): + log.debug("jail_config_already_exists", path=str(file_path)) + else: + file_path.write_text(default_content, encoding="utf-8") + log.info("jail_config_created", path=str(file_path)) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index beab707..7b8ad69 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "bangui-backend" -version = "0.1.0" +version = "0.9.0" description = "BanGUI backend — fail2ban web management interface" requires-python = ">=3.12" dependencies = [ diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index ae71e04..f3f32fb 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -370,6 +370,124 @@ class TestReloadFail2ban: assert resp.status_code == 204 + async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None: + """POST /api/config/reload returns 502 when fail2ban socket is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.config.jail_service.reload_all", + AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")), + ): + resp = await config_client.post("/api/config/reload") + + assert resp.status_code == 502 + + async def test_409_when_reload_operation_fails(self, config_client: AsyncClient) -> None: + """POST /api/config/reload returns 409 when fail2ban reports a reload error.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.config.jail_service.reload_all", + AsyncMock(side_effect=JailOperationError("reload rejected")), + ): + resp = await config_client.post("/api/config/reload") + + assert resp.status_code == 409 + + +# --------------------------------------------------------------------------- +# POST /api/config/restart +# --------------------------------------------------------------------------- + + +class TestRestartFail2ban: + """Tests for ``POST /api/config/restart``.""" + + async def test_204_on_success(self, config_client: AsyncClient) -> None: + """POST /api/config/restart returns 204 when fail2ban restarts cleanly.""" + with ( + patch( + "app.routers.config.jail_service.restart", + AsyncMock(return_value=None), + ), + patch( + "app.routers.config.config_file_service.start_daemon", + AsyncMock(return_value=True), + ), + patch( + "app.routers.config.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=True), + ), + ): + resp = await config_client.post("/api/config/restart") + + assert resp.status_code == 204 + + async def test_503_when_fail2ban_does_not_come_back(self, config_client: AsyncClient) -> None: + """POST /api/config/restart returns 503 when fail2ban does not come back online.""" + with ( + patch( + "app.routers.config.jail_service.restart", + AsyncMock(return_value=None), + ), + patch( + "app.routers.config.config_file_service.start_daemon", + AsyncMock(return_value=True), + ), + patch( + "app.routers.config.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=False), + ), + ): + resp = await config_client.post("/api/config/restart") + + assert resp.status_code == 503 + + async def test_409_when_stop_command_fails(self, config_client: AsyncClient) -> None: + """POST /api/config/restart returns 409 when fail2ban rejects the stop command.""" + from app.services.jail_service import JailOperationError + + with patch( + "app.routers.config.jail_service.restart", + AsyncMock(side_effect=JailOperationError("stop failed")), + ): + resp = await config_client.post("/api/config/restart") + + assert resp.status_code == 409 + + async def test_502_when_fail2ban_unreachable(self, config_client: AsyncClient) -> None: + """POST /api/config/restart returns 502 when fail2ban socket is unreachable.""" + from app.utils.fail2ban_client import Fail2BanConnectionError + + with patch( + "app.routers.config.jail_service.restart", + AsyncMock(side_effect=Fail2BanConnectionError("no socket", "/fake.sock")), + ): + resp = await config_client.post("/api/config/restart") + + assert resp.status_code == 502 + + async def test_start_daemon_called_after_stop(self, config_client: AsyncClient) -> None: + """start_daemon is called after a successful stop.""" + mock_start = AsyncMock(return_value=True) + with ( + patch( + "app.routers.config.jail_service.restart", + AsyncMock(return_value=None), + ), + patch( + "app.routers.config.config_file_service.start_daemon", + mock_start, + ), + patch( + "app.routers.config.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=True), + ), + ): + await config_client.post("/api/config/restart") + + mock_start.assert_awaited_once() + # --------------------------------------------------------------------------- # POST /api/config/regex-test diff --git a/backend/tests/test_routers/test_file_config.py b/backend/tests/test_routers/test_file_config.py index c788bef..2226238 100644 --- a/backend/tests/test_routers/test_file_config.py +++ b/backend/tests/test_routers/test_file_config.py @@ -377,6 +377,102 @@ class TestCreateActionFile: assert resp.json()["name"] == "myaction" +# --------------------------------------------------------------------------- +# GET /api/config/actions/{name}/raw +# --------------------------------------------------------------------------- + + +class TestGetActionFileRaw: + """Tests for ``GET /api/config/actions/{name}/raw``.""" + + async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_action_file", + AsyncMock(return_value=_conf_file_content("iptables")), + ): + resp = await file_config_client.get("/api/config/actions/iptables/raw") + + assert resp.status_code == 200 + assert resp.json()["name"] == "iptables" + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.get_action_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.get("/api/config/actions/missing/raw") + + assert resp.status_code == 404 + + async def test_503_on_config_dir_error( + self, file_config_client: AsyncClient + ) -> None: + with patch( + "app.routers.file_config.file_config_service.get_action_file", + AsyncMock(side_effect=ConfigDirError("no dir")), + ): + resp = await file_config_client.get("/api/config/actions/iptables/raw") + + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# PUT /api/config/actions/{name}/raw +# --------------------------------------------------------------------------- + + +class TestUpdateActionFileRaw: + """Tests for ``PUT /api/config/actions/{name}/raw``.""" + + async def test_204_on_success(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.write_action_file", + AsyncMock(return_value=None), + ): + resp = await file_config_client.put( + "/api/config/actions/iptables/raw", + json={"content": "[Definition]\nactionban = iptables -I INPUT -s -j DROP\n"}, + ) + + assert resp.status_code == 204 + + async def test_400_write_error(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.write_action_file", + AsyncMock(side_effect=ConfigFileWriteError("disk full")), + ): + resp = await file_config_client.put( + "/api/config/actions/iptables/raw", + json={"content": "x"}, + ) + + assert resp.status_code == 400 + + async def test_404_not_found(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.write_action_file", + AsyncMock(side_effect=ConfigFileNotFoundError("missing")), + ): + resp = await file_config_client.put( + "/api/config/actions/missing/raw", + json={"content": "x"}, + ) + + assert resp.status_code == 404 + + async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: + with patch( + "app.routers.file_config.file_config_service.write_action_file", + AsyncMock(side_effect=ConfigFileNameError("bad/../name")), + ): + resp = await file_config_client.put( + "/api/config/actions/escape/raw", + json={"content": "x"}, + ) + + assert resp.status_code == 400 + + # --------------------------------------------------------------------------- # POST /api/config/jail-files # --------------------------------------------------------------------------- diff --git a/backend/tests/test_routers/test_setup.py b/backend/tests/test_routers/test_setup.py index e07cef4..0fc7040 100644 --- a/backend/tests/test_routers/test_setup.py +++ b/backend/tests/test_routers/test_setup.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import aiosqlite import pytest @@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient from app.config import Settings from app.db import init_db -from app.main import create_app +from app.main import _lifespan, create_app # --------------------------------------------------------------------------- # Shared setup payload @@ -286,3 +286,151 @@ class TestSetupCompleteCaching: # Cache was warm — is_setup_complete must not have been called. assert call_count == 0 + +# --------------------------------------------------------------------------- +# Task 0.1 — Lifespan creates the database parent directory (Task 0.1) +# --------------------------------------------------------------------------- + + +class TestLifespanDatabaseDirectoryCreation: + """App lifespan creates the database parent directory when it does not exist.""" + + async def test_creates_nested_database_directory(self, tmp_path: Path) -> None: + """Lifespan creates intermediate directories for the database path. + + Verifies that a deeply-nested database path is handled correctly — + the parent directories are created before ``aiosqlite.connect`` is + called so the app does not crash on a fresh volume. + """ + nested_db = tmp_path / "deep" / "nested" / "bangui.db" + assert not nested_db.parent.exists() + + settings = Settings( + database_path=str(nested_db), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-lifespan-mkdir-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + mock_scheduler = MagicMock() + mock_scheduler.start = MagicMock() + mock_scheduler.shutdown = MagicMock() + + with ( + patch("app.services.geo_service.init_geoip"), + patch( + "app.services.geo_service.load_cache_from_db", + new=AsyncMock(return_value=None), + ), + patch("app.tasks.health_check.register"), + patch("app.tasks.blocklist_import.register"), + patch("app.tasks.geo_cache_flush.register"), + patch("app.tasks.geo_re_resolve.register"), + patch("app.main.AsyncIOScheduler", return_value=mock_scheduler), + patch("app.main.ensure_jail_configs"), + ): + async with _lifespan(app): + assert nested_db.parent.exists(), ( + "Expected lifespan to create database parent directory" + ) + + async def test_existing_database_directory_is_not_an_error( + self, tmp_path: Path + ) -> None: + """Lifespan does not raise when the database directory already exists. + + ``mkdir(exist_ok=True)`` must be used so that re-starts on an existing + volume do not fail. + """ + db_path = tmp_path / "bangui.db" + # tmp_path already exists — this simulates a pre-existing volume. + + settings = Settings( + database_path=str(db_path), + fail2ban_socket="/tmp/fake.sock", + session_secret="test-lifespan-exist-ok-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + mock_scheduler = MagicMock() + mock_scheduler.start = MagicMock() + mock_scheduler.shutdown = MagicMock() + + with ( + patch("app.services.geo_service.init_geoip"), + patch( + "app.services.geo_service.load_cache_from_db", + new=AsyncMock(return_value=None), + ), + patch("app.tasks.health_check.register"), + patch("app.tasks.blocklist_import.register"), + patch("app.tasks.geo_cache_flush.register"), + patch("app.tasks.geo_re_resolve.register"), + patch("app.main.AsyncIOScheduler", return_value=mock_scheduler), + patch("app.main.ensure_jail_configs"), + ): + # Should not raise FileExistsError or similar. + async with _lifespan(app): + assert tmp_path.exists() + + +# --------------------------------------------------------------------------- +# Task 0.2 — Middleware redirects when app.state.db is None +# --------------------------------------------------------------------------- + + +class TestSetupRedirectMiddlewareDbNone: + """SetupRedirectMiddleware redirects when the database is not yet available.""" + + async def test_redirects_to_setup_when_db_not_set(self, tmp_path: Path) -> None: + """A ``None`` db on app.state causes a 307 redirect to ``/api/setup``. + + Simulates the race window where a request arrives before the lifespan + has finished initialising the database connection. + """ + settings = Settings( + database_path=str(tmp_path / "bangui.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-db-none-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + # Deliberately do NOT set app.state.db to simulate startup not complete. + + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://test" + ) as ac: + response = await ac.get("/api/auth/login", follow_redirects=False) + + assert response.status_code == 307 + assert response.headers["location"] == "/api/setup" + + async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None: + """Health endpoint is always reachable even when db is not initialised.""" + settings = Settings( + database_path=str(tmp_path / "bangui.db"), + fail2ban_socket="/tmp/fake_fail2ban.sock", + session_secret="test-db-none-health-secret", + session_duration_minutes=60, + timezone="UTC", + log_level="debug", + ) + app = create_app(settings=settings) + + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://test" + ) as ac: + response = await ac.get("/api/health") + + assert response.status_code == 200 + diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py index ddbd548..e648fe8 100644 --- a/backend/tests/test_services/test_config_file_service.py +++ b/backend/tests/test_services/test_config_file_service.py @@ -21,6 +21,7 @@ from app.services.config_file_service import ( activate_jail, deactivate_jail, list_inactive_jails, + rollback_jail, ) # --------------------------------------------------------------------------- @@ -289,6 +290,28 @@ class TestBuildInactiveJail: jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf") assert jail.enabled is True + def test_has_local_override_absent(self, tmp_path: Path) -> None: + """has_local_override is False when no .local file exists.""" + jail = _build_inactive_jail( + "sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path + ) + assert jail.has_local_override is False + + def test_has_local_override_present(self, tmp_path: Path) -> None: + """has_local_override is True when jail.d/{name}.local exists.""" + local = tmp_path / "jail.d" / "sshd.local" + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text("[sshd]\nenabled = false\n") + jail = _build_inactive_jail( + "sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path + ) + assert jail.has_local_override is True + + def test_has_local_override_no_config_dir(self) -> None: + """has_local_override is False when config_dir is not provided.""" + jail = _build_inactive_jail("sshd", {}, "/etc/fail2ban/jail.conf") + assert jail.has_local_override is False + # --------------------------------------------------------------------------- # _write_local_override_sync @@ -424,6 +447,121 @@ class TestListInactiveJails: assert "sshd" in names assert "apache-auth" in names + async def test_has_local_override_true_when_local_file_exists( + self, tmp_path: Path + ) -> None: + """has_local_override is True for a jail whose jail.d .local file exists.""" + _write(tmp_path / "jail.conf", JAIL_CONF) + local = tmp_path / "jail.d" / "apache-auth.local" + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text("[apache-auth]\nenabled = false\n") + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + jail = next(j for j in result.jails if j.name == "apache-auth") + assert jail.has_local_override is True + + async def test_has_local_override_false_when_no_local_file( + self, tmp_path: Path + ) -> None: + """has_local_override is False when no jail.d .local file exists.""" + _write(tmp_path / "jail.conf", JAIL_CONF) + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_inactive_jails(str(tmp_path), "/fake.sock") + jail = next(j for j in result.jails if j.name == "apache-auth") + assert jail.has_local_override is False + + +# --------------------------------------------------------------------------- +# delete_jail_local_override +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDeleteJailLocalOverride: + """Tests for :func:`~app.services.config_file_service.delete_jail_local_override`.""" + + async def test_deletes_local_file(self, tmp_path: Path) -> None: + """delete_jail_local_override removes the jail.d/.local file.""" + from app.services.config_file_service import delete_jail_local_override + + _write(tmp_path / "jail.conf", JAIL_CONF) + local = tmp_path / "jail.d" / "apache-auth.local" + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text("[apache-auth]\nenabled = false\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth") + + assert not local.exists() + + async def test_no_error_when_local_file_missing(self, tmp_path: Path) -> None: + """delete_jail_local_override succeeds silently when no .local file exists.""" + from app.services.config_file_service import delete_jail_local_override + + _write(tmp_path / "jail.conf", JAIL_CONF) + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + # Must not raise even though there is no .local file. + await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth") + + async def test_raises_jail_not_found(self, tmp_path: Path) -> None: + """delete_jail_local_override raises JailNotFoundInConfigError for unknown jail.""" + from app.services.config_file_service import ( + JailNotFoundInConfigError, + delete_jail_local_override, + ) + + _write(tmp_path / "jail.conf", JAIL_CONF) + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + pytest.raises(JailNotFoundInConfigError), + ): + await delete_jail_local_override(str(tmp_path), "/fake.sock", "nonexistent") + + async def test_raises_jail_already_active(self, tmp_path: Path) -> None: + """delete_jail_local_override raises JailAlreadyActiveError when jail is running.""" + from app.services.config_file_service import ( + JailAlreadyActiveError, + delete_jail_local_override, + ) + + _write(tmp_path / "jail.conf", JAIL_CONF) + local = tmp_path / "jail.d" / "sshd.local" + local.parent.mkdir(parents=True, exist_ok=True) + local.write_text("[sshd]\nenabled = false\n") + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ), + pytest.raises(JailAlreadyActiveError), + ): + await delete_jail_local_override(str(tmp_path), "/fake.sock", "sshd") + + async def test_raises_jail_name_error(self, tmp_path: Path) -> None: + """delete_jail_local_override raises JailNameError for invalid jail names.""" + from app.services.config_file_service import ( + JailNameError, + delete_jail_local_override, + ) + + with pytest.raises(JailNameError): + await delete_jail_local_override(str(tmp_path), "/fake.sock", "../evil") + # --------------------------------------------------------------------------- # activate_jail @@ -3173,5 +3311,209 @@ class TestActivateJailRollback: # Verify the error message mentions logpath issues. assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower() + async def test_activate_jail_rollback_deletes_file_when_no_prior_local( + self, tmp_path: Path + ) -> None: + """Rollback deletes the .local file when none existed before activation. + + When a jail had no .local override before activation, activate_jail + creates one with enabled = true. If reload then crashes, rollback must + delete that file (leaving the jail in the same state as before the + activation attempt). + + Expects: + - The .local file is absent after rollback. + - The response indicates recovered=True. + """ + from app.models.config import ActivateJailRequest, JailValidationResult + + _write(tmp_path / "jail.conf", JAIL_CONF) + (tmp_path / "jail.d").mkdir(parents=True, exist_ok=True) + local_path = tmp_path / "jail.d" / "apache-auth.local" + # No .local file exists before activation. + assert not local_path.exists() + + req = ActivateJailRequest() + reload_call_count = 0 + + async def reload_side_effect(socket_path: str, **kwargs: object) -> None: + nonlocal reload_call_count + reload_call_count += 1 + if reload_call_count == 1: + raise RuntimeError("fail2ban crashed") + # Recovery reload succeeds. + + with ( + patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), + patch("app.services.config_file_service.jail_service") as mock_js, + patch( + "app.services.config_file_service._probe_fail2ban_running", + new=AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._validate_jail_config_sync", + return_value=JailValidationResult( + jail_name="apache-auth", valid=True + ), + ), + ): + mock_js.reload_all = AsyncMock(side_effect=reload_side_effect) + result = await activate_jail( + str(tmp_path), "/fake.sock", "apache-auth", req + ) + + assert result.active is False + assert result.recovered is True + assert not local_path.exists() +# --------------------------------------------------------------------------- +# rollback_jail +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestRollbackJail: + """Integration tests for :func:`~app.services.config_file_service.rollback_jail`.""" + + async def test_local_file_written_enabled_false(self, tmp_path: Path) -> None: + """rollback_jail writes enabled=false to jail.d/{name}.local before any socket call.""" + (tmp_path / "jail.d").mkdir() + + with ( + patch( + "app.services.config_file_service.start_daemon", + AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._get_active_jail_names", + AsyncMock(return_value={"sshd"}), + ), + ): + await rollback_jail(str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]) + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file(), "jail.d/sshd.local must be written" + content = local.read_text() + assert "enabled = false" in content + + async def test_start_command_invoked_via_subprocess(self, tmp_path: Path) -> None: + """rollback_jail invokes the daemon start command via start_daemon, not via socket.""" + mock_start = AsyncMock(return_value=True) + + with ( + patch("app.services.config_file_service.start_daemon", mock_start), + patch( + "app.services.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._get_active_jail_names", + AsyncMock(return_value={"other"}), + ), + ): + await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + mock_start.assert_awaited_once_with(["fail2ban-client", "start"]) + + async def test_fail2ban_running_reflects_socket_probe_not_subprocess_exit( + self, tmp_path: Path + ) -> None: + """fail2ban_running in the response reflects the socket probe result. + + Even when start_daemon returns True (subprocess exit 0), if the socket + probe returns False the response must report fail2ban_running=False. + """ + with ( + patch( + "app.services.config_file_service.start_daemon", + AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=False), # socket still unresponsive + ), + ): + result = await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + assert result.fail2ban_running is False + + async def test_active_jails_zero_when_fail2ban_not_running( + self, tmp_path: Path + ) -> None: + """active_jails is 0 in the response when fail2ban_running is False.""" + with ( + patch( + "app.services.config_file_service.start_daemon", + AsyncMock(return_value=False), + ), + patch( + "app.services.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=False), + ), + ): + result = await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + assert result.active_jails == 0 + + async def test_active_jails_count_from_socket_when_running( + self, tmp_path: Path + ) -> None: + """active_jails reflects the actual jail count from the socket when fail2ban is up.""" + with ( + patch( + "app.services.config_file_service.start_daemon", + AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=True), + ), + patch( + "app.services.config_file_service._get_active_jail_names", + AsyncMock(return_value={"sshd", "nginx", "apache-auth"}), + ), + ): + result = await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + assert result.active_jails == 3 + + async def test_fail2ban_down_at_start_still_succeeds_file_write( + self, tmp_path: Path + ) -> None: + """rollback_jail writes the local file even when fail2ban is down at call time.""" + # fail2ban is down: start_daemon fails and wait_for_fail2ban returns False. + with ( + patch( + "app.services.config_file_service.start_daemon", + AsyncMock(return_value=False), + ), + patch( + "app.services.config_file_service.wait_for_fail2ban", + AsyncMock(return_value=False), + ), + ): + result = await rollback_jail( + str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"] + ) + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file(), "local file must be written even when fail2ban is down" + assert result.disabled is True + assert result.fail2ban_running is False + diff --git a/backend/tests/test_services/test_jail_service.py b/backend/tests/test_services/test_jail_service.py index 9ac80c0..4afb718 100644 --- a/backend/tests/test_services/test_jail_service.py +++ b/backend/tests/test_services/test_jail_service.py @@ -441,6 +441,33 @@ class TestJailControls: ) assert exc_info.value.name == "airsonic-auth" + async def test_restart_sends_stop_command(self) -> None: + """restart() sends the ['stop'] command to the fail2ban socket.""" + with _patch_client({"stop": (0, None)}): + await jail_service.restart(_SOCKET) # should not raise + + async def test_restart_operation_error_raises(self) -> None: + """restart() raises JailOperationError when fail2ban rejects the stop.""" + with _patch_client({"stop": (1, Exception("cannot stop"))}), pytest.raises( + JailOperationError + ): + await jail_service.restart(_SOCKET) + + async def test_restart_connection_error_propagates(self) -> None: + """restart() propagates Fail2BanConnectionError when socket is unreachable.""" + + class _FailClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock( + side_effect=Fail2BanConnectionError("no socket", _SOCKET) + ) + + with ( + patch("app.services.jail_service.Fail2BanClient", _FailClient), + pytest.raises(Fail2BanConnectionError), + ): + await jail_service.restart(_SOCKET) + async def test_start_not_found_raises(self) -> None: """start_jail raises JailNotFoundError for unknown jail.""" with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError): diff --git a/backend/tests/test_utils/test_jail_config.py b/backend/tests/test_utils/test_jail_config.py new file mode 100644 index 0000000..35f55b6 --- /dev/null +++ b/backend/tests/test_utils/test_jail_config.py @@ -0,0 +1,134 @@ +"""Tests for app.utils.jail_config.ensure_jail_configs.""" + +from __future__ import annotations + +from pathlib import Path + +from app.utils.jail_config import ( + _BLOCKLIST_IMPORT_CONF, + _BLOCKLIST_IMPORT_LOCAL, + _MANUAL_JAIL_CONF, + _MANUAL_JAIL_LOCAL, + ensure_jail_configs, +) + +# --------------------------------------------------------------------------- +# Expected filenames +# --------------------------------------------------------------------------- + +_MANUAL_CONF = "manual-Jail.conf" +_MANUAL_LOCAL = "manual-Jail.local" +_BLOCKLIST_CONF = "blocklist-import.conf" +_BLOCKLIST_LOCAL = "blocklist-import.local" + +_ALL_FILES = [_MANUAL_CONF, _MANUAL_LOCAL, _BLOCKLIST_CONF, _BLOCKLIST_LOCAL] + +_CONTENT_MAP: dict[str, str] = { + _MANUAL_CONF: _MANUAL_JAIL_CONF, + _MANUAL_LOCAL: _MANUAL_JAIL_LOCAL, + _BLOCKLIST_CONF: _BLOCKLIST_IMPORT_CONF, + _BLOCKLIST_LOCAL: _BLOCKLIST_IMPORT_LOCAL, +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _read(jail_d: Path, filename: str) -> str: + return (jail_d / filename).read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Tests: ensure_jail_configs +# --------------------------------------------------------------------------- + + +class TestEnsureJailConfigs: + def test_all_missing_creates_all_four(self, tmp_path: Path) -> None: + """All four files are created when the directory is empty.""" + jail_d = tmp_path / "jail.d" + ensure_jail_configs(jail_d) + + for name in _ALL_FILES: + assert (jail_d / name).exists(), f"{name} should have been created" + assert _read(jail_d, name) == _CONTENT_MAP[name] + + def test_all_missing_creates_correct_content(self, tmp_path: Path) -> None: + """Each created file has exactly the expected default content.""" + jail_d = tmp_path / "jail.d" + ensure_jail_configs(jail_d) + + # .conf files must set enabled = false + for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF): + content = _read(jail_d, conf_file) + assert "enabled = false" in content + + # .local files must set enabled = true and nothing else + for local_file in (_MANUAL_LOCAL, _BLOCKLIST_LOCAL): + content = _read(jail_d, local_file) + assert "enabled = true" in content + + def test_all_present_overwrites_nothing(self, tmp_path: Path) -> None: + """Existing files are never overwritten.""" + jail_d = tmp_path / "jail.d" + jail_d.mkdir() + + sentinel = "# EXISTING CONTENT — must not be replaced\n" + for name in _ALL_FILES: + (jail_d / name).write_text(sentinel, encoding="utf-8") + + ensure_jail_configs(jail_d) + + for name in _ALL_FILES: + assert _read(jail_d, name) == sentinel, ( + f"{name} should not have been overwritten" + ) + + def test_only_local_files_missing_creates_only_locals( + self, tmp_path: Path + ) -> None: + """Only the .local files are created when the .conf files already exist.""" + jail_d = tmp_path / "jail.d" + jail_d.mkdir() + + sentinel = "# pre-existing conf\n" + for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF): + (jail_d / conf_file).write_text(sentinel, encoding="utf-8") + + ensure_jail_configs(jail_d) + + # .conf files must remain unchanged + for conf_file in (_MANUAL_CONF, _BLOCKLIST_CONF): + assert _read(jail_d, conf_file) == sentinel + + # .local files must have been created with correct content + for local_file, expected in ( + (_MANUAL_LOCAL, _MANUAL_JAIL_LOCAL), + (_BLOCKLIST_LOCAL, _BLOCKLIST_IMPORT_LOCAL), + ): + assert (jail_d / local_file).exists(), f"{local_file} should have been created" + assert _read(jail_d, local_file) == expected + + def test_creates_jail_d_directory_if_missing(self, tmp_path: Path) -> None: + """The jail.d directory is created automatically when absent.""" + jail_d = tmp_path / "nested" / "jail.d" + assert not jail_d.exists() + ensure_jail_configs(jail_d) + assert jail_d.is_dir() + + def test_idempotent_on_repeated_calls(self, tmp_path: Path) -> None: + """Calling ensure_jail_configs twice does not alter any file.""" + jail_d = tmp_path / "jail.d" + ensure_jail_configs(jail_d) + + # Record content after first call + first_pass = {name: _read(jail_d, name) for name in _ALL_FILES} + + ensure_jail_configs(jail_d) + + for name in _ALL_FILES: + assert _read(jail_d, name) == first_pass[name], ( + f"{name} changed on second call" + ) diff --git a/frontend/package.json b/frontend/package.json index b62ef87..91423a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "bangui-frontend", "private": true, - "version": "0.1.0", + "version": "0.9.3", "description": "BanGUI frontend — fail2ban web management interface", "type": "module", "scripts": { diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 6fc8266..8917e3e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -7,22 +7,16 @@ import { api } from "./client"; import { ENDPOINTS } from "./endpoints"; -import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth"; -import { sha256Hex } from "../utils/crypto"; +import type { LoginResponse, LogoutResponse } from "../types/auth"; /** * Authenticate with the master password. * - * The password is SHA-256 hashed client-side before transmission so that - * the plaintext never leaves the browser. The backend bcrypt-verifies the - * received hash against the stored bcrypt(sha256) digest. - * * @param password - The master password entered by the user. * @returns The login response containing the session token. */ export async function login(password: string): Promise { - const body: LoginRequest = { password: await sha256Hex(password) }; - return api.post(ENDPOINTS.authLogin, body); + return api.post(ENDPOINTS.authLogin, { password }); } /** diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 738ae3e..2b7ef23 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -39,10 +39,8 @@ import type { LogPreviewResponse, MapColorThresholdsResponse, MapColorThresholdsUpdate, - PendingRecovery, RegexTestRequest, RegexTestResponse, - RollbackResponse, ServerSettingsResponse, ServerSettingsUpdate, JailFileConfig, @@ -265,14 +263,14 @@ export async function fetchActionFiles(): Promise { } export async function fetchActionFile(name: string): Promise { - return get(ENDPOINTS.configAction(name)); + return get(ENDPOINTS.configActionRaw(name)); } export async function updateActionFile( name: string, req: ConfFileUpdateRequest ): Promise { - await put(ENDPOINTS.configAction(name), req); + await put(ENDPOINTS.configActionRaw(name), req); } export async function createActionFile( @@ -552,6 +550,18 @@ export async function deactivateJail( ); } +/** + * Delete the ``jail.d/{name}.local`` override file for an inactive jail. + * + * Only valid when the jail is **not** currently active. Use this to clean up + * leftover ``.local`` files after a jail has been fully deactivated. + * + * @param name - The jail name. + */ +export async function deleteJailLocalOverride(name: string): Promise { + await del(ENDPOINTS.configJailLocalOverride(name)); +} + // --------------------------------------------------------------------------- // fail2ban log viewer (Task 2) // --------------------------------------------------------------------------- @@ -593,21 +603,3 @@ export async function validateJailConfig( ): 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/endpoints.ts b/frontend/src/api/endpoints.ts index df6f9f1..871d8b6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -71,11 +71,10 @@ export const ENDPOINTS = { `/config/jails/${encodeURIComponent(name)}/activate`, configJailDeactivate: (name: string): string => `/config/jails/${encodeURIComponent(name)}/deactivate`, + configJailLocalOverride: (name: string): string => + `/config/jails/${encodeURIComponent(name)}/local`, configJailValidate: (name: string): string => `/config/jails/${encodeURIComponent(name)}/validate`, - configJailRollback: (name: string): string => - `/config/jails/${encodeURIComponent(name)}/rollback`, - configPendingRecovery: "/config/pending-recovery" as string, configGlobal: "/config/global", configReload: "/config/reload", configRestart: "/config/restart", @@ -105,6 +104,7 @@ export const ENDPOINTS = { `/config/jails/${encodeURIComponent(jailName)}/action/${encodeURIComponent(actionName)}`, configActions: "/config/actions", configAction: (name: string): string => `/config/actions/${encodeURIComponent(name)}`, + configActionRaw: (name: string): string => `/config/actions/${encodeURIComponent(name)}/raw`, configActionParsed: (name: string): string => `/config/actions/${encodeURIComponent(name)}/parsed`, diff --git a/frontend/src/components/ServerStatusBar.tsx b/frontend/src/components/ServerStatusBar.tsx index 4953abf..c0851e8 100644 --- a/frontend/src/components/ServerStatusBar.tsx +++ b/frontend/src/components/ServerStatusBar.tsx @@ -109,7 +109,7 @@ export function ServerStatusBar(): React.JSX.Element { {/* Version */} {/* ---------------------------------------------------------------- */} {status?.version != null && ( - + v{status.version} @@ -139,9 +139,9 @@ export function ServerStatusBar(): React.JSX.Element { - +
- Failures: + Failed Attempts: {status.total_failures} diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx index f448f9d..cb7d49a 100644 --- a/frontend/src/components/SetupGuard.tsx +++ b/frontend/src/components/SetupGuard.tsx @@ -33,9 +33,9 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element { if (!cancelled) setStatus(res.completed ? "done" : "pending"); }) .catch((): void => { - // If the check fails, optimistically allow through — the backend will - // redirect API calls to /api/setup anyway. - if (!cancelled) setStatus("done"); + // A failed check conservatively redirects to /setup — a crashed + // backend cannot serve protected routes anyway. + if (!cancelled) setStatus("pending"); }); return (): void => { cancelled = true; diff --git a/frontend/src/components/__tests__/ServerStatusBar.test.tsx b/frontend/src/components/__tests__/ServerStatusBar.test.tsx new file mode 100644 index 0000000..a827bf8 --- /dev/null +++ b/frontend/src/components/__tests__/ServerStatusBar.test.tsx @@ -0,0 +1,153 @@ +/** + * Tests for the ServerStatusBar component. + * + * Covers loading state, online / offline rendering, and correct tooltip + * wording that distinguishes the fail2ban daemon version from the BanGUI + * application version. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { ServerStatusBar } from "../ServerStatusBar"; + +// --------------------------------------------------------------------------- +// Mock useServerStatus so tests never touch the network. +// --------------------------------------------------------------------------- + +vi.mock("../../hooks/useServerStatus"); + +import { useServerStatus } from "../../hooks/useServerStatus"; + +const mockedUseServerStatus = vi.mocked(useServerStatus); + +function renderBar(): void { + render( + + + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ServerStatusBar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows a spinner while the initial load is in progress", () => { + mockedUseServerStatus.mockReturnValue({ + status: null, + loading: true, + error: null, + refresh: vi.fn(), + }); + renderBar(); + // The status-area spinner is labelled "Checking\u2026". + expect(screen.getByText("Checking\u2026")).toBeInTheDocument(); + }); + + it("renders an Online badge when the server is reachable", () => { + mockedUseServerStatus.mockReturnValue({ + status: { + online: true, + version: "1.1.0", + active_jails: 3, + total_bans: 10, + total_failures: 5, + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + renderBar(); + expect(screen.getByText("Online")).toBeInTheDocument(); + }); + + it("renders an Offline badge when the server is unreachable", () => { + mockedUseServerStatus.mockReturnValue({ + status: { + online: false, + version: null, + active_jails: 0, + total_bans: 0, + total_failures: 0, + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + renderBar(); + expect(screen.getByText("Offline")).toBeInTheDocument(); + }); + + it("displays the daemon version string when available", () => { + mockedUseServerStatus.mockReturnValue({ + status: { + online: true, + version: "1.2.3", + active_jails: 1, + total_bans: 0, + total_failures: 0, + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + renderBar(); + expect(screen.getByText("v1.2.3")).toBeInTheDocument(); + }); + + it("does not render the version element when version is null", () => { + mockedUseServerStatus.mockReturnValue({ + status: { + online: false, + version: null, + active_jails: 0, + total_bans: 0, + total_failures: 0, + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + renderBar(); + // No version string should appear in the document. + expect(screen.queryByText(/^v\d/)).not.toBeInTheDocument(); + }); + + it("shows jail / ban / failure counts when the server is online", () => { + mockedUseServerStatus.mockReturnValue({ + status: { + online: true, + version: "1.0.0", + active_jails: 4, + total_bans: 21, + total_failures: 99, + }, + loading: false, + error: null, + refresh: vi.fn(), + }); + renderBar(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("21")).toBeInTheDocument(); + expect(screen.getByText("99")).toBeInTheDocument(); + // Verify the "Failed Attempts:" label (renamed from "Failures:"). + expect(screen.getByText("Failed Attempts:")).toBeInTheDocument(); + }); + + it("renders an error message when the status fetch fails", () => { + mockedUseServerStatus.mockReturnValue({ + status: null, + loading: false, + error: "Network error", + refresh: vi.fn(), + }); + renderBar(); + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/SetupGuard.test.tsx b/frontend/src/components/__tests__/SetupGuard.test.tsx new file mode 100644 index 0000000..c7e4a76 --- /dev/null +++ b/frontend/src/components/__tests__/SetupGuard.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { SetupGuard } from "../SetupGuard"; + +// Mock the setup API module so tests never hit a real network. +vi.mock("../../api/setup", () => ({ + getSetupStatus: vi.fn(), +})); + +import { getSetupStatus } from "../../api/setup"; + +const mockedGetSetupStatus = vi.mocked(getSetupStatus); + +function renderGuard() { + return render( + + + + +
Protected
+ + } + /> + Setup Page
} + /> + + + , + ); +} + +describe("SetupGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows a spinner while the setup status is loading", () => { + // getSetupStatus resolves eventually — spinner should show immediately. + mockedGetSetupStatus.mockReturnValue(new Promise(() => {})); + renderGuard(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + it("renders children when setup is complete", async () => { + mockedGetSetupStatus.mockResolvedValue({ completed: true }); + renderGuard(); + await waitFor(() => { + expect(screen.getByTestId("protected-content")).toBeInTheDocument(); + }); + }); + + it("redirects to /setup when setup is not complete", async () => { + mockedGetSetupStatus.mockResolvedValue({ completed: false }); + renderGuard(); + await waitFor(() => { + expect(screen.getByTestId("setup-page")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument(); + }); + + it("redirects to /setup when the API call fails", async () => { + // Task 0.3: a failed check must redirect to /setup, not allow through. + mockedGetSetupStatus.mockRejectedValue(new Error("Network error")); + renderGuard(); + await waitFor(() => { + expect(screen.getByTestId("setup-page")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/common/RecoveryBanner.tsx b/frontend/src/components/common/RecoveryBanner.tsx deleted file mode 100644 index 032f07f..0000000 --- a/frontend/src/components/common/RecoveryBanner.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** - * RecoveryBanner — full-width warning shown when fail2ban stopped responding - * shortly after a jail was activated (indicating the new jail config may be - * invalid). - * - * Polls ``GET /api/config/pending-recovery`` every 10 seconds and renders a - * dismissible ``MessageBar`` when an unresolved crash record is present. - * The "Disable & Restart" button calls the rollback endpoint to disable the - * offending jail and attempt to restart fail2ban. - */ - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Button, - MessageBar, - MessageBarActions, - MessageBarBody, - MessageBarTitle, - Spinner, - tokens, -} from "@fluentui/react-components"; -import { useNavigate } from "react-router-dom"; -import { fetchPendingRecovery, rollbackJail } from "../../api/config"; -import type { PendingRecovery } from "../../types/config"; - -const POLL_INTERVAL_MS = 10_000; - -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -/** - * Recovery banner that polls for pending crash-recovery records. - * - * Mount this once at the layout level so it is visible across all pages - * while a recovery is pending. - * - * @returns A MessageBar element, or null when nothing is pending. - */ -export function RecoveryBanner(): React.JSX.Element | null { - const navigate = useNavigate(); - const [pending, setPending] = useState(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 deleted file mode 100644 index 2ac52f3..0000000 --- a/frontend/src/components/common/__tests__/RecoveryBanner.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Tests for RecoveryBanner (Task 3). - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { FluentProvider, webLightTheme } from "@fluentui/react-components"; -import { MemoryRouter } from "react-router-dom"; -import { RecoveryBanner } from "../RecoveryBanner"; -import type { PendingRecovery } from "../../../types/config"; - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -vi.mock("../../../api/config", () => ({ - fetchPendingRecovery: vi.fn(), - rollbackJail: vi.fn(), -})); - -import { fetchPendingRecovery, rollbackJail } from "../../../api/config"; - -const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery); -const mockRollbackJail = vi.mocked(rollbackJail); - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const pendingRecord: PendingRecovery = { - jail_name: "sshd", - activated_at: "2024-01-01T12:00:00Z", - detected_at: "2024-01-01T12:00:30Z", - recovered: false, -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function renderBanner() { - return render( - - - - - , - ); -} - -// --------------------------------------------------------------------------- -// 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/ActivateJailDialog.tsx b/frontend/src/components/config/ActivateJailDialog.tsx index 7adf14e..d31fc6d 100644 --- a/frontend/src/components/config/ActivateJailDialog.tsx +++ b/frontend/src/components/config/ActivateJailDialog.tsx @@ -5,12 +5,8 @@ * findtime, maxretry, port and logpath. Calls the activate endpoint on * confirmation and propagates the result via callbacks. * - * Task 3 additions: - * - Runs pre-activation validation when the dialog opens and displays any - * warnings or blocking errors before the user confirms. - * - Extended spinner text during the post-reload probe phase. - * - Calls `onCrashDetected` when the activation response signals that - * fail2ban stopped responding after the reload. + * Runs pre-activation validation when the dialog opens and displays any + * warnings or blocking errors before the user confirms. */ import { useEffect, useState } from "react"; @@ -52,11 +48,6 @@ export interface ActivateJailDialogProps { onClose: () => void; /** Called after the jail has been successfully activated. */ onActivated: () => void; - /** - * Called when fail2ban stopped responding after the jail was activated. - * The recovery banner will surface this to the user. - */ - onCrashDetected?: () => void; } // --------------------------------------------------------------------------- @@ -77,7 +68,6 @@ export function ActivateJailDialog({ open, onClose, onActivated, - onCrashDetected, }: ActivateJailDialogProps): React.JSX.Element { const [bantime, setBantime] = useState(""); const [findtime, setFindtime] = useState(""); @@ -173,9 +163,6 @@ export function ActivateJailDialog({ setValidationWarnings(result.validation_warnings); } resetForm(); - if (!result.fail2ban_running) { - onCrashDetected?.(); - } onActivated(); }) .catch((err: unknown) => { @@ -339,9 +326,10 @@ export function ActivateJailDialog({ style={{ marginTop: tokens.spacingVerticalS }} > - Activation Failed — System Recovered - Activation of jail “{jail.name}” failed. The server - has been automatically recovered. + Activation Failed — Configuration Rolled Back + The configuration for jail “{jail.name}” has been + rolled back to its previous state and fail2ban is running + normally. Review the configuration and try activating again. )} @@ -351,10 +339,12 @@ export function ActivateJailDialog({ style={{ marginTop: tokens.spacingVerticalS }} > - Activation Failed — Manual Intervention Required - Activation of jail “{jail.name}” failed and - automatic recovery was unsuccessful. Manual intervention is - required. + Activation Failed — Rollback Unsuccessful + Activation of jail “{jail.name}” failed and the + automatic rollback did not complete. The file{" "} + jail.d/{jail.name}.local may still contain{" "} + enabled = true. Check the fail2ban logs, correct + the file manually, and restart fail2ban. )} diff --git a/frontend/src/components/config/JailsTab.tsx b/frontend/src/components/config/JailsTab.tsx index a776d8c..9606325 100644 --- a/frontend/src/components/config/JailsTab.tsx +++ b/frontend/src/components/config/JailsTab.tsx @@ -35,6 +35,7 @@ import { ApiError } from "../../api/client"; import { addLogPath, deactivateJail, + deleteJailLocalOverride, deleteLogPath, fetchInactiveJails, fetchJailConfigFileContent, @@ -573,7 +574,7 @@ function JailConfigDetail({ )} - {readOnly && (onActivate !== undefined || onValidate !== undefined) && ( + {readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
{onValidate !== undefined && ( + )} {onActivate !== undefined && (