feat(stage-1): inactive jail discovery and activation

- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
  following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
  POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
  to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
2026-03-13 15:44:36 +01:00
parent a344f1035b
commit 8d9d63b866
15 changed files with 2711 additions and 182 deletions

View File

@@ -447,3 +447,119 @@ class JailFileConfigUpdate(BaseModel):
default=None,
description="Jail section updates. Only jails present in this dict are updated.",
)
# ---------------------------------------------------------------------------
# Inactive jail models (Stage 1)
# ---------------------------------------------------------------------------
class InactiveJail(BaseModel):
"""A jail defined in fail2ban config files that is not currently active.
A jail is considered inactive when its ``enabled`` key is ``false`` (or
absent from the config, since fail2ban defaults to disabled) **or** when it
is explicitly enabled in config but fail2ban is not reporting it as
running.
"""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name from the config section header.")
filter: str = Field(
...,
description=(
"Filter name used by this jail. May include fail2ban mode suffix, "
"e.g. ``sshd[mode=normal]``."
),
)
actions: list[str] = Field(
default_factory=list,
description="Action references listed in the config (raw strings).",
)
port: str | None = Field(
default=None,
description="Port(s) to monitor, e.g. ``ssh`` or ``22,2222``.",
)
logpath: list[str] = Field(
default_factory=list,
description="Log file paths to monitor.",
)
bantime: str | None = Field(
default=None,
description="Ban duration as a raw config string, e.g. ``10m`` or ``-1``.",
)
findtime: str | None = Field(
default=None,
description="Failure-counting window as a raw config string, e.g. ``10m``.",
)
maxretry: int | None = Field(
default=None,
description="Number of failures before a ban is issued.",
)
source_file: str = Field(
...,
description="Absolute path to the config file where this jail is defined.",
)
enabled: bool = Field(
...,
description=(
"Effective ``enabled`` value from the merged config. ``False`` for "
"inactive jails that appear in this list."
),
)
class InactiveJailListResponse(BaseModel):
"""Response for ``GET /api/config/jails/inactive``."""
model_config = ConfigDict(strict=True)
jails: list[InactiveJail] = Field(default_factory=list)
total: int = Field(..., ge=0)
class ActivateJailRequest(BaseModel):
"""Optional override values when activating an inactive jail.
All fields are optional. Omitted fields are not written to the
``.local`` override file so that fail2ban falls back to its default
values.
"""
model_config = ConfigDict(strict=True)
bantime: str | None = Field(
default=None,
description="Override ban duration, e.g. ``1h`` or ``3600``.",
)
findtime: str | None = Field(
default=None,
description="Override failure-counting window, e.g. ``10m``.",
)
maxretry: int | None = Field(
default=None,
ge=1,
description="Override maximum failures before a ban.",
)
port: str | None = Field(
default=None,
description="Override port(s) to monitor.",
)
logpath: list[str] | None = Field(
default=None,
description="Override log file paths.",
)
class JailActivationResponse(BaseModel):
"""Response for jail activation and deactivation endpoints."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Name of the affected jail.")
active: bool = Field(
...,
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
)
message: str = Field(..., description="Human-readable result message.")