- jail_service.restart(): replace invalid ["restart"] socket command with
["stop"], matching fail2ban transmitter protocol. The daemon is now
stopped via socket; the caller starts it via subprocess.
- config_file_service: expose _start_daemon and _wait_for_fail2ban as
public start_daemon / wait_for_fail2ban functions.
- restart_fail2ban router: orchestrate stop (socket) → start (subprocess)
→ probe (socket). Returns 204 on success, 503 when fail2ban does not
come back within 10 s. Catches JailOperationError → 409.
- reload_fail2ban router: add JailOperationError catch → 409 Conflict,
consistent with other jail control endpoints.
- Tests: add TestJailControls.test_restart_* (3 cases), TestReloadFail2ban
502/409 cases, TestRestartFail2ban (5 cases), TestRollbackJail (6
integration tests verifying file-write, subprocess invocation, socket-
probe truthiness, active_jails count, and offline-at-call-time).
This commit implements fixes for three independent bugs in the fail2ban configuration and integration layer:
1. Task 1: Detect UnknownJailException and prevent silent failures
- Added JailNotFoundError detection in jail_service.reload_all()
- Enhanced error handling in config_file_service to catch JailNotFoundError
- Added specific error message with logpath validation hints
- Added rollback test for this scenario
2. Task 2: Fix iptables-allports exit code 4 (xtables lock contention)
- Added global banaction setting in jail.conf with -w 5 lockingopt
- Removed redundant per-jail banaction overrides from bangui-sim and blocklist-import
- Added production compose documentation note
3. Task 3: Suppress log noise from unsupported backend/idle commands
- Implemented capability detection to cache command support status
- Double-check locking to minimize lock contention
- Avoids sending unsupported get <jail> backend/idle commands
- Returns default values without socket calls when unsupported
All changes include comprehensive tests and maintain backward compatibility.
Adds ability to reload or restart fail2ban service from the Server tab UI.
Backend changes:
- Add new restart() method to jail_service.py that sends 'restart' command
- Add new POST /api/config/restart endpoint in config router
- Endpoint returns 204 on success, 502 if fail2ban unreachable
- Includes structured logging via 'fail2ban_restarted' log entry
Frontend changes:
- Add configRestart endpoint to endpoints.ts
- Add restartFail2Ban() API function in config.ts API module
- Import ArrowSync24Regular icon from Fluent UI
- Add reload and restart button handlers to ServerTab
- Display 'Reload fail2ban' and 'Restart fail2ban' buttons in action row
- Show loading spinner during operation
- Display success/error MessageBar with appropriate feedback
- Update ServerTab docstring to document new buttons
All 115 frontend tests pass.
Stage 1.1-1.3: reload_all include/exclude_jails params already implemented;
added keyword-arg assertions in router and service tests.
Stage 2.1/6.1: _send_command_sync retry loop (3 attempts, 150ms exp backoff)
retrying on EAGAIN/ECONNREFUSED/ENOBUFS; immediate raise on all other errors.
Stage 2.2: asyncio.Lock at module level in jail_service.reload_all to
serialize concurrent reload--all commands.
Stage 3.1: activate_jail re-queries _get_active_jail_names after reload;
returns active=False with descriptive message if jail did not start.
Stage 4.1/6.2: asyncio.Semaphore (max 10) in Fail2BanClient.send, lazy-
initialized; logs fail2ban_command_waiting_semaphore at debug when waiting.
Stage 5.1/5.2: unit tests asserting reload_all is called with include_jails
and exclude_jails; activation verification happy/sad path tests.
Stage 6.3: TestSendCommandSyncRetry (5 cases) + TestFail2BanClientSemaphore
concurrency test.
Stage 7.1-7.3: _since_unix uses time.time(); bans_by_jail debug logging with
since_iso; diagnostic warning when total==0 despite table rows; unit test
verifying the warning fires for stale data.
- Backend: Add BantimeEscalation + BantimeEscalationUpdate Pydantic models
to app/models/config.py; add bantime_escalation field to Jail in jail.py
- Backend: jail_service.get_jail_detail() fetches 7 bantime.* socket commands
(increment, factor, formula, multipliers, maxtime, rndtime, overalljails)
and populates bantime_escalation on the returned Jail object
- Backend: config_service.get_jail_config() fetches same 7 commands;
update_jail_config() writes escalation fields when provided
- Frontend: Add BantimeEscalation + BantimeEscalationUpdate interfaces to
types/config.ts; extend JailConfig + JailConfigUpdate; extend Jail in
types/jail.ts
- Frontend: JailDetailPage.tsx adds BantimeEscalationSection component that
renders only when increment is enabled (shows factor, formula, multipliers,
max_time, rnd_time, overall_jails)
- Frontend: ConfigPage.tsx JailAccordionPanel adds full escalation edit form
(Switch for enable/disable, number inputs for factor/max_time/rnd_time,
text inputs for formula/multipliers, Switch for overall_jails);
handleSave includes bantime_escalation in the JailConfigUpdate payload
- Tests: Update ConfigPageLogPath.test.tsx mock to include bantime_escalation:null
- Docs: Mark Task 6 as DONE in Tasks.md
- Send fail2ban's `unban --all` command via new `unban_all_ips()` service
function; returns the count of unbanned IPs
- Add `UnbanAllResponse` Pydantic model (message + count)
- Add `DELETE /api/bans/all` router endpoint; handles 502 on socket error
- Frontend: `bansAll` endpoint constant, `unbanAllBans()` API call,
`UnbanAllResponse` type, `unbanAll` action in `useActiveBans` hook
- JailsPage: "Clear All Bans" button (visible when bans > 0) with a
Fluent UI confirmation Dialog before executing the operation
- 7 new tests (3 service, 4 router); 440 total pass, 82% coverage
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.
_is_not_found_error in jail_service did not match the concatenated form
'unknownjailexception' that fail2ban produces when it serialises
UnknownJailException, so JailOperationError was raised instead of
JailNotFoundError and every ban attempt in the import loop failed
individually, skipping all 27 840 IPs before returning an error.
Two changes:
- Add 'unknownjail' to the phrase list in _is_not_found_error so that
UnknownJailException is correctly mapped to JailNotFoundError.
- In blocklist_service.import_source, catch JailNotFoundError explicitly
and break out of the loop immediately with a warning log instead of
retrying on every IP.