Add Deactivate Jail button for inactive jails with local override

- Add has_local_override field to InactiveJail model
- Update _build_inactive_jail and list_inactive_jails to compute the field
- Add delete_jail_local_override() service function
- Add DELETE /api/config/jails/{name}/local router endpoint
- Surface has_local_override in frontend InactiveJail type
- Show Deactivate Jail button in JailsTab when has_local_override is true
- Add tests: TestBuildInactiveJail, TestListInactiveJails, TestDeleteJailLocalOverride
This commit is contained in:
2026-03-15 13:41:00 +01:00
parent 93dc699825
commit d4d04491d2
9 changed files with 367 additions and 77 deletions

View File

@@ -807,6 +807,14 @@ class InactiveJail(BaseModel):
"inactive jails that appear in this list."
),
)
has_local_override: bool = Field(
default=False,
description=(
"``True`` when a ``jail.d/{name}.local`` file exists for this jail. "
"Only meaningful for inactive jails; indicates that a cleanup action "
"is available."
),
)
class InactiveJailListResponse(BaseModel):

View File

@@ -798,6 +798,60 @@ async def deactivate_jail(
return result
@router.delete(
"/jails/{name}/local",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete the jail.d override file for an inactive jail",
)
async def delete_jail_local_override(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> None:
"""Remove the ``jail.d/{name}.local`` override file for an inactive jail.
This endpoint is the clean-up action for inactive jails that still carry
a ``.local`` override file (e.g. one written with ``enabled = false`` by a
previous deactivation). The file is deleted without modifying fail2ban's
running state, since the jail is already inactive.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail whose ``.local`` file should be removed.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
HTTPException: 409 if the jail is currently active.
HTTPException: 500 if the file cannot be deleted.
HTTPException: 502 if fail2ban is unreachable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.delete_jail_local_override(
config_dir, socket_path, name
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except JailAlreadyActiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is currently active; deactivate it first.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Jail validation & rollback endpoints (Task 3)
# ---------------------------------------------------------------------------

View File

@@ -429,6 +429,7 @@ def _build_inactive_jail(
name: str,
settings: dict[str, str],
source_file: str,
config_dir: Path | None = None,
) -> InactiveJail:
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
@@ -436,6 +437,8 @@ def _build_inactive_jail(
name: Jail section name.
settings: Merged key→value dict (DEFAULT values already applied).
source_file: Path of the file that last defined this section.
config_dir: Absolute path to the fail2ban configuration directory, used
to check whether a ``jail.d/{name}.local`` override file exists.
Returns:
Populated :class:`~app.models.config.InactiveJail`.
@@ -513,6 +516,11 @@ def _build_inactive_jail(
bantime_escalation=bantime_escalation,
source_file=source_file,
enabled=enabled,
has_local_override=(
(config_dir / "jail.d" / f"{name}.local").is_file()
if config_dir is not None
else False
),
)
@@ -1111,7 +1119,7 @@ async def list_inactive_jails(
continue
source = source_files.get(jail_name, config_dir)
inactive.append(_build_inactive_jail(jail_name, settings, source))
inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir)))
log.info(
"inactive_jails_listed",
@@ -1469,6 +1477,57 @@ async def deactivate_jail(
)
async def delete_jail_local_override(
config_dir: str,
socket_path: str,
name: str,
) -> None:
"""Delete the ``jail.d/{name}.local`` override file for an inactive jail.
This is the clean-up action shown in the config UI when an inactive jail
still has a ``.local`` override file (e.g. ``enabled = false``). The
file is deleted outright; no fail2ban reload is required because the jail
is already inactive.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail whose ``.local`` file should be removed.
Raises:
JailNameError: If *name* contains invalid characters.
JailNotFoundInConfigError: If *name* is not defined in any config file.
JailAlreadyActiveError: If the jail is currently active (refusing to
delete the live config file).
ConfigWriteError: If the file cannot be deleted.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
if name in active_names:
raise JailAlreadyActiveError(name)
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
try:
await loop.run_in_executor(
None, lambda: local_path.unlink(missing_ok=True)
)
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path}: {exc}"
) from exc
log.info("jail_local_override_deleted", jail=name, path=str(local_path))
async def validate_jail_config(
config_dir: str,
name: str,

View File

@@ -290,6 +290,28 @@ class TestBuildInactiveJail:
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
assert jail.enabled is True
def test_has_local_override_absent(self, tmp_path: Path) -> None:
"""has_local_override is False when no .local file exists."""
jail = _build_inactive_jail(
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
)
assert jail.has_local_override is False
def test_has_local_override_present(self, tmp_path: Path) -> None:
"""has_local_override is True when jail.d/{name}.local exists."""
local = tmp_path / "jail.d" / "sshd.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[sshd]\nenabled = false\n")
jail = _build_inactive_jail(
"sshd", {}, "/etc/fail2ban/jail.d/sshd.conf", config_dir=tmp_path
)
assert jail.has_local_override is True
def test_has_local_override_no_config_dir(self) -> None:
"""has_local_override is False when config_dir is not provided."""
jail = _build_inactive_jail("sshd", {}, "/etc/fail2ban/jail.conf")
assert jail.has_local_override is False
# ---------------------------------------------------------------------------
# _write_local_override_sync
@@ -425,6 +447,121 @@ class TestListInactiveJails:
assert "sshd" in names
assert "apache-auth" in names
async def test_has_local_override_true_when_local_file_exists(
self, tmp_path: Path
) -> None:
"""has_local_override is True for a jail whose jail.d .local file exists."""
_write(tmp_path / "jail.conf", JAIL_CONF)
local = tmp_path / "jail.d" / "apache-auth.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[apache-auth]\nenabled = false\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
jail = next(j for j in result.jails if j.name == "apache-auth")
assert jail.has_local_override is True
async def test_has_local_override_false_when_no_local_file(
self, tmp_path: Path
) -> None:
"""has_local_override is False when no jail.d .local file exists."""
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
jail = next(j for j in result.jails if j.name == "apache-auth")
assert jail.has_local_override is False
# ---------------------------------------------------------------------------
# delete_jail_local_override
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDeleteJailLocalOverride:
"""Tests for :func:`~app.services.config_file_service.delete_jail_local_override`."""
async def test_deletes_local_file(self, tmp_path: Path) -> None:
"""delete_jail_local_override removes the jail.d/.local file."""
from app.services.config_file_service import delete_jail_local_override
_write(tmp_path / "jail.conf", JAIL_CONF)
local = tmp_path / "jail.d" / "apache-auth.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[apache-auth]\nenabled = false\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
assert not local.exists()
async def test_no_error_when_local_file_missing(self, tmp_path: Path) -> None:
"""delete_jail_local_override succeeds silently when no .local file exists."""
from app.services.config_file_service import delete_jail_local_override
_write(tmp_path / "jail.conf", JAIL_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
# Must not raise even though there is no .local file.
await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth")
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
"""delete_jail_local_override raises JailNotFoundInConfigError for unknown jail."""
from app.services.config_file_service import (
JailNotFoundInConfigError,
delete_jail_local_override,
)
_write(tmp_path / "jail.conf", JAIL_CONF)
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
),
pytest.raises(JailNotFoundInConfigError),
):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "nonexistent")
async def test_raises_jail_already_active(self, tmp_path: Path) -> None:
"""delete_jail_local_override raises JailAlreadyActiveError when jail is running."""
from app.services.config_file_service import (
JailAlreadyActiveError,
delete_jail_local_override,
)
_write(tmp_path / "jail.conf", JAIL_CONF)
local = tmp_path / "jail.d" / "sshd.local"
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text("[sshd]\nenabled = false\n")
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value={"sshd"}),
),
pytest.raises(JailAlreadyActiveError),
):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "sshd")
async def test_raises_jail_name_error(self, tmp_path: Path) -> None:
"""delete_jail_local_override raises JailNameError for invalid jail names."""
from app.services.config_file_service import (
JailNameError,
delete_jail_local_override,
)
with pytest.raises(JailNameError):
await delete_jail_local_override(str(tmp_path), "/fake.sock", "../evil")
# ---------------------------------------------------------------------------
# activate_jail
@@ -3174,6 +3311,64 @@ class TestActivateJailRollback:
# Verify the error message mentions logpath issues.
assert "logpath" in result.message.lower() or "check that all logpath" in result.message.lower()
async def test_activate_jail_rollback_deletes_file_when_no_prior_local(
self, tmp_path: Path
) -> None:
"""Rollback deletes the .local file when none existed before activation.
When a jail had no .local override before activation, activate_jail
creates one with enabled = true. If reload then crashes, rollback must
delete that file (leaving the jail in the same state as before the
activation attempt).
Expects:
- The .local file is absent after rollback.
- The response indicates recovered=True.
"""
from app.models.config import ActivateJailRequest, JailValidationResult
_write(tmp_path / "jail.conf", JAIL_CONF)
(tmp_path / "jail.d").mkdir(parents=True, exist_ok=True)
local_path = tmp_path / "jail.d" / "apache-auth.local"
# No .local file exists before activation.
assert not local_path.exists()
req = ActivateJailRequest()
reload_call_count = 0
async def reload_side_effect(socket_path: str, **kwargs: object) -> None:
nonlocal reload_call_count
reload_call_count += 1
if reload_call_count == 1:
raise RuntimeError("fail2ban crashed")
# Recovery reload succeeds.
with (
patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=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),
),
patch(
"app.services.config_file_service._validate_jail_config_sync",
return_value=JailValidationResult(
jail_name="apache-auth", valid=True
),
),
):
mock_js.reload_all = AsyncMock(side_effect=reload_side_effect)
result = await activate_jail(
str(tmp_path), "/fake.sock", "apache-auth", req
)
assert result.active is False
assert result.recovered is True
assert not local_path.exists()
# ---------------------------------------------------------------------------
# rollback_jail