diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 44ae5cc..d7ed642 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -190,3 +190,73 @@ Reference config directory: `/home/lukas/Volume/repo/BanGUI/Docker/fail2ban-dev- - 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`. +--- + +## Task 7 — Expose Remaining Per-Jail Config Fields (usedns, date_pattern, prefregex) ✅ DONE + +**Goal:** Surface the three remaining per-jail configuration fields — DNS look-up mode (`usedns`), custom date pattern (`datepattern`), and prefix regex (`prefregex`) — in both the backend API response model and the Configuration → Jails UI, completing the editable jail config surface defined in [Features.md § 6](Features.md). + +**Implementation summary:** + +- **Backend model** (`app/models/config.py`): + - Added `use_dns: str` (default `"warn"`) and `prefregex: str` (default `""`) to `JailConfig`. + - Added `prefregex: str | None` to `JailConfigUpdate` (`None` = skip, `""` = clear, non-empty = set). +- **Backend service** (`app/services/config_service.py`): + - Added `get usedns` and `get prefregex` to the `asyncio.gather()` block in `get_jail_config()`. + - Populated `use_dns` and `prefregex` on the returned `JailConfig`. + - Added `prefregex` validation (regex compile-check) and `set prefregex` write in `update_jail_config()`. +- **Frontend types** (`types/config.ts`): + - Added `use_dns: string` and `prefregex: string` to `JailConfig`. + - Added `prefregex?: string | null` to `JailConfigUpdate`. +- **Frontend ConfigPage** (`ConfigPage.tsx` `JailAccordionPanel`): + - Added state and editable `Input` for `date_pattern` (hints "Leave blank for auto-detect"). + - Added state and `Select` dropdown for `dns_mode` with options yes / warn / no / raw. + - Added state and editable `Input` for `prefregex` (hints "Leave blank to disable"). + - All three included in `handleSave()` update payload. +- **Tests**: 8 new service unit tests + 3 new router integration tests; `ConfigPageLogPath.test.tsx` mock updated; 628 tests pass; 85% coverage; ruff + mypy + tsc + eslint clean. + +**Goal:** Surface the three remaining per-jail configuration fields — DNS look-up mode (`usedns`), custom date pattern (`datepattern`), and prefix regex (`prefregex`) — in both the backend API response model and the Configuration → Jails UI, completing the editable jail config surface defined in [Features.md § 6](Features.md). + +**Background:** Task 4c audit found several options not yet exposed in the UI. Task 6 covered ban-time escalation. This task covers the three remaining fields that are most commonly changed through the fail2ban configuration: +- `usedns` — controls whether fail2ban resolves hostnames ("yes" / "warn" / "no" / "raw"). +- `datepattern` — custom date format for log parsing; empty / unset means fail2ban auto-detects. +- `prefregex` — a prefix regex prepended to every `failregex` for pre-filtering log lines; empty means disabled. + +**Tasks:** + +### 7a — Backend: Add `use_dns` and `prefregex` to `JailConfig` model + +- Add `use_dns: str` field to `JailConfig` in `app/models/config.py` (default `"warn"`). +- Add `prefregex: str` field to `JailConfig` (default `""`; empty string means not set). +- Add `prefregex: str | None` to `JailConfigUpdate` (`None` = skip, `""` = clear, non-empty = set). + +### 7b — Backend: Read `usedns` and `prefregex` from fail2ban socket + +- In `config_service.get_jail_config()`: add `get usedns` and `get prefregex` to the existing `asyncio.gather()` block. +- Populate `use_dns` and `prefregex` on the returned `JailConfig`. + +### 7c — Backend: Write `prefregex` to fail2ban socket + +- In `config_service.update_jail_config()`: validate `prefregex` with `_validate_regex` if non-empty, then `set prefregex ` when `JailConfigUpdate.prefregex is not None`. + +### 7d — Frontend: Update types + +- `types/config.ts`: add `use_dns: string` and `prefregex: string` to `JailConfig`. +- `types/config.ts`: add `prefregex?: string | null` to `JailConfigUpdate`. + +### 7e — Frontend: Edit `date_pattern`, `use_dns`, and `prefregex` in ConfigPage + +- In `ConfigPage.tsx` `JailAccordionPanel`, add: + - Text input for `date_pattern` (empty = auto-detect; non-empty value is sent as-is). + - `Select` dropdown for `use_dns` with options "yes" / "warn" / "no" / "raw". + - Text input for `prefregex` (empty = not set / cleared). + - All three are included in the `handleSave()` update payload. + +### 7f — Tests + +- Backend: add `usedns` and `prefregex` entries to `_DEFAULT_JAIL_RESPONSES` in `test_config_service.py`. +- Backend: add unit tests verifying new fields are fetched and `prefregex` is written via `update_jail_config()`. +- Backend: update `_make_jail_config()` in `test_config.py` to include `use_dns` and `prefregex`. +- Backend: add router integration tests for the new update fields. +- Frontend: update `ConfigPageLogPath.test.tsx` mock `JailConfig` to include `use_dns` and `prefregex`. + diff --git a/backend/app/models/config.py b/backend/app/models/config.py index d195909..2a1ff29 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -79,6 +79,8 @@ class JailConfig(BaseModel): date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.") log_encoding: str = Field(default="UTF-8", description="Log file encoding.") backend: str = Field(default="polling", description="Log monitoring backend.") + use_dns: str = Field(default="warn", description="DNS lookup mode: yes | warn | no | raw.") + prefregex: str = Field(default="", description="Prefix regex prepended to every failregex; empty means disabled.") actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.") bantime_escalation: BantimeEscalation | None = Field( default=None, @@ -113,8 +115,9 @@ class JailConfigUpdate(BaseModel): find_time: int | None = Field(default=None, ge=1) fail_regex: list[str] | None = Field(default=None, description="Failure detection regex patterns.") ignore_regex: list[str] | None = Field(default=None) + prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.") date_pattern: str | None = Field(default=None) - dns_mode: str | None = Field(default=None, description="DNS lookup mode: raw | warn | no.") + dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.") enabled: bool | None = Field(default=None) bantime_escalation: BantimeEscalationUpdate | None = Field( default=None, diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 950f8e6..4a814a5 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -200,6 +200,8 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: datepattern_raw, logencoding_raw, backend_raw, + usedns_raw, + prefregex_raw, actions_raw, bt_increment_raw, bt_factor_raw, @@ -218,6 +220,8 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: _safe_get(client, ["get", name, "datepattern"], None), _safe_get(client, ["get", name, "logencoding"], "UTF-8"), _safe_get(client, ["get", name, "backend"], "polling"), + _safe_get(client, ["get", name, "usedns"], "warn"), + _safe_get(client, ["get", name, "prefregex"], ""), _safe_get(client, ["get", name, "actions"], []), _safe_get(client, ["get", name, "bantime.increment"], False), _safe_get(client, ["get", name, "bantime.factor"], None), @@ -249,6 +253,8 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: date_pattern=str(datepattern_raw) if datepattern_raw else None, log_encoding=str(logencoding_raw or "UTF-8"), backend=str(backend_raw or "polling"), + use_dns=str(usedns_raw or "warn"), + prefregex=str(prefregex_raw) if prefregex_raw else "", actions=_ensure_list(actions_raw), bantime_escalation=bantime_escalation, ) @@ -329,6 +335,10 @@ async def update_jail_config( err = _validate_regex(pattern) if err: raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})") + if update.prefregex is not None and update.prefregex: + err = _validate_regex(update.prefregex) + if err: + raise ConfigValidationError(f"Invalid regex in 'prefregex': {err!r} (pattern: {update.prefregex!r})") client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) @@ -356,6 +366,8 @@ async def update_jail_config( await _set("datepattern", update.date_pattern) if update.dns_mode is not None: await _set("usedns", update.dns_mode) + if update.prefregex is not None: + await _set("prefregex", update.prefregex) if update.enabled is not None: await _set("idle", "off" if update.enabled else "on") diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index 4c12f82..992651e 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -77,6 +77,8 @@ def _make_jail_config(name: str = "sshd") -> JailConfig: date_pattern=None, log_encoding="UTF-8", backend="polling", + use_dns="warn", + prefregex="", actions=["iptables"], ) @@ -234,6 +236,45 @@ class TestUpdateJailConfig: assert resp.status_code == 400 + async def test_204_with_dns_mode(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd accepts dns_mode field.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"dns_mode": "no"}, + ) + + assert resp.status_code == 204 + + async def test_204_with_prefregex(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd accepts prefregex field.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"prefregex": r"^%(__prefix_line)s"}, + ) + + assert resp.status_code == 204 + + async def test_204_with_date_pattern(self, config_client: AsyncClient) -> None: + """PUT /api/config/jails/sshd accepts date_pattern field.""" + with patch( + "app.routers.config.config_service.update_jail_config", + AsyncMock(return_value=None), + ): + resp = await config_client.put( + "/api/config/jails/sshd", + json={"date_pattern": "%Y-%m-%d %H:%M:%S"}, + ) + + assert resp.status_code == 204 + # --------------------------------------------------------------------------- # GET /api/config/global diff --git a/backend/tests/test_services/test_config_service.py b/backend/tests/test_services/test_config_service.py index f1f69b0..dbf6595 100644 --- a/backend/tests/test_services/test_config_service.py +++ b/backend/tests/test_services/test_config_service.py @@ -75,6 +75,8 @@ _DEFAULT_JAIL_RESPONSES: dict[str, Any] = { "get|sshd|datepattern": (0, None), "get|sshd|logencoding": (0, "UTF-8"), "get|sshd|backend": (0, "polling"), + "get|sshd|usedns": (0, "warn"), + "get|sshd|prefregex": (0, ""), "get|sshd|actions": (0, ["iptables"]), } @@ -143,6 +145,41 @@ class TestGetJailConfig: assert result.jail.date_pattern is None + async def test_use_dns_populated(self) -> None: + """get_jail_config returns use_dns from the socket response.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|usedns": (0, "no")} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.use_dns == "no" + + async def test_use_dns_default_when_missing(self) -> None: + """get_jail_config defaults use_dns to 'warn' when socket returns None.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|usedns": (0, None)} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.use_dns == "warn" + + async def test_prefregex_populated(self) -> None: + """get_jail_config returns prefregex from the socket response.""" + responses = { + **_DEFAULT_JAIL_RESPONSES, + "get|sshd|prefregex": (0, r"^%(__prefix_line)s"), + } + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.prefregex == r"^%(__prefix_line)s" + + async def test_prefregex_empty_when_missing(self) -> None: + """get_jail_config returns empty string prefregex when socket returns None.""" + responses = {**_DEFAULT_JAIL_RESPONSES, "get|sshd|prefregex": (0, None)} + with _patch_client(responses): + result = await config_service.get_jail_config(_SOCKET, "sshd") + + assert result.jail.prefregex == "" + # --------------------------------------------------------------------------- # list_jail_configs @@ -275,6 +312,88 @@ class TestUpdateJailConfig: assert add_cmd is not None assert add_cmd[3] == "new_pattern" + async def test_sets_dns_mode(self) -> None: + """update_jail_config sends 'set usedns' for dns_mode.""" + from app.models.config import JailConfigUpdate + + sent_commands: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent_commands.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = JailConfigUpdate(dns_mode="no") + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + usedns_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "usedns"), + None, + ) + assert usedns_cmd is not None + assert usedns_cmd[3] == "no" + + async def test_sets_prefregex(self) -> None: + """update_jail_config sends 'set prefregex' for prefregex.""" + from app.models.config import JailConfigUpdate + + sent_commands: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent_commands.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = JailConfigUpdate(prefregex=r"^%(__prefix_line)s") + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + prefregex_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "prefregex"), + None, + ) + assert prefregex_cmd is not None + assert prefregex_cmd[3] == r"^%(__prefix_line)s" + + async def test_skips_none_prefregex(self) -> None: + """update_jail_config does not send prefregex command when field is None.""" + from app.models.config import JailConfigUpdate + + sent_commands: list[list[Any]] = [] + + async def _send(command: list[Any]) -> Any: + sent_commands.append(command) + return (0, "OK") + + class _FakeClient: + def __init__(self, **_kw: Any) -> None: + self.send = AsyncMock(side_effect=_send) + + update = JailConfigUpdate(prefregex=None) + with patch("app.services.config_service.Fail2BanClient", _FakeClient): + await config_service.update_jail_config(_SOCKET, "sshd", update) + + prefregex_cmd = next( + (c for c in sent_commands if len(c) >= 4 and c[2] == "prefregex"), + None, + ) + assert prefregex_cmd is None + + async def test_raises_validation_error_on_invalid_prefregex(self) -> None: + """update_jail_config raises ConfigValidationError for an invalid prefregex.""" + from app.models.config import JailConfigUpdate + + update = JailConfigUpdate(prefregex="[invalid") + with pytest.raises(ConfigValidationError, match="prefregex"): + await config_service.update_jail_config(_SOCKET, "sshd", update) + # --------------------------------------------------------------------------- # get_global_config diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx index ae16b11..849935c 100644 --- a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -116,6 +116,8 @@ const MOCK_JAIL: JailConfig = { date_pattern: null, log_encoding: "UTF-8", backend: "auto", + use_dns: "warn", + prefregex: "", actions: [], bantime_escalation: null, }; diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index ff24610..970a279 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -254,6 +254,9 @@ function JailAccordionPanel({ const [failRegex, setFailRegex] = useState(jail.fail_regex); const [ignoreRegex, setIgnoreRegex] = useState(jail.ignore_regex); const [logPaths, setLogPaths] = useState(jail.log_paths); + const [datePattern, setDatePattern] = useState(jail.date_pattern ?? ""); + const [dnsMode, setDnsMode] = useState(jail.use_dns); + const [prefRegex, setPrefRegex] = useState(jail.prefregex); const [deletingPath, setDeletingPath] = useState(null); const [newLogPath, setNewLogPath] = useState(""); const [newLogPathTail, setNewLogPathTail] = useState(true); @@ -331,6 +334,9 @@ function JailAccordionPanel({ max_retry: Number(maxRetry) || jail.max_retry, fail_regex: failRegex, ignore_regex: ignoreRegex, + date_pattern: datePattern !== "" ? datePattern : null, + dns_mode: dnsMode, + prefregex: prefRegex !== "" ? prefRegex : null, bantime_escalation: escalation, }); setMsg({ text: "Saved.", ok: true }); @@ -346,6 +352,9 @@ function JailAccordionPanel({ maxRetry, failRegex, ignoreRegex, + datePattern, + dnsMode, + prefRegex, escEnabled, escFactor, escFormula, @@ -404,6 +413,41 @@ function JailAccordionPanel({ +
+ + { + setDatePattern(d.value); + }} + /> + + + + +
+ + { + setPrefRegex(d.value); + }} + /> + {logPaths.length === 0 ? ( diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 92e833f..5404328 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -50,6 +50,10 @@ export interface JailConfig { date_pattern: string | null; log_encoding: string; backend: string; + /** DNS look-up mode reported by fail2ban: "yes" | "warn" | "no" | "raw". */ + use_dns: string; + /** Prefix regex prepended to every failregex; empty string means disabled. */ + prefregex: string; actions: string[]; bantime_escalation: BantimeEscalation | null; } @@ -69,6 +73,8 @@ export interface JailConfigUpdate { find_time?: number | null; fail_regex?: string[] | null; ignore_regex?: string[] | null; + /** Prefix regex; undefined/null = skip, "" = clear, non-empty = set. */ + prefregex?: string | null; date_pattern?: string | null; dns_mode?: string | null; enabled?: boolean | null;