Expose usedns, date_pattern, and prefregex in jail config UI

- Add use_dns and prefregex fields to JailConfig model (backend + frontend types)
- Add prefregex to JailConfigUpdate; validate as regex before writing
- Fetch usedns and prefregex in get_jail_config via asyncio.gather
- Write usedns and prefregex in update_jail_config
- ConfigPage JailAccordionPanel: editable date_pattern input, dns_mode
  Select dropdown (yes/warn/no/raw), and prefregex input
- 8 new service unit tests + 3 new router integration tests
- 628 tests pass; 85% line coverage; ruff/mypy/tsc/eslint clean
This commit is contained in:
2026-03-12 21:00:51 +01:00
parent e3375fd187
commit d0b8b78d12
8 changed files with 298 additions and 1 deletions

View File

@@ -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,

View File

@@ -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")

View File

@@ -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

View File

@@ -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 <jail> 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 <jail> 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