Config page tasks 1-4: dropdowns, key props, inactive jail full GUI, banaction fix

Task 1: Backend/LogEncoding/DatePattern dropdowns in JailConfigDetail
- Added BACKENDS, LOG_ENCODINGS, DATE_PATTERN_PRESETS constants
- Backend and Log Encoding: <Input readOnly> → <Select> (editable, auto-saves)
- Date Pattern: <Input> → <Combobox freeform> with presets
- Extended JailConfigUpdate model (backend, log_encoding) and service
- Added readOnly prop to JailConfigDetail (all fields, toggles, buttons)
- Extended RegexList with readOnly prop

Task 2: Fix raw action/filter config always blank
- Added key={selectedAction.name} to ActionDetail in ActionsTab
- Added key={selectedFilter.name} to FilterDetail in FiltersTab

Task 3: Inactive jail full GUI same as active jails
- Extended InactiveJail Pydantic model with all config fields
- Added _parse_time_to_seconds helper to config_file_service
- Updated _build_inactive_jail to populate all extended fields
- Extended InactiveJail TypeScript type to match
- Rewrote InactiveJailDetail to reuse JailConfigDetail (readOnly=true)

Task 4: Fix banaction interpolation error when activating jails
- _write_local_override_sync now includes banaction=iptables-multiport
  and banaction_allports=iptables-allports in every .local file
This commit is contained in:
2026-03-14 09:28:30 +01:00
parent 201cca8b66
commit c110352e9e
9 changed files with 541 additions and 246 deletions

View File

