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:
@@ -443,6 +443,10 @@ class TestActivateJail:
|
||||
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
|
||||
@@ -494,9 +498,13 @@ class TestActivateJail:
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
new=AsyncMock(side_effect=[set(), set()]),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
|
||||
@@ -2513,6 +2521,10 @@ class TestActivateJailReloadArgs:
|
||||
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
|
||||
@@ -2535,6 +2547,10 @@ class TestActivateJailReloadArgs:
|
||||
new=AsyncMock(side_effect=[set(), {"apache-auth"}]),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
result = await activate_jail(
|
||||
@@ -2558,12 +2574,17 @@ class TestActivateJailReloadArgs:
|
||||
|
||||
req = ActivateJailRequest()
|
||||
# Pre-reload: jail not running. Post-reload: still not running (boot failed).
|
||||
# fail2ban is up (probe succeeds) but the jail didn't appear.
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(side_effect=[set(), set()]),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
patch(
|
||||
"app.services.config_file_service._probe_fail2ban_running",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
result = await activate_jail(
|
||||
@@ -2600,3 +2621,212 @@ class TestDeactivateJailReloadArgs:
|
||||
"/fake.sock", exclude_jails=["sshd"]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_jail_config_sync (Task 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.services.config_file_service import ( # noqa: E402 (added after block)
|
||||
_validate_jail_config_sync,
|
||||
_extract_filter_base_name,
|
||||
_extract_action_base_name,
|
||||
validate_jail_config,
|
||||
rollback_jail,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractFilterBaseName:
|
||||
def test_plain_name(self) -> None:
|
||||
assert _extract_filter_base_name("sshd") == "sshd"
|
||||
|
||||
def test_strips_mode_suffix(self) -> None:
|
||||
assert _extract_filter_base_name("sshd[mode=aggressive]") == "sshd"
|
||||
|
||||
def test_strips_whitespace(self) -> None:
|
||||
assert _extract_filter_base_name(" nginx ") == "nginx"
|
||||
|
||||
|
||||
class TestExtractActionBaseName:
|
||||
def test_plain_name(self) -> None:
|
||||
assert _extract_action_base_name("iptables-multiport") == "iptables-multiport"
|
||||
|
||||
def test_strips_option_suffix(self) -> None:
|
||||
assert _extract_action_base_name("iptables[name=SSH]") == "iptables"
|
||||
|
||||
def test_returns_none_for_variable_interpolation(self) -> None:
|
||||
assert _extract_action_base_name("%(action_)s") is None
|
||||
|
||||
def test_returns_none_for_dollar_variable(self) -> None:
|
||||
assert _extract_action_base_name("${action}") is None
|
||||
|
||||
|
||||
class TestValidateJailConfigSync:
|
||||
"""Tests for _validate_jail_config_sync — the sync validation core."""
|
||||
|
||||
def _setup_config(self, config_dir: Path, jail_cfg: str) -> None:
|
||||
"""Write a minimal fail2ban directory layout with *jail_cfg* content."""
|
||||
_write(config_dir / "jail.d" / "test.conf", jail_cfg)
|
||||
|
||||
def test_valid_config_no_issues(self, tmp_path: Path) -> None:
|
||||
"""A jail whose filter exists and has a valid regex should pass."""
|
||||
# Create a real filter file so the existence check passes.
|
||||
filter_d = tmp_path / "filter.d"
|
||||
filter_d.mkdir(parents=True, exist_ok=True)
|
||||
(filter_d / "sshd.conf").write_text("[Definition]\nfailregex = Host .* <HOST>\n")
|
||||
|
||||
self._setup_config(
|
||||
tmp_path,
|
||||
"[sshd]\nenabled = false\nfilter = sshd\nlogpath = /no/such/log\n",
|
||||
)
|
||||
|
||||
result = _validate_jail_config_sync(tmp_path, "sshd")
|
||||
# logpath advisory warning is OK; no blocking errors expected.
|
||||
blocking = [i for i in result.issues if i.field != "logpath"]
|
||||
assert blocking == [], blocking
|
||||
|
||||
def test_missing_filter_reported(self, tmp_path: Path) -> None:
|
||||
"""A jail whose filter file does not exist must report a filter issue."""
|
||||
self._setup_config(
|
||||
tmp_path,
|
||||
"[bad-jail]\nenabled = false\nfilter = nonexistent-filter\n",
|
||||
)
|
||||
|
||||
result = _validate_jail_config_sync(tmp_path, "bad-jail")
|
||||
assert not result.valid
|
||||
fields = [i.field for i in result.issues]
|
||||
assert "filter" in fields
|
||||
|
||||
def test_bad_failregex_reported(self, tmp_path: Path) -> None:
|
||||
"""A jail with an un-compilable failregex must report a failregex issue."""
|
||||
self._setup_config(
|
||||
tmp_path,
|
||||
"[broken]\nenabled = false\nfailregex = [invalid regex(\n",
|
||||
)
|
||||
|
||||
result = _validate_jail_config_sync(tmp_path, "broken")
|
||||
assert not result.valid
|
||||
fields = [i.field for i in result.issues]
|
||||
assert "failregex" in fields
|
||||
|
||||
def test_missing_log_path_is_advisory(self, tmp_path: Path) -> None:
|
||||
"""A missing log path should be reported in the logpath field."""
|
||||
self._setup_config(
|
||||
tmp_path,
|
||||
"[myjail]\nenabled = false\nlogpath = /no/such/path.log\n",
|
||||
)
|
||||
|
||||
result = _validate_jail_config_sync(tmp_path, "myjail")
|
||||
fields = [i.field for i in result.issues]
|
||||
assert "logpath" in fields
|
||||
|
||||
def test_missing_action_reported(self, tmp_path: Path) -> None:
|
||||
"""A jail referencing a non-existent action file must report an action issue."""
|
||||
self._setup_config(
|
||||
tmp_path,
|
||||
"[myjail]\nenabled = false\naction = nonexistent-action\n",
|
||||
)
|
||||
|
||||
result = _validate_jail_config_sync(tmp_path, "myjail")
|
||||
fields = [i.field for i in result.issues]
|
||||
assert "action" in fields
|
||||
|
||||
def test_unknown_jail_name(self, tmp_path: Path) -> None:
|
||||
"""Requesting validation for a jail not in any config returns an invalid result."""
|
||||
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _validate_jail_config_sync(tmp_path, "ghost")
|
||||
assert not result.valid
|
||||
assert any(i.field == "name" for i in result.issues)
|
||||
|
||||
def test_variable_action_not_flagged(self, tmp_path: Path) -> None:
|
||||
"""An action like ``%(action_)s`` should not be checked for file existence."""
|
||||
self._setup_config(
|
||||
tmp_path,
|
||||
"[myjail]\nenabled = false\naction = %(action_)s\n",
|
||||
)
|
||||
result = _validate_jail_config_sync(tmp_path, "myjail")
|
||||
# Ensure no action file-missing error (the variable expression is skipped).
|
||||
action_errors = [i for i in result.issues if i.field == "action"]
|
||||
assert action_errors == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestValidateJailConfigAsync:
|
||||
"""Tests for the public async wrapper validate_jail_config."""
|
||||
|
||||
async def test_returns_jail_validation_result(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
|
||||
_write(
|
||||
tmp_path / "jail.d" / "test.conf",
|
||||
"[testjail]\nenabled = false\n",
|
||||
)
|
||||
|
||||
result = await validate_jail_config(str(tmp_path), "testjail")
|
||||
assert result.jail_name == "testjail"
|
||||
|
||||
async def test_rejects_unsafe_name(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
await validate_jail_config(str(tmp_path), "../evil")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRollbackJail:
|
||||
"""Tests for rollback_jail (Task 3)."""
|
||||
|
||||
async def test_rollback_success(self, tmp_path: Path) -> None:
|
||||
"""When fail2ban comes back online, rollback returns fail2ban_running=True."""
|
||||
_write(tmp_path / "jail.d" / "sshd.conf", "[sshd]\nenabled = true\n")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._start_daemon",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._wait_for_fail2ban",
|
||||
new=AsyncMock(return_value=True),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
):
|
||||
result = await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
assert result.disabled is True
|
||||
assert result.fail2ban_running is True
|
||||
assert result.jail_name == "sshd"
|
||||
# .local file must have enabled=false
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
assert local.is_file()
|
||||
assert "enabled = false" in local.read_text()
|
||||
|
||||
async def test_rollback_fail2ban_still_down(self, tmp_path: Path) -> None:
|
||||
"""When fail2ban does not come back, rollback returns fail2ban_running=False."""
|
||||
_write(tmp_path / "jail.d" / "sshd.conf", "[sshd]\nenabled = true\n")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._start_daemon",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
patch(
|
||||
"app.services.config_file_service._wait_for_fail2ban",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
result = await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "sshd", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
assert result.fail2ban_running is False
|
||||
assert result.disabled is True
|
||||
|
||||
async def test_rollback_rejects_unsafe_name(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
await rollback_jail(
|
||||
str(tmp_path), "/fake.sock", "../evil", ["fail2ban-client", "start"]
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user