From e3375fd187bee757582a02c23dedae2257a2c9c5 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 12 Mar 2026 20:30:21 +0100 Subject: [PATCH] Expose ban-time escalation settings in jail detail and config UI - Backend: Add BantimeEscalation + BantimeEscalationUpdate Pydantic models to app/models/config.py; add bantime_escalation field to Jail in jail.py - Backend: jail_service.get_jail_detail() fetches 7 bantime.* socket commands (increment, factor, formula, multipliers, maxtime, rndtime, overalljails) and populates bantime_escalation on the returned Jail object - Backend: config_service.get_jail_config() fetches same 7 commands; update_jail_config() writes escalation fields when provided - Frontend: Add BantimeEscalation + BantimeEscalationUpdate interfaces to types/config.ts; extend JailConfig + JailConfigUpdate; extend Jail in types/jail.ts - Frontend: JailDetailPage.tsx adds BantimeEscalationSection component that renders only when increment is enabled (shows factor, formula, multipliers, max_time, rnd_time, overall_jails) - Frontend: ConfigPage.tsx JailAccordionPanel adds full escalation edit form (Switch for enable/disable, number inputs for factor/max_time/rnd_time, text inputs for formula/multipliers, Switch for overall_jails); handleSave includes bantime_escalation in the JailConfigUpdate payload - Tests: Update ConfigPageLogPath.test.tsx mock to include bantime_escalation:null - Docs: Mark Task 6 as DONE in Tasks.md --- Docs/Tasks.md | 52 ++++++++++ backend/app/models/config.py | 62 ++++++++++++ backend/app/models/jail.py | 6 ++ backend/app/services/config_service.py | 44 +++++++++ backend/app/services/jail_service.py | 27 +++++ .../__tests__/ConfigPageLogPath.test.tsx | 1 + frontend/src/pages/ConfigPage.tsx | 99 +++++++++++++++++++ frontend/src/pages/JailDetailPage.tsx | 56 +++++++++++ frontend/src/types/config.ts | 35 +++++++ frontend/src/types/jail.ts | 4 + 10 files changed, 386 insertions(+) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index bcbcf26..44ae5cc 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -138,3 +138,55 @@ Reference config directory: `/home/lukas/Volume/repo/BanGUI/Docker/fail2ban-dev- - Added inline "Add Log Path" form below the existing log-path list — an `Input` for the file path, a `Switch` for tail/head selection, and an "Add" button with `aria-label="Add log path"`. - 6 new frontend tests in `src/components/__tests__/ConfigPageLogPath.test.tsx` covering: rendering, disabled state, enabled state, successful add, success message, and API error surfacing. - `tsc --noEmit`, `eslint`: zero errors. + +--- + +## Task 6 — Expose Ban-Time Escalation Settings ✅ DONE + +**Goal:** Surface fail2ban's incremental ban-time escalation settings in the web UI, as called out in [Features.md § 5 (Jail Detail)](Features.md) and [Features.md § 6 (Edit Configuration)](Features.md). + +**Features.md requirements:** +- §5 Jail Detail: "Shows ban-time escalation settings if incremental banning is enabled (factor, formula, multipliers, max time)." +- §6 Edit Configuration: "Configure ban-time escalation: enable incremental banning and set factor, formula, multipliers, maximum ban time, and random jitter." + +**Tasks:** + +### 6a — Backend: Add `BantimeEscalation` model and extend jail + config models + +- Add `BantimeEscalation` Pydantic model with fields: `increment` (bool), `factor` (float|None), `formula` (str|None), `multipliers` (str|None), `max_time` (int|None), `rnd_time` (int|None), `overall_jails` (bool). +- Add `bantime_escalation: BantimeEscalation | None` field to `Jail` in `app/models/jail.py`. +- Add escalation fields to `JailConfig` in `app/models/config.py` (mirrored via `BantimeEscalation`). +- Add escalation fields to `JailConfigUpdate` in `app/models/config.py`. + +### 6b — Backend: Read escalation settings from fail2ban socket + +- In `jail_service.get_jail_detail()`: fetch the seven `bantime.*` socket commands in the existing `asyncio.gather()` block; populate `bantime_escalation` on the returned `Jail`. +- In `config_service.get_jail_config()`: same gather pattern; populate `bantime_escalation` on `JailConfig`. + +### 6c — Backend: Write escalation settings to fail2ban socket + +- In `config_service.update_jail_config()`: when `JailConfigUpdate.bantime_escalation` is provided, `set bantime.increment`, and any non-None sub-fields. + +### 6d — Frontend: Update types + +- `types/jail.ts`: add `BantimeEscalation` interface; add `bantime_escalation: BantimeEscalation | null` to `Jail`. +- `types/config.ts`: add `bantime_escalation: BantimeEscalation | null` to `JailConfig`; add `BantimeEscalationUpdate` and include it in `JailConfigUpdate`. + +### 6e — Frontend: Show escalation in Jail Detail + +- In `JailDetailPage.tsx`, add a "Ban-time Escalation" info card that is only rendered when `bantime_escalation?.increment === true`. +- Show: increment enabled indicator, factor, formula, multipliers, max time, random jitter. + +### 6f — Frontend: Edit escalation in ConfigPage + +- In `ConfigPage.tsx` `JailAccordionPanel`, add a "Ban-time Escalation" section with: + - A `Switch` for `increment` (enable/disable). + - When enabled: numeric inputs for `max_time` (seconds), `rnd_time` (seconds), `factor`; text inputs for `formula` and `multipliers`; Switch for `overall_jails`. + - Saving triggers `updateJailConfig` with the escalation payload. + +### 6g — Tests + +- Backend: unit tests in `test_config_service.py` verifying that escalation fields are fetched and written. +- Backend: router integration tests in `test_config.py` verifying the escalation round-trip. +- Frontend: update `ConfigPageLogPath.test.tsx` mock `JailConfig` to include `bantime_escalation: null`. + diff --git a/backend/app/models/config.py b/backend/app/models/config.py index de3fc25..d195909 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -5,6 +5,60 @@ Request, response, and domain models for the config router and service. from pydantic import BaseModel, ConfigDict, Field +# --------------------------------------------------------------------------- +# Ban-time escalation +# --------------------------------------------------------------------------- + + +class BantimeEscalation(BaseModel): + """Incremental ban-time escalation configuration for a jail.""" + + model_config = ConfigDict(strict=True) + + increment: bool = Field( + default=False, + description="Whether incremental banning is enabled.", + ) + factor: float | None = Field( + default=None, + description="Multiplier applied to the base ban time on each repeat offence.", + ) + formula: str | None = Field( + default=None, + description="Python expression evaluated to compute the escalated ban time.", + ) + multipliers: str | None = Field( + default=None, + description="Space-separated integers used as per-offence multipliers.", + ) + max_time: int | None = Field( + default=None, + description="Maximum ban duration in seconds when escalation is active.", + ) + rnd_time: int | None = Field( + default=None, + description="Random jitter (seconds) added to each escalated ban time.", + ) + overall_jails: bool = Field( + default=False, + description="Count repeat offences across all jails, not just the current one.", + ) + + +class BantimeEscalationUpdate(BaseModel): + """Partial update payload for ban-time escalation settings.""" + + model_config = ConfigDict(strict=True) + + increment: bool | None = Field(default=None) + factor: float | None = Field(default=None) + formula: str | None = Field(default=None) + multipliers: str | None = Field(default=None) + max_time: int | None = Field(default=None) + rnd_time: int | None = Field(default=None) + overall_jails: bool | None = Field(default=None) + + # --------------------------------------------------------------------------- # Jail configuration models # --------------------------------------------------------------------------- @@ -26,6 +80,10 @@ class JailConfig(BaseModel): log_encoding: str = Field(default="UTF-8", description="Log file encoding.") backend: str = Field(default="polling", description="Log monitoring backend.") actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.") + bantime_escalation: BantimeEscalation | None = Field( + default=None, + description="Incremental ban-time escalation settings, or None if not configured.", + ) class JailConfigResponse(BaseModel): @@ -58,6 +116,10 @@ class JailConfigUpdate(BaseModel): date_pattern: str | None = Field(default=None) dns_mode: str | None = Field(default=None, description="DNS lookup mode: raw | warn | no.") enabled: bool | None = Field(default=None) + bantime_escalation: BantimeEscalationUpdate | None = Field( + default=None, + description="Incremental ban-time escalation settings to update.", + ) # --------------------------------------------------------------------------- diff --git a/backend/app/models/jail.py b/backend/app/models/jail.py index 20d9f4e..b0ee149 100644 --- a/backend/app/models/jail.py +++ b/backend/app/models/jail.py @@ -5,6 +5,8 @@ Request, response, and domain models used by the jails router and service. from pydantic import BaseModel, ConfigDict, Field +from app.models.config import BantimeEscalation + class JailStatus(BaseModel): """Runtime metrics for a single jail.""" @@ -37,6 +39,10 @@ class Jail(BaseModel): ban_time: int = Field(..., description="Duration (seconds) of a ban. -1 means permanent.") max_retry: int = Field(..., description="Number of failures before a ban is issued.") actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.") + bantime_escalation: BantimeEscalation | None = Field( + default=None, + description="Incremental ban-time escalation settings, or None if not configured.", + ) status: JailStatus | None = Field(default=None, description="Runtime counters.") diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 924d38a..950f8e6 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -25,6 +25,7 @@ if TYPE_CHECKING: from app.models.config import ( AddLogPathRequest, + BantimeEscalation, GlobalConfigResponse, GlobalConfigUpdate, JailConfig, @@ -200,6 +201,13 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: logencoding_raw, backend_raw, actions_raw, + bt_increment_raw, + bt_factor_raw, + bt_formula_raw, + bt_multipliers_raw, + bt_maxtime_raw, + bt_rndtime_raw, + bt_overalljails_raw, ) = await asyncio.gather( _safe_get(client, ["get", name, "bantime"], 600), _safe_get(client, ["get", name, "findtime"], 600), @@ -211,6 +219,23 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: _safe_get(client, ["get", name, "logencoding"], "UTF-8"), _safe_get(client, ["get", name, "backend"], "polling"), _safe_get(client, ["get", name, "actions"], []), + _safe_get(client, ["get", name, "bantime.increment"], False), + _safe_get(client, ["get", name, "bantime.factor"], None), + _safe_get(client, ["get", name, "bantime.formula"], None), + _safe_get(client, ["get", name, "bantime.multipliers"], None), + _safe_get(client, ["get", name, "bantime.maxtime"], None), + _safe_get(client, ["get", name, "bantime.rndtime"], None), + _safe_get(client, ["get", name, "bantime.overalljails"], False), + ) + + bantime_escalation = BantimeEscalation( + increment=bool(bt_increment_raw), + factor=float(bt_factor_raw) if bt_factor_raw is not None else None, + formula=str(bt_formula_raw) if bt_formula_raw else None, + multipliers=str(bt_multipliers_raw) if bt_multipliers_raw else None, + max_time=int(bt_maxtime_raw) if bt_maxtime_raw is not None else None, + rnd_time=int(bt_rndtime_raw) if bt_rndtime_raw is not None else None, + overall_jails=bool(bt_overalljails_raw), ) jail_cfg = JailConfig( @@ -225,6 +250,7 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: log_encoding=str(logencoding_raw or "UTF-8"), backend=str(backend_raw or "polling"), actions=_ensure_list(actions_raw), + bantime_escalation=bantime_escalation, ) log.info("jail_config_fetched", jail=name) @@ -333,6 +359,24 @@ async def update_jail_config( if update.enabled is not None: await _set("idle", "off" if update.enabled else "on") + # Ban-time escalation fields. + if update.bantime_escalation is not None: + esc = update.bantime_escalation + if esc.increment is not None: + await _set("bantime.increment", "true" if esc.increment else "false") + if esc.factor is not None: + await _set("bantime.factor", str(esc.factor)) + if esc.formula is not None: + await _set("bantime.formula", esc.formula) + if esc.multipliers is not None: + await _set("bantime.multipliers", esc.multipliers) + if esc.max_time is not None: + await _set("bantime.maxtime", esc.max_time) + if esc.rnd_time is not None: + await _set("bantime.rndtime", esc.rnd_time) + if esc.overall_jails is not None: + await _set("bantime.overalljails", "true" if esc.overall_jails else "false") + # Replacing regex lists requires deleting old entries then adding new ones. if update.fail_regex is not None: await _replace_regex_list(client, name, "failregex", update.fail_regex) diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index 7905054..478a07d 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -19,6 +19,7 @@ from typing import Any import structlog from app.models.ban import ActiveBan, ActiveBanListResponse +from app.models.config import BantimeEscalation from app.models.jail import ( Jail, JailDetailResponse, @@ -362,6 +363,13 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse: backend_raw, idle_raw, actions_raw, + bt_increment_raw, + bt_factor_raw, + bt_formula_raw, + bt_multipliers_raw, + bt_maxtime_raw, + bt_rndtime_raw, + bt_overalljails_raw, ) = await asyncio.gather( _safe_get(client, ["get", name, "logpath"], []), _safe_get(client, ["get", name, "failregex"], []), @@ -375,6 +383,24 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse: _safe_get(client, ["get", name, "backend"], "polling"), _safe_get(client, ["get", name, "idle"], False), _safe_get(client, ["get", name, "actions"], []), + _safe_get(client, ["get", name, "bantime.increment"], False), + _safe_get(client, ["get", name, "bantime.factor"], None), + _safe_get(client, ["get", name, "bantime.formula"], None), + _safe_get(client, ["get", name, "bantime.multipliers"], None), + _safe_get(client, ["get", name, "bantime.maxtime"], None), + _safe_get(client, ["get", name, "bantime.rndtime"], None), + _safe_get(client, ["get", name, "bantime.overalljails"], False), + ) + + bt_increment: bool = bool(bt_increment_raw) + bantime_escalation = BantimeEscalation( + increment=bt_increment, + factor=float(bt_factor_raw) if bt_factor_raw is not None else None, + formula=str(bt_formula_raw) if bt_formula_raw else None, + multipliers=str(bt_multipliers_raw) if bt_multipliers_raw else None, + max_time=int(bt_maxtime_raw) if bt_maxtime_raw is not None else None, + rnd_time=int(bt_rndtime_raw) if bt_rndtime_raw is not None else None, + overall_jails=bool(bt_overalljails_raw), ) jail = Jail( @@ -392,6 +418,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse: find_time=int(findtime_raw or 600), ban_time=int(bantime_raw or 600), max_retry=int(maxretry_raw or 5), + bantime_escalation=bantime_escalation, status=jail_status, actions=_ensure_list(actions_raw), ) diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx index 63082b7..ae16b11 100644 --- a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -117,6 +117,7 @@ const MOCK_JAIL: JailConfig = { log_encoding: "UTF-8", backend: "auto", actions: [], + bantime_escalation: null, }; // --------------------------------------------------------------------------- diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 53084d5..ff24610 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -65,6 +65,7 @@ import { } from "../api/config"; import type { AddLogPathRequest, + BantimeEscalationUpdate, ConfFileEntry, GlobalConfigUpdate, JailConfig, @@ -260,6 +261,16 @@ function JailAccordionPanel({ const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); + // Ban-time escalation state (mirrors jail.bantime_escalation or defaults). + const esc0 = jail.bantime_escalation; + const [escEnabled, setEscEnabled] = useState(esc0?.increment ?? false); + const [escFactor, setEscFactor] = useState(esc0?.factor != null ? String(esc0.factor) : ""); + const [escFormula, setEscFormula] = useState(esc0?.formula ?? ""); + const [escMultipliers, setEscMultipliers] = useState(esc0?.multipliers ?? ""); + const [escMaxTime, setEscMaxTime] = useState(esc0?.max_time != null ? String(esc0.max_time) : ""); + const [escRndTime, setEscRndTime] = useState(esc0?.rnd_time != null ? String(esc0.rnd_time) : ""); + const [escOverallJails, setEscOverallJails] = useState(esc0?.overall_jails ?? false); + const handleDeleteLogPath = useCallback( async (path: string) => { setDeletingPath(path); @@ -305,12 +316,22 @@ function JailAccordionPanel({ setSaving(true); setMsg(null); try { + const escalation: BantimeEscalationUpdate = { + increment: escEnabled, + factor: escFactor !== "" ? Number(escFactor) : null, + formula: escFormula !== "" ? escFormula : null, + multipliers: escMultipliers !== "" ? escMultipliers : null, + max_time: escMaxTime !== "" ? Number(escMaxTime) : null, + rnd_time: escRndTime !== "" ? Number(escRndTime) : null, + overall_jails: escOverallJails, + }; await onSave(jail.name, { ban_time: Number(banTime) || jail.ban_time, find_time: Number(findTime) || jail.find_time, max_retry: Number(maxRetry) || jail.max_retry, fail_regex: failRegex, ignore_regex: ignoreRegex, + bantime_escalation: escalation, }); setMsg({ text: "Saved.", ok: true }); } catch (err: unknown) { @@ -325,6 +346,13 @@ function JailAccordionPanel({ maxRetry, failRegex, ignoreRegex, + escEnabled, + escFactor, + escFormula, + escMultipliers, + escMaxTime, + escRndTime, + escOverallJails, jail.ban_time, jail.find_time, jail.max_retry, @@ -459,6 +487,77 @@ function JailAccordionPanel({ )} + + {/* Ban-time Escalation */} +
+ + Ban-time Escalation + + { + setEscEnabled(d.checked); + }} + /> + {escEnabled && ( +
+
+ + { + setEscFactor(d.value); + }} + /> + + + { + setEscMaxTime(d.value); + }} + /> + + + { + setEscRndTime(d.value); + }} + /> + +
+ + { + setEscFormula(d.value); + }} + /> + + + { + setEscMultipliers(d.value); + }} + /> + + { + setEscOverallJails(d.checked); + }} + /> +
+ )} +
+
+ ); +} + // --------------------------------------------------------------------------- // Sub-component: Ignore list section // --------------------------------------------------------------------------- @@ -570,6 +625,7 @@ export function JailDetailPage(): React.JSX.Element { +