@@ -118,6 +118,8 @@ class JailConfigUpdate(BaseModel):
prefregex: str | None = Field(default=None, description="Prefix regex; None = skip, '' = clear, non-empty = set.")
date_pattern: str | None = Field(default=None)
dns_mode: str | None = Field(default=None, description="DNS lookup mode: yes | warn | no | raw.")
backend: str | None = Field(default=None, description="Log monitoring backend.")
log_encoding: str | None = Field(default=None, description="Log file encoding.")
enabled: bool | None = Field(default=None)
bantime_escalation: BantimeEscalationUpdate | None = Field(
default=None,
@@ -751,6 +753,47 @@ class InactiveJail(BaseModel):
default=None,
description="Number of failures before a ban is issued.",
)
# ---- Extended fields for full GUI display ----
ban_time_seconds: int = Field(
default=600,
description="Ban duration in seconds, parsed from bantime string.",
)
find_time_seconds: int = Field(
default=600,
description="Failure-counting window in seconds, parsed from findtime string.",
)
log_encoding: str = Field(
default="auto",
description="Log encoding, e.g. ``utf-8`` or ``auto``.",
)
backend: str = Field(
default="auto",
description="Log-monitoring backend, e.g. ``auto``, ``pyinotify``, ``polling``.",
)
date_pattern: str | None = Field(
default=None,
description="Date pattern for log parsing, or None for auto-detect.",
)
use_dns: str = Field(
default="warn",
description="DNS resolution mode: ``yes``, ``warn``, ``no``, or ``raw``.",
)
prefregex: str = Field(
default="",
description="Prefix regex prepended to every failregex.",
)
fail_regex: list[str] = Field(
default_factory=list,
description="List of failure regex patterns.",
)
ignore_regex: list[str] = Field(
default_factory=list,
description="List of ignore regex patterns.",
)
bantime_escalation: BantimeEscalation | None = Field(
default=None,
description="Ban-time escalation configuration, if enabled.",
)
source_file: str = Field(
...,
description="Absolute path to the config file where this jail is defined.",

View File

@@ -41,6 +41,7 @@ from app.models.config import (
ActivateJailRequest,
AssignActionRequest,
AssignFilterRequest,
BantimeEscalation,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
@@ -290,6 +291,44 @@ def _parse_int_safe(value: str) -> int | None:
return None
def _parse_time_to_seconds(value: str | None, default: int) -> int:
"""Convert a fail2ban time string (e.g. ``1h``, ``10m``, ``3600``) to seconds.
Supports the suffixes ``s`` (seconds), ``m`` (minutes), ``h`` (hours),
``d`` (days), ``w`` (weeks), and plain integers (already seconds).
``-1`` is treated as a permanent ban and returned as-is.
Args:
value: Raw time string from config, or ``None``.
default: Value to return when ``value`` is absent or unparseable.
Returns:
Duration in seconds, or ``-1`` for permanent, or ``default`` on failure.
"""
if not value:
return default
stripped = value.strip()
if stripped == "-1":
return -1
multipliers: dict[str, int] = {
"w": 604800,
"d": 86400,
"h": 3600,
"m": 60,
"s": 1,
}
for suffix, factor in multipliers.items():
if stripped.endswith(suffix) and len(stripped) > 1:
try:
return int(stripped[:-1]) * factor
except ValueError:
return default
try:
return int(stripped)
except ValueError:
return default
def _parse_multiline(raw: str) -> list[str]:
"""Split a multi-line INI value into individual non-blank lines.
@@ -413,6 +452,42 @@ def _build_inactive_jail(
maxretry_raw = settings.get("maxretry", "")
maxretry = _parse_int_safe(maxretry_raw)
# Extended fields for full GUI display
ban_time_seconds = _parse_time_to_seconds(settings.get("bantime"), 600)
find_time_seconds = _parse_time_to_seconds(settings.get("findtime"), 600)
log_encoding = settings.get("logencoding") or "auto"
backend = settings.get("backend") or "auto"
date_pattern = settings.get("datepattern") or None
use_dns = settings.get("usedns") or "warn"
prefregex = settings.get("prefregex") or ""
fail_regex = _parse_multiline(settings.get("failregex", ""))
ignore_regex = _parse_multiline(settings.get("ignoreregex", ""))
# Ban-time escalation
esc_increment = _is_truthy(settings.get("bantime.increment", "false"))
esc_factor_raw = settings.get("bantime.factor")
esc_factor = float(esc_factor_raw) if esc_factor_raw else None
esc_formula = settings.get("bantime.formula") or None
esc_multipliers = settings.get("bantime.multipliers") or None
esc_max_raw = settings.get("bantime.maxtime")
esc_max_time = _parse_time_to_seconds(esc_max_raw, 0) if esc_max_raw else None
esc_rnd_raw = settings.get("bantime.rndtime")
esc_rnd_time = _parse_time_to_seconds(esc_rnd_raw, 0) if esc_rnd_raw else None
esc_overall = _is_truthy(settings.get("bantime.overalljails", "false"))
bantime_escalation = (
BantimeEscalation(
increment=esc_increment,
factor=esc_factor,
formula=esc_formula,
multipliers=esc_multipliers,
max_time=esc_max_time,
rnd_time=esc_rnd_time,
overall_jails=esc_overall,
)
if esc_increment
else None
)
return InactiveJail(
name=name,
filter=filter_name,
@@ -422,6 +497,16 @@ def _build_inactive_jail(
bantime=settings.get("bantime") or None,
findtime=settings.get("findtime") or None,
maxretry=maxretry,
ban_time_seconds=ban_time_seconds,
find_time_seconds=find_time_seconds,
log_encoding=log_encoding,
backend=backend,
date_pattern=date_pattern,
use_dns=use_dns,
prefregex=prefregex,
fail_regex=fail_regex,
ignore_regex=ignore_regex,
bantime_escalation=bantime_escalation,
source_file=source_file,
enabled=enabled,
)
@@ -513,6 +598,10 @@ def _write_local_override_sync(
f"[{jail_name}]",
"",
f"enabled = {'true' if enabled else 'false'}",
# Provide explicit banaction defaults so fail2ban can resolve the
# %(banaction)s interpolation used in the built-in action_ chain.
"banaction = iptables-multiport",
"banaction_allports = iptables-allports",
]
if overrides.get("bantime") is not None:

View File

@@ -366,6 +366,10 @@ 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.backend is not None:
await _set("backend", update.backend)
if update.log_encoding is not None:
await _set("logencoding", update.log_encoding)
if update.prefregex is not None:
await _set("prefregex", update.prefregex)
if update.enabled is not None: