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,