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

@@ -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