fix: reload/stop jail 404 + access list simulator

Task 1 — fix Stop/Reload Jail returning 404
  Root cause: reload_jail and reload_all sent an empty config stream
  (["reload", name, [], []]).  In fail2ban's reload protocol the end-of-
  reload phase deletes every jail still in reload_state — i.e. every jail
  that received no configuration commands.  An empty stream means *all*
  affected jails are silently removed from the daemon's runtime, causing
  everything touching those jails afterwards (including stop) to receive
  UnknownJailException → HTTP 404.

  Fixes:
  - reload_jail: send ["start", name] in the config stream; startJail()
    removes the jail from reload_state so the end phase commits instead of
    deletes, and un-idles the jail.
  - reload_all: fetch current jail list first, build a ["start", name]
    entry for every active jail, then send reload --all with that stream.
  - stop_jail: made idempotent — if the jail is already gone (not-found
    error) the operation silently succeeds (200 OK) rather than returning
    404, matching the user expectation that stop = ensure-stopped.
  - Router: removed dead JailNotFoundError handler from stop endpoint.

  391 tests pass (2 new), ruff clean, mypy clean (pre-existing
  config.py error unchanged).

Task 2 — access list simulator
  - Docker/simulate_accesses.sh: writes fake HTTP-scan log lines in
    custom format (bangui-access: http scan from <IP> ...) to
    Docker/logs/access.log so the bangui-access jail detects them.
  - fail2ban/filter.d/bangui-access.conf: failregex matching the above.
  - fail2ban/jail.d/bangui-access.conf: polling jail on access.log,
    same settings as bangui-sim (maxretry=3, bantime=60s).
  - .gitignore: whitelist new bangui-access.conf files.
  - Docker/fail2ban-dev-config/README.md: added "Testing the Access
    List Feature" section with step-by-step instructions and updated
    Configuration Reference + Troubleshooting.
This commit is contained in:
2026-03-06 19:49:31 +01:00
parent 73c1300d9f
commit 08b8f3872a
10 changed files with 239 additions and 965 deletions

View File

