feat: Task 3 — invalid jail config recovery (pre-validation, crash detection, rollback)
- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
+ 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
frontend tests; fix mock setup in existing activate_jail tests
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
Request, response, and domain models for the config router and service.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -860,6 +862,102 @@ class JailActivationResponse(BaseModel):
|
||||
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
|
||||
)
|
||||
message: str = Field(..., description="Human-readable result message.")
|
||||
fail2ban_running: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Whether the fail2ban daemon is still running after the activation "
|
||||
"and reload. ``False`` signals that the daemon may have crashed."
|
||||
),
|
||||
)
|
||||
validation_warnings: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Non-fatal warnings from the pre-activation validation step.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail validation models (Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JailValidationIssue(BaseModel):
|
||||
"""A single issue found during pre-activation validation of a jail config."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
field: str = Field(
|
||||
...,
|
||||
description="Config field associated with this issue, e.g. 'filter', 'failregex', 'logpath'.",
|
||||
)
|
||||
message: str = Field(..., description="Human-readable description of the issue.")
|
||||
|
||||
|
||||
class JailValidationResult(BaseModel):
|
||||
"""Result of pre-activation validation of a single jail configuration."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
jail_name: str = Field(..., description="Name of the validated jail.")
|
||||
valid: bool = Field(..., description="True when no issues were found.")
|
||||
issues: list[JailValidationIssue] = Field(
|
||||
default_factory=list,
|
||||
description="Validation issues found. Empty when valid=True.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback response model (Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RollbackResponse(BaseModel):
|
||||
"""Response for ``POST /api/config/jails/{name}/rollback``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
jail_name: str = Field(..., description="Name of the jail that was disabled.")
|
||||
disabled: bool = Field(
|
||||
...,
|
||||
description="Whether the jail's .local override was successfully written with enabled=false.",
|
||||
)
|
||||
fail2ban_running: bool = Field(
|
||||
...,
|
||||
description="Whether fail2ban is online after the rollback attempt.",
|
||||
)
|
||||
active_jails: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
description="Number of currently active jails after a successful restart.",
|
||||
)
|
||||
message: str = Field(..., description="Human-readable result message.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending recovery model (Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PendingRecovery(BaseModel):
|
||||
"""Records a probable activation-caused fail2ban crash pending user action."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
jail_name: str = Field(
|
||||
...,
|
||||
description="Name of the jail whose activation likely caused the crash.",
|
||||
)
|
||||
activated_at: datetime.datetime = Field(
|
||||
...,
|
||||
description="ISO-8601 UTC timestamp of when the jail was activated.",
|
||||
)
|
||||
detected_at: datetime.datetime = Field(
|
||||
...,
|
||||
description="ISO-8601 UTC timestamp of when the crash was detected.",
|
||||
)
|
||||
recovered: bool = Field(
|
||||
default=False,
|
||||
description="Whether fail2ban has been successfully restarted.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user