On startup BanGUI now verifies that the four fail2ban jail config files
required by its two custom jails (manual-Jail and blocklist-import) are
present in `$fail2ban_config_dir/jail.d`. Any missing file is created
with the correct default content; existing files are never overwritten.
Files managed:
- manual-Jail.conf (enabled=false template)
- manual-Jail.local (enabled=true override)
- blocklist-import.conf (enabled=false template)
- blocklist-import.local (enabled=true override)
The check runs in the lifespan hook immediately after logging is
configured, before the database is opened.
Task 0.1: Create database parent directory before connecting
- main.py _lifespan now calls Path(database_path).parent.mkdir(parents=True,
exist_ok=True) before aiosqlite.connect() so the app starts cleanly on
a fresh Docker volume with a nested database path.
Task 0.2: SetupRedirectMiddleware redirects when db is None
- Guard now reads: if db is None or not is_setup_complete(db)
A missing database (startup still in progress) is treated as setup not
complete instead of silently allowing all API routes through.
Task 0.3: SetupGuard redirects to /setup on API failure
- .catch() handler now sets status to 'pending' instead of 'done'.
A crashed backend cannot serve protected routes; conservative fallback
is to redirect to /setup.
Task 0.4: SetupPage shows spinner while checking setup status
- Added 'checking' boolean state; full-screen Spinner is rendered until
getSetupStatus() resolves, preventing form flash before redirect.
- Added console.warn in catch block; cleanup return added to useEffect.
Also: remove unused type: ignore[call-arg] from config.py.
Tests: 18 backend tests pass; 117 frontend tests pass.
Rename GET/PUT /api/config/actions/{name} to /actions/{name}/raw in
file_config.py to eliminate the route-shadowing conflict with config.py,
which registers its own GET /actions/{name} returning ActionConfig.
Add configActionRaw endpoint helper in endpoints.ts and update
fetchActionFile/updateActionFile in config.ts to use it. Add
TestGetActionFileRaw and TestUpdateActionFileRaw test classes.
- 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
- 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).
The container init script (init-fail2ban-config) copies jail.conf from the
image's /defaults/ on every start, overwriting any direct edits. The correct
fix is jail.local, which is not present in the image defaults and therefore
persists across restarts.
Changes:
- Add Docker/fail2ban-dev-config/fail2ban/jail.local with [DEFAULT] overrides
for banaction = iptables-multiport and banaction_allports = iptables-allports.
fail2ban loads jail.local after jail.conf so these values are available to
all jails during %(action_)s interpolation.
- Untrack jail.local from .gitignore so it is committed to the repo.
- Fix TypeError in config_file_service: except jail_service.JailNotFoundError
failed when jail_service was mocked in tests because MagicMock attributes are
not BaseException subclasses. Import JailNotFoundError directly instead.
- Mark BUG-001 as Done in Tasks.md.
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.
Task 1: Move Configuration to last position in sidebar NAV_ITEMS
Task 2: Add automatic rollback when jail activation fails
- Back up .local override file before writing
- Restore original file (or delete) on reload failure, health-check
failure, or jail not appearing post-reload
- Return recovered=True/False in JailActivationResponse
- Show warning/critical banner in ActivateJailDialog based on recovery
- Add _restore_local_file_sync and _rollback_activation_async helpers
- Add 3 new tests: rollback on reload failure, health-check failure,
and double failure (recovered=False)
Task 3: Color pie chart legend labels to match their slice color
- legendFormatter now returns ReactNode with span style={{ color }}
- Import LegendPayload from recharts/types/component/DefaultLegendContent
Two frontend bugs and one mypy false positive fixed:
- ActivateJailDialog: Activate button was never disabled when
blockingIssues.length > 0 (missing condition in disabled prop).
- ActivateJailDialog: handleConfirm called onActivated() even when
the backend returned active=false (blocked activation). Dialog now
stays open and shows result.message instead.
- config.py: Settings() call flagged by mypy --strict because
pydantic-settings loads required fields from env vars at runtime;
suppressed with a targeted type: ignore[call-arg] comment.
Tests: added ActivateJailDialog.test.tsx (5 tests covering button state,
backend-rejection handling, success path, and crash detection callback).
After deactivation the endpoint now calls _run_probe to flush the
cached server status immediately, matching the activate_jail behaviour
added in Task 5. Without this, the dashboard active-jail count could
remain stale for up to 30 s after a deactivation reload.
- config.py: capture result, await _run_probe, return result
- test_config.py: add test_deactivate_triggers_health_probe; fix 3
pre-existing UP017 ruff warnings (datetime.UTC alias)
- test_health.py: update test to assert the new fail2ban field
backend/tests/test_routers/test_file_config.py:
- TestListActionFiles.test_200_returns_files: GET /api/config/actions is
handled by config.router (registered before file_config.router), so mock
config_file_service.list_actions and assert on ActionListResponse.actions
- TestCreateActionFile.test_201_creates_file: same route conflict; mock
config_file_service.create_action and use ActionCreateRequest body format
frontend/src/components/__tests__/ConfigPageLogPath.test.tsx:
- Log paths are rendered as <Input value={path}>, not text nodes; replace
getByText() with getByDisplayValue() for both test assertions
Task 2: adds a new Log tab to the Configuration page.
Backend:
- New Pydantic models: Fail2BanLogResponse, ServiceStatusResponse
(backend/app/models/config.py)
- New service methods in config_service.py:
read_fail2ban_log() — queries socket for log target/level, validates the
resolved path against a safe-prefix allowlist (/var/log) to prevent
path traversal, then reads the tail of the file via the existing
_read_tail_lines() helper; optional substring filter applied server-side.
get_service_status() — delegates to health_service.probe() and appends
log level/target from the socket.
- New endpoints in routers/config.py:
GET /api/config/fail2ban-log?lines=200&filter=...
GET /api/config/service-status
Both require authentication; log endpoint returns 400 for non-file log
targets or path-traversal attempts, 502 when fail2ban is unreachable.
Frontend:
- New LogTab.tsx component:
Service Health panel (Running/Offline badge, version, jail count, bans,
failures, log level/target, offline warning banner).
Log viewer with color-coded lines (error=red, warning=yellow,
debug=grey), toolbar (filter input + debounce, lines selector, manual
refresh, auto-refresh with interval selector), truncation notice, and
auto-scroll to bottom on data updates.
fetchData uses Promise.allSettled so a log-read failure never hides the
service-health panel.
- Types: Fail2BanLogResponse, ServiceStatusResponse (types/config.ts)
- API functions: fetchFail2BanLog, fetchServiceStatus (api/config.ts)
- Endpoint constants (api/endpoints.ts)
- ConfigPage.tsx: Log tab added after existing tabs
Tests:
- Backend service tests: TestReadFail2BanLog (6), TestGetServiceStatus (2)
- Backend router tests: TestGetFail2BanLog (8), TestGetServiceStatus (3)
- Frontend: LogTab.test.tsx (8 tests)
Docs:
- Features.md: Log section added under Configuration View
- Architekture.md: config.py router and config_service.py descriptions updated
- Tasks.md: Task 2 marked done
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.
Task 1: Backend/LogEncoding/DatePattern dropdowns in JailConfigDetail
- Added BACKENDS, LOG_ENCODINGS, DATE_PATTERN_PRESETS constants
- Backend and Log Encoding: <Input readOnly> → <Select> (editable, auto-saves)
- Date Pattern: <Input> → <Combobox freeform> with presets
- Extended JailConfigUpdate model (backend, log_encoding) and service
- Added readOnly prop to JailConfigDetail (all fields, toggles, buttons)
- Extended RegexList with readOnly prop
Task 2: Fix raw action/filter config always blank
- Added key={selectedAction.name} to ActionDetail in ActionsTab
- Added key={selectedFilter.name} to FilterDetail in FiltersTab
Task 3: Inactive jail full GUI same as active jails
- Extended InactiveJail Pydantic model with all config fields
- Added _parse_time_to_seconds helper to config_file_service
- Updated _build_inactive_jail to populate all extended fields
- Extended InactiveJail TypeScript type to match
- Rewrote InactiveJailDetail to reuse JailConfigDetail (readOnly=true)
Task 4: Fix banaction interpolation error when activating jails
- _write_local_override_sync now includes banaction=iptables-multiport
and banaction_allports=iptables-allports in every .local file
- PUT /api/config/filters/{name}: updates failregex/ignoreregex/datepattern/
journalmatch in filter.d/{name}.local; validates regex via re.compile();
supports ?reload=true
- POST /api/config/filters: creates filter.d/{name}.local from FilterCreateRequest;
returns 409 if file already exists
- DELETE /api/config/filters/{name}: deletes .local only; returns 409 for
conf-only (readonly) filters
- POST /api/config/jails/{name}/filter: assigns filter to jail by writing
'filter = {name}' to jail.d/{jail}.local; supports ?reload=true
- New models: FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest
- New service helpers: _safe_filter_name, _validate_regex_patterns,
_write_filter_local_sync, _set_jail_local_key_sync
- Fixed .local-only filter discovery in _parse_filters_sync (5-tuple return)
- Fixed get_filter extension stripping (.local is 6 chars not 5)
- Renamed file_config.py raw-write routes to /raw suffix
(PUT /filters/{name}/raw, POST /filters/raw) to avoid routing conflicts
- Full service + router tests; all 930 tests pass
- Add list_filters() and get_filter() to config_file_service.py:
scans filter.d/, parses [Definition] + [Init] sections, merges .local
overrides, and cross-references running jails to set active/used_by_jails
- Add FilterConfig.active, used_by_jails, source_file, has_local_override
fields to the Pydantic model; add FilterListResponse and FilterNotFoundError
- Add GET /api/config/filters and GET /api/config/filters/{name} to config.py
- Remove the shadowed GET /api/config/filters list route from file_config.py;
rename GET /api/config/filters/{name} raw variant to /filters/{name}/raw
- Update frontend: fetchFilterFiles() adapts FilterListResponse -> ConfFilesResponse;
add fetchFilters() and fetchFilter() to api/config.ts; remove unused
fetchFilterFiles/fetchActionFiles calls from useConfigActiveStatus
- Fix ConfigPageLogPath test mock to include fetchInactiveJails and related
exports introduced by Stage 1
- Backend: 169 tests pass, mypy --strict clean, ruff clean
- Frontend: 63 tests pass, tsc --noEmit clean, eslint clean
- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
Add PUT endpoints for overwriting raw content of jail.d, filter.d, and
action.d config files. Mirrors the existing GET endpoints so the frontend
can show an editable raw-text view of each config file.
- test_conffile_parser.py: unit tests for section/key parsing, comment
preservation, and round-trip write correctness
- test_file_config_service.py: service-level tests with mock filesystem
- test_file_config.py: router integration tests covering GET / PUT
endpoints for jails, actions, and filters
- Add conffile_parser.py: reads, writes and manipulates fail2ban .conf
files while preserving comments and section structure
- Extend config models with ActionConfig, FilterConfig, ConfFileContent,
and related Pydantic schemas for jails, actions, and filters
- Add use_dns and prefregex fields to JailConfig model (backend + frontend types)
- Add prefregex to JailConfigUpdate; validate as regex before writing
- Fetch usedns and prefregex in get_jail_config via asyncio.gather
- Write usedns and prefregex in update_jail_config
- ConfigPage JailAccordionPanel: editable date_pattern input, dns_mode
Select dropdown (yes/warn/no/raw), and prefregex input
- 8 new service unit tests + 3 new router integration tests
- 628 tests pass; 85% line coverage; ruff/mypy/tsc/eslint clean
- 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
Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
- Replace str(exc) with repr(exc) in lookup() and _batch_api_call()
so exception class name is always present even for no-message errors
(e.g. aiohttp.ServerDisconnectedError() whose str() is empty)
- Add exc_type=type(exc).__name__ field to network-error log events
for easy structured-log filtering
- Move import aiohttp to runtime import; use aiohttp.ClientTimeout()
instead of raw float, removing # type: ignore[arg-type] workarounds
- Add TestErrorLogging with 3 tests covering empty-message exceptions
All error-handling branches in app/routers/jails.py were previously
untested: every Fail2BanConnectionError (502) path, several
JailNotFoundError (404) and JailOperationError (409) paths, and the
toggle_ignore_self endpoint which had zero coverage.
Added 26 new test cases across three new test classes
(TestIgnoreIpEndpoints extended, TestToggleIgnoreSelf,
TestFail2BanConnectionErrors) covering every remaining branch.
- app/routers/jails.py: 61% → 100% line coverage
- Overall backend coverage: 83% → 85%
- Total test count: 497 → 523 (all pass)
- ruff check and mypy --strict clean
- Wrap long dict literal in test_geo_service.py across multiple lines (E501)
- Combine nested with statements in test_jail_service.py (SIM117)
- Add vitest.config.ts to tsconfig.node.json include so ESLint
parserOptions.project resolves it correctly
- backend: GET /api/dashboard/bans/by-jail endpoint
- JailBanCount + BansByJailResponse Pydantic models in ban.py
- bans_by_jail() service function with origin filter support
- Route added to dashboard router
- 17 new tests (7 service, 10 router); full suite 497 passed, 83% coverage
- frontend: JailDistributionChart component
- JailBanCount / BansByJailResponse types in types/ban.ts
- dashboardBansByJail endpoint constant in api/endpoints.ts
- fetchBansByJail() in api/dashboard.ts
- useJailDistribution hook in hooks/useJailDistribution.ts
- JailDistributionChart component (horizontal bar chart, Recharts)
- DashboardPage: full-width Jail Distribution section below Top Countries
- Cache setup_completed flag in app.state._setup_complete_cached after
first successful is_setup_complete() call; all subsequent API requests
skip the DB query entirely (one-way transition, cleared on restart).
- Add in-memory session token TTL cache (10 s) in require_auth; the second
request with the same token within the window skips session_repo.get_session.
- Call invalidate_session_cache() on logout so revoked tokens are evicted
immediately rather than waiting for TTL expiry.
- Add clear_session_cache() for test isolation.
- 5 new tests covering the cached fast-path for both optimisations.
- 460 tests pass, 83% coverage, zero ruff/mypy warnings.
- Remove per-IP db.commit() from _persist_entry() and _persist_neg_entry();
add a single commit after the full lookup_batch() chunk loop instead.
Reduces commits from ~5,200 to 1 per bans/by-country request.
- Remove db dependency from GET /api/dashboard/bans and
GET /api/dashboard/bans/by-country; pass app_db=None so no SQLite
writes occur during read-only requests.
- Add _dirty set to geo_service; _store() marks resolved IPs dirty.
New flush_dirty(db) batch-upserts all dirty entries in one transaction.
New geo_cache_flush APScheduler task flushes every 60 s so geo data
is persisted without blocking requests.
list_bans() was calling geo_service.lookup() once per IP on the
page (e.g. 100 sequential HTTP requests), hitting the ip-api.com
free-tier single-IP limit of 45 req/min. IPs beyond the ~45th
were added to the in-process negative cache (5 min TTL) and showed
as no country until the TTL expired. The map endpoint never had
this problem because it used lookup_batch (100 IPs per POST).
Add http_session and app_db params to list_bans(). When
http_session is provided (production path), the entire page is
resolved in one lookup_batch() call instead of N individual ones.
The legacy geo_enricher callback is kept for test compatibility.
Update the dashboard router to use the batch path directly.
Adds 3 tests covering the batch geo path, failure resilience, and
http_session priority over geo_enricher.
geoip2 is an optional dependency used only when a MaxMind mmdb path is
configured. Importing it unconditionally at module level caused the server
to crash on startup with ModuleNotFoundError when the package was absent
from the environment.
Move the imports under TYPE_CHECKING (for static analysis) and add lazy
local imports inside init_geoip() and _geoip_lookup() where geoip2 is
actually needed at runtime. The server now starts normally without a
MaxMind database, and geoip2 is loaded on demand if the feature is used.
- 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
When the most recent scheduled import completed with errors, surface the
failure in the persistent app shell:
- A warning MessageBar appears at top of main content area
- An amber badge is rendered on the Blocklists sidebar nav item
Backend: add last_run_errors: bool | None to ScheduleInfo model and
populate it in get_schedule_info() from the latest import_log row.
Frontend: extend ScheduleInfo type, add useBlocklistStatus polling hook,
wire both indicators into MainLayout.
Tests: 3 new service tests + 1 new router test (433 total, all pass).
- Add 5-min negative cache (_neg_cache) so failing IPs are throttled
rather than hammering the API on every request
- Add MaxMind GeoLite2 fallback (init_geoip / _geoip_lookup) that fires
when ip-api fails; controlled by BANGUI_GEOIP_DB_PATH env var
- Fix lookup_batch bug: failed API results were stored in positive cache
- Add _persist_neg_entry: INSERT OR IGNORE into geo_cache with NULL
country_code so re-resolve can find historically failed IPs
- Add POST /api/geo/re-resolve: clears neg cache, batch-retries all
geo_cache rows with country_code IS NULL, returns resolved/total count
- BanTable + MapPage: wrap the country — placeholder in a Fluent UI
Tooltip explaining the retry behaviour
- Add geoip2>=4.8.0 dependency; geoip_db_path config setting
- Tests: add TestNegativeCache (4), TestGeoipFallback (4), TestReResolve (4)
- Add persistent geo_cache SQLite table (db.py)
- Rewrite geo_service: batch API (100 IPs/call), two-tier cache,
no caching of failed lookups so they are retried
- Pre-warm geo cache from DB on startup (main.py lifespan)
- Rewrite bans_by_country: SQL GROUP BY ip aggregation + lookup_batch
instead of 2000-row fetch + asyncio.gather individual calls
- Pre-warm geo cache after blocklist import (blocklist_service)
- Add 300ms debounce to useMapData hook to cancel stale requests
- Add perf benchmark asserting <2s for 10k bans
- Add seed_10k_bans.py script for manual perf testing
- Task 1: Mark imported blocklist IP addresses
- Add BanOrigin type and _derive_origin() to ban.py model
- Populate origin field in ban_service list_bans() and bans_by_country()
- BanTable and MapPage companion table show origin badge column
- Tests: origin derivation in test_ban_service.py and test_dashboard.py
- Task 2: Add origin filter to dashboard and world map
- ban_service: _origin_sql_filter() helper; origin param on list_bans()
and bans_by_country()
- dashboard router: optional origin query param forwarded to service
- Frontend: BanOriginFilter type + BAN_ORIGIN_FILTER_LABELS in ban.ts
- fetchBans / fetchBansByCountry forward origin to API
- useBans / useMapData accept and pass origin; page resets on change
- BanTable accepts origin prop; DashboardPage adds segmented filter
- MapPage adds origin Select next to time-range picker
- Tests: origin filter assertions in test_ban_service and test_dashboard
The blocklist import service targets a dedicated jail called
'blocklist-import' (BLOCKLIST_JAIL constant in blocklist_service.py),
but that jail was never defined in the dev fail2ban configuration.
Every import attempt immediately failed with UnknownJailException.
Add Docker/fail2ban-dev-config/fail2ban/jail.d/blocklist-import.conf:
a manual-ban jail with no log-based detection that accepts banip
commands only, using iptables-allports with a 1-week bantime.
Also track the new file in .gitignore (whitelist) and fix a
pre-existing blank-line-with-whitespace lint error in setup_service.py.
- Implement ban model, service, and router endpoints in backend
- Add ban table component and dashboard integration in frontend
- Update ban-related types and API endpoints
- Add comprehensive tests for ban service and dashboard router
- Update documentation (Features, Tasks, Architecture, Web-Design)
- Clean up old fail2ban configuration files
- Update Makefile with new commands
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.