@@ -248,7 +248,8 @@ async def stop_jail(
"""Stop a running fail2ban jail.
The jail will no longer monitor logs or issue new bans. Existing bans
may or may not be removed depending on fail2ban configuration.
may or may not be removed depending on fail2ban configuration. If the
jail is already stopped the request succeeds silently (idempotent).
Args:
request: Incoming request (used to access ``app.state``).
@@ -259,7 +260,6 @@ async def stop_jail(
:class:`~app.models.jail.JailCommandResponse` confirming the stop.
Raises:
HTTPException: 404 when the jail does not exist.
HTTPException: 409 when fail2ban reports the operation failed.
HTTPException: 502 when fail2ban is unreachable.
"""
@@ -267,8 +267,6 @@ async def stop_jail(
try:
await jail_service.stop_jail(socket_path, name)
return JailCommandResponse(message=f"Jail {name!r} stopped.", jail=name)
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:

View File

@@ -431,12 +431,14 @@ async def start_jail(socket_path: str, name: str) -> None:
async def stop_jail(socket_path: str, name: str) -> None:
"""Stop a running fail2ban jail.
If the jail is not currently active (already stopped), this is a no-op
and no error is raised — the operation is idempotent.
Args:
socket_path: Path to the fail2ban Unix domain socket.
name: Jail name to stop.
Raises:
JailNotFoundError: If *name* is not a known jail.
JailOperationError: If fail2ban reports the operation failed.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
@@ -447,7 +449,9 @@ async def stop_jail(socket_path: str, name: str) -> None:
log.info("jail_stopped", jail=name)
except ValueError as exc:
if _is_not_found_error(exc):
raise JailNotFoundError(name) from exc
# Jail is already stopped or was never running — treat as a no-op.
log.info("jail_stop_noop", jail=name)
return
raise JailOperationError(str(exc)) from exc
@@ -482,6 +486,13 @@ async def set_idle(socket_path: str, name: str, *, on: bool) -> None:
async def reload_jail(socket_path: str, name: str) -> None:
"""Reload a single fail2ban jail to pick up configuration changes.
The reload protocol requires a non-empty configuration stream. Without
one, fail2ban's end-of-reload phase removes every jail that received no
configuration commands — permanently deleting the jail from the running
daemon. Sending ``['start', name]`` as the minimal stream is sufficient:
``startJail`` removes the jail from ``reload_state``, which causes the
end phase to *commit* the reload instead of deleting the jail.
Args:
socket_path: Path to the fail2ban Unix domain socket.
name: Jail name to reload.
@@ -494,7 +505,7 @@ async def reload_jail(socket_path: str, name: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["reload", name, [], []]))
_ok(await client.send(["reload", name, [], [["start", name]]]))
log.info("jail_reloaded", jail=name)
except ValueError as exc:
if _is_not_found_error(exc):
@@ -505,6 +516,11 @@ async def reload_jail(socket_path: str, name: str) -> None:
async def reload_all(socket_path: str) -> None:
"""Reload all fail2ban jails at once.
Fetches the current jail list first so that a ``['start', name]`` entry
can be included in the config stream for every active jail. Without a
non-empty stream the end-of-reload phase deletes every jail that received
no configuration commands.
Args:
socket_path: Path to the fail2ban Unix domain socket.
@@ -515,7 +531,13 @@ async def reload_all(socket_path: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["reload", "--all", [], []]))
# Resolve jail names so we can build the minimal config stream.
status_raw = _ok(await client.send(["status"]))
status_dict = _to_dict(status_raw)
jail_list_raw: str = str(status_dict.get("Jail list", ""))
jail_names = [n.strip() for n in jail_list_raw.split(",") if n.strip()]
stream: list[list[str]] = [["start", n] for n in jail_names]
_ok(await client.send(["reload", "--all", [], stream]))
log.info("all_jails_reloaded")
except ValueError as exc:
raise JailOperationError(str(exc)) from exc

View File

@@ -257,17 +257,19 @@ class TestStopJail:
assert resp.status_code == 200
async def test_404_for_unknown_jail(self, jails_client: AsyncClient) -> None:
"""POST /api/jails/ghost/stop returns 404."""
from app.services.jail_service import JailNotFoundError
async def test_200_for_already_stopped_jail(self, jails_client: AsyncClient) -> None:
"""POST /api/jails/sshd/stop returns 200 even when the jail is already stopped.
stop_jail is idempotent — service returns None rather than raising
JailNotFoundError when the jail is not present in fail2ban's runtime.
"""
with patch(
"app.routers.jails.jail_service.stop_jail",
AsyncMock(side_effect=JailNotFoundError("ghost")),
AsyncMock(return_value=None),
):
resp = await jails_client.post("/api/jails/ghost/stop")
resp = await jails_client.post("/api/jails/sshd/stop")
assert resp.status_code == 404
assert resp.status_code == 200
# ---------------------------------------------------------------------------

View File

@@ -283,13 +283,28 @@ class TestJailControls:
await jail_service.set_idle(_SOCKET, "sshd", on=False) # should not raise
async def test_reload_jail_success(self) -> None:
"""reload_jail sends the reload command without error."""
with _patch_client({"reload|sshd|[]|[]": (0, "OK")}):
"""reload_jail sends a reload command with a minimal start-stream."""
with _patch_client({"reload|sshd|[]|[['start', 'sshd']]": (0, "OK")}):
await jail_service.reload_jail(_SOCKET, "sshd") # should not raise
async def test_reload_all_success(self) -> None:
"""reload_all sends the reload --all command without error."""
with _patch_client({"reload|--all|[]|[]": (0, "OK")}):
"""reload_all fetches jail names then sends reload --all with a start-stream."""
with _patch_client(
{
"status": _make_global_status("sshd, nginx"),
"reload|--all|[]|[['start', 'sshd'], ['start', 'nginx']]": (0, "OK"),
}
):
await jail_service.reload_all(_SOCKET) # should not raise
async def test_reload_all_no_jails_still_sends_reload(self) -> None:
"""reload_all works with an empty jail list (sends an empty stream)."""
with _patch_client(
{
"status": (0, [("Number of jail", 0), ("Jail list", "")]),
"reload|--all|[]|[]": (0, "OK"),
}
):
await jail_service.reload_all(_SOCKET) # should not raise
async def test_start_not_found_raises(self) -> None:
@@ -297,8 +312,13 @@ class TestJailControls:
with _patch_client({"start|ghost": (1, Exception("Unknown jail: 'ghost'"))}), pytest.raises(JailNotFoundError):
await jail_service.start_jail(_SOCKET, "ghost")
async def test_stop_jail_already_stopped_is_noop(self) -> None:
"""stop_jail silently succeeds when the jail is not found (idempotent)."""
with _patch_client({"stop|sshd": (1, Exception("UnknownJailException('sshd')"))}):
await jail_service.stop_jail(_SOCKET, "sshd") # should not raise
async def test_stop_operation_error_raises(self) -> None:
"""stop_jail raises JailOperationError on fail2ban error code."""
"""stop_jail raises JailOperationError on a non-not-found fail2ban error."""
with _patch_client({"stop|sshd": (1, Exception("cannot stop"))}), pytest.raises(JailOperationError):
await jail_service.stop_jail(_SOCKET, "sshd")