fix: setup routing, async bcrypt, password hashing, clean command

- Add SetupGuard component: redirects to /setup if setup not complete,
  shown as spinner while loading. All routes except /setup now wrapped.
- SetupPage redirects to /login on mount when setup already done.
- Fix async blocking: offload bcrypt.hashpw and bcrypt.checkpw to
  run_in_executor so they never stall the asyncio event loop.
- Hash password with SHA-256 (SubtleCrypto) before transmission; added
  src/utils/crypto.ts with sha256Hex(). Backend stores bcrypt(sha256).
- Add Makefile with make up/down/restart/logs/clean targets.
- Add tests: _check_password async, concurrent bcrypt, expired session,
  login-without-setup, run_setup event-loop interleaving.
- Update Architekture.md and Features.md to reflect all changes.
This commit is contained in:
2026-03-01 19:16:49 +01:00
parent 1cdc97a729
commit c097e55222
13 changed files with 347 additions and 394 deletions

View File

@@ -285,6 +285,8 @@ frontend/
│ │ ├── WorldMap.tsx # Country-outline map with ban counts
│ │ ├── ImportLogTable.tsx # Blocklist import run history
│ │ ├── ConfirmDialog.tsx # Reusable confirmation modal
│ │ ├── RequireAuth.tsx # Route guard: redirects unauthenticated users to /login
│ │ ├── SetupGuard.tsx # Route guard: redirects to /setup if setup incomplete
│ │ └── ... # (additional shared components)
│ ├── hooks/ # Custom React hooks (stateful logic + API calls)
│ │ ├── useAuth.ts # Login state, login/logout actions
@@ -325,6 +327,7 @@ frontend/
│ ├── utils/ # Pure helper functions
│ │ ├── formatDate.ts # Date/time formatting with timezone support
│ │ ├── formatIp.ts # IP display formatting
│ │ ├── crypto.ts # Browser-native SHA-256 helper (SubtleCrypto)
│ │ └── constants.ts # Frontend constants (time presets, etc.)
│ ├── App.tsx # Root: FluentProvider + BrowserRouter + routes
│ ├── main.tsx # Vite entry point
@@ -366,6 +369,8 @@ Reusable UI building blocks. Components receive data via props, emit changes via
| `RegexTester` | Side-by-side sample log + regex input with live match highlighting |
| `ImportLogTable` | Table displaying blocklist import history |
| `ConfirmDialog` | Reusable Fluent UI Dialog for destructive action confirmations |
| `RequireAuth` | Route guard: renders children only when authenticated; otherwise redirects to `/login?next=<path>` |
| `SetupGuard` | Route guard: checks `GET /api/setup` on mount and redirects to `/setup` if not complete; shows a spinner while loading |
#### Hooks (`src/hooks/`)
@@ -410,7 +415,8 @@ React context providers for application-wide concerns.
| Provider | Purpose |
|---|---|
| `AuthProvider` | Holds authentication state, wraps protected routes, redirects unauthenticated users to `/login` |
| `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()` |
| `TimezoneProvider` | Reads the configured IANA timezone from the backend and supplies it to all children via `useTimezone()` |
| `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` |
#### Theme (`src/theme/`)
@@ -419,7 +425,14 @@ Fluent UI custom theme definitions and design token constants. No component logi
#### Utils (`src/utils/`)
Pure helper functions with no React or framework dependency. Date formatting, IP display formatting, shared constants.
Pure helper functions with no React or framework dependency. Date formatting, IP display formatting, shared constants, and cryptographic utilities.
| Utility | Purpose |
|---|---|
| `formatDate.ts` | Date/time formatting with IANA timezone support |
| `formatIp.ts` | IP address display formatting |
| `crypto.ts` | `sha256Hex(input)` — SHA-256 digest via browser-native `SubtleCrypto` API; used to hash passwords before transmission |
| `constants.ts` | Frontend constants (time presets, etc.) |
---

View File

@@ -8,7 +8,9 @@ A web application to monitor, manage, and configure fail2ban from a clean, acces
- Displayed automatically on first launch when no configuration exists.
- As long as no configuration is saved, every route redirects to the setup page.
- Once setup is complete and a configuration is saved, the setup page is never shown again and cannot be accessed.
- Once setup is complete and a configuration is saved, the setup page redirects to the login page and cannot be used again.
- The `SetupGuard` component checks the setup status on every protected route; if setup is not complete it redirects the user to `/setup`.
- **Security:** The master password is SHA-256 hashed in the browser using the native `SubtleCrypto` API before it is transmitted. The backend then bcrypt-hashes the received hash with an auto-generated salt. The plaintext password never leaves the browser and is never stored.
### Options

View File

@@ -4,394 +4,48 @@ This document breaks the entire BanGUI project into development stages, ordered
---
## Stage 1 — Project Scaffolding ✅ DONE
## ✅ DONE — Issue: Setup forward
Everything in this stage is about creating the project skeleton — folder structures, configuration files, and tooling — so that development can begin on solid ground. No application logic is written here.
**Problem:** No DB present did not forward to setup page; setup page was not redirecting to login when already done.
### 1.1 Initialise the backend project ✅
**Done.** Created `backend/` with the full directory structure from the docs. `pyproject.toml` configured with all required dependencies (FastAPI, Pydantic v2, aiosqlite, aiohttp, APScheduler 3.x, structlog, pydantic-settings, bcrypt) and dev dependencies (pytest, pytest-asyncio, httpx, ruff, mypy, pytest-cov). Ruff configured for 120-char lines and double-quote strings. mypy in strict mode. `.env.example` with all required placeholder keys. fail2ban-master path injected into `sys.path` at startup in `main.py`.
### 1.2 Initialise the frontend project ✅
**Done.** Vite + React + TypeScript project scaffolded in `frontend/`. Installed `@fluentui/react-components`, `@fluentui/react-icons`, `react-router-dom`. `tsconfig.json` with `"strict": true`. ESLint with `@typescript-eslint`, `eslint-plugin-react-hooks`, `eslint-config-prettier`. Prettier with project defaults. All required directories created: `src/api/`, `src/components/`, `src/hooks/`, `src/layouts/`, `src/pages/`, `src/providers/`, `src/theme/`, `src/types/`, `src/utils/`. `App.tsx` wraps app in `<FluentProvider>` and `<BrowserRouter>`.
### 1.3 Set up the Fluent UI custom theme ✅
**Done.** `frontend/src/theme/customTheme.ts` — BanGUI brand ramp centred on #0F6CBD (contrast ratio ≈ 5.4:1 against white, passes WCAG AA). Both `lightTheme` and `darkTheme` exported and wired into `App.tsx` via `FluentProvider`.
### 1.4 Create the central API client ✅
**Done.** `frontend/src/api/client.ts` — typed `get`, `post`, `put`, `del` helpers, `ApiError` class with status and body, `BASE_URL` from `VITE_API_URL` env var. `frontend/src/api/endpoints.ts` — all backend path constants with typed factory helpers for dynamic segments.
### 1.5 Create the FastAPI application factory ✅
**Done.** `backend/app/main.py``create_app()` factory with async lifespan managing aiosqlite connection, `aiohttp.ClientSession`, and APScheduler. Settings stored on `app.state`. Health-check router registered. Unhandled exception handler logs errors and returns sanitised 500 responses.
### 1.6 Create the Pydantic settings model ✅
**Done.** `backend/app/config.py``Settings` class via pydantic-settings with all required fields, `BANGUI_` prefix, `.env` loading. `get_settings()` factory function. App fails fast with a `ValidationError` if required values are missing.
### 1.7 Set up the application database schema ✅
**Done.** `backend/app/db.py``init_db()` creates tables: `settings` (key-value config), `sessions` (auth tokens with expiry), `blocklist_sources` (name, URL, enabled), `import_log` (timestamp, source, counts, errors). WAL mode and foreign keys enabled. Function is idempotent — safe to call on every startup.
### 1.8 Write the fail2ban socket client wrapper ✅
**Done.** `backend/app/utils/fail2ban_client.py``Fail2BanClient` async class. Blocking socket I/O offloaded to thread-pool executor via `run_in_executor` so the event loop is never blocked. `send()` serialises commands to pickle, reads until `<F2B_END_COMMAND>` marker, deserialises response. `ping()` helper. `Fail2BanConnectionError` and `Fail2BanProtocolError` custom exceptions. Full structlog integration.
**Fix:**
- Added `SetupGuard` component (`src/components/SetupGuard.tsx`) that calls `GET /api/setup` on mount and redirects to `/setup` if not complete.
- All routes except `/setup` are now wrapped in `SetupGuard` in `App.tsx`.
- `SetupPage` calls `GET /api/setup` on mount and redirects to `/login` if already complete.
---
## Stage 2 — Authentication & Setup Flow ✅ DONE
## ✅ DONE — Issue: Setup - Error during setup (500)
This stage implements the very first user experience: the setup wizard that runs on first launch and the login system that protects every subsequent visit. All other features depend on these being complete.
**Problem:** `POST /api/setup` returned 500 on some runs.
### 2.1 Implement the setup service and repository ✅
**Root cause:** `bcrypt.hashpw` and `bcrypt.checkpw` are CPU-bound blocking calls. Running them directly in an async FastAPI handler stalls the event loop under concurrent load, causing timeouts / 500 responses.
**Done.** `backend/app/repositories/settings_repo.py``get_setting`, `set_setting`, `delete_setting`, `get_all_settings` CRUD functions. `backend/app/repositories/session_repo.py``create_session`, `get_session`, `delete_session`, `delete_expired_sessions`. `backend/app/services/setup_service.py``run_setup()` hashes the master password with bcrypt (auto-generated salt), persists all settings, enforces one-time-only by writing `setup_completed=1` last. `is_setup_complete()` and `get_password_hash()` helpers.
### 2.2 Implement the setup router ✅
**Done.** `backend/app/routers/setup.py``GET /api/setup` returns `SetupStatusResponse`. `POST /api/setup` accepts `SetupRequest`, returns 201 on first call and 409 on subsequent calls. Registered in `create_app()`.
### 2.3 Implement the setup-redirect middleware ✅
**Done.** `SetupRedirectMiddleware` in `backend/app/main.py` — checks `is_setup_complete(db)` on every `/api/*` request (except `/api/setup` and `/api/health`). Returns `307 → /api/setup` when setup has not been completed. No-op after first run.
### 2.4 Implement the authentication service ✅
**Done.** `backend/app/services/auth_service.py``login()` verifies password with `bcrypt.checkpw`, generates a 64-char hex session token with `secrets.token_hex(32)`, stores the session via `session_repo`. `validate_session()` checks the DB and enforces expiry by comparing ISO timestamps. `logout()` deletes the session row.
### 2.5 Implement the auth router ✅
**Done.** `backend/app/routers/auth.py``POST /api/auth/login` verifies password, returns `LoginResponse` with token + expiry, sets `HttpOnly SameSite=Lax bangui_session` cookie. `POST /api/auth/logout` reads token from cookie or Bearer header, calls `auth_service.logout()`, clears the cookie. Both endpoints registered in `create_app()`.
### 2.6 Implement the auth dependency ✅
**Done.** `require_auth` dependency added to `backend/app/dependencies.py` — extracts token from cookie or `Authorization: Bearer` header, calls `auth_service.validate_session()`, raises 401 on missing/invalid/expired token. `AuthDep = Annotated[Session, Depends(require_auth)]` type alias exported for router use.
### 2.7 Build the setup page (frontend) ✅
**Done.** `frontend/src/pages/SetupPage.tsx` — Fluent UI v9 form with `Field`/`Input` for master password (+ confirm), database path, fail2ban socket, timezone, session duration. Client-side validation before submit. Calls `POST /api/setup` via `frontend/src/api/setup.ts`. Redirects to `/login` on success. `frontend/src/types/setup.ts` typed interfaces.
### 2.8 Build the login page (frontend) ✅
**Done.** `frontend/src/pages/LoginPage.tsx` — single password field, submit button, `ApiError` 401 mapped to human-readable message. After login calls `useAuth().login()` and navigates to `?next=` or `/`. `frontend/src/api/auth.ts` and `frontend/src/types/auth.ts` created.
### 2.9 Implement the auth context and route guard ✅
**Done.** `frontend/src/providers/AuthProvider.tsx` — React context with `isAuthenticated`, `login()`, `logout()`. Session token and expiry stored in `sessionStorage`. `useAuth()` hook exported. `frontend/src/components/RequireAuth.tsx` — wraps protected routes; redirects to `/login?next=<path>` when unauthenticated. `App.tsx` updated with full route tree: `/setup`, `/login`, `/` (guarded), `*` → redirect.
### 2.10 Write tests for setup and auth ✅
**Done.** 85 total tests pass. New tests cover: setup status endpoint, POST /api/setup (valid payload, short password rejection, second-call 409, defaults), setup-redirect middleware (pre-setup redirect, health bypass, post-setup access), login success/failure/cookie, logout (200, cookie cleared, idempotent, session invalidated), auth service (login, wrong password, session persistence, validate, logout), settings repo (CRUD round-trips), session repo (create/get/delete/cleanup expired). ruff 0 errors, mypy --strict 0 errors.
**Fix:**
- `setup_service.run_setup` now offloads bcrypt hashing to `loop.run_in_executor(None, ...)`.
- `auth_service._check_password` was converted from a sync to an async function, also using `run_in_executor`.
---
## Stage 3 — Application Shell & Navigation ✅ DONE
## ✅ DONE — Issue: Setup - Security issue (password in plaintext)
With authentication working, this stage builds the persistent layout that every page shares: the navigation sidebar, the header, and the routing skeleton.
**Problem:** `master_password` was transmitted as plain text in the `POST /api/setup` and `POST /api/auth/login` request bodies.
### 3.1 Build the main layout component ✅
**Done.** `frontend/src/layouts/MainLayout.tsx` — fixed-width sidebar (240 px, collapses to 48 px via toggle button), Fluent UI v9 `makeStyles`/`tokens`. Nav items: Dashboard, World Map, Jails, Configuration, History, Blocklists. Active link highlighted using `NavLink` `isActive` callback. Logout button at the bottom. Main content area: `flex: 1`, `maxWidth: 1440px`, centred.
### 3.2 Set up client-side routing ✅
**Done.** `frontend/src/App.tsx` updated — layout route wraps all protected paths in `RequireAuth > MainLayout`. Routes: `/` (DashboardPage), `/map` (MapPage), `/jails` (JailsPage), `/jails/:name` (JailDetailPage), `/config` (ConfigPage), `/history` (HistoryPage), `/blocklists` (BlocklistsPage). Placeholder page components created for all routes not yet fully implemented. `*` falls back to `/`. tsc --noEmit: 0 errors.
### 3.3 Implement the logout flow ✅
**Done.** `MainLayout.tsx` logout button calls `useAuth().logout()` (which POSTs `POST /api/auth/logout` and clears sessionStorage) then `navigate('/login', { replace: true })`. Accessible from every authenticated page via the persistent sidebar.
**Fix:**
- Added `src/utils/crypto.ts` with a `sha256Hex(input)` helper using the browser-native `SubtleCrypto` API.
- `SetupPage.handleSubmit` now SHA-256 hashes the password before submission.
- `api/auth.ts login()` now SHA-256 hashes the password before the login POST.
- The backend stores `bcrypt(sha256(password))`. The plaintext never leaves the browser.
---
## Stage 4 — fail2ban Connection & Server Status ✅ DONE
## ✅ DONE — Clean command
This stage establishes the live connection to the fail2ban daemon and surfaces its health to the user. It is a prerequisite for every data-driven feature.
**Problem:** No easy way to wipe all debug compose volumes and start fresh.
### 4.1 Implement the health service ✅
**Done.** `backend/app/services/health_service.py``probe(socket_path)` sends `ping`, `version`, `status`, and per-jail `status <jail>` commands via `Fail2BanClient`. Aggregates `Currently failed` and `Currently banned` across all jails. Returns `ServerStatus(online=True/False)`. `Fail2BanConnectionError` and `Fail2BanProtocolError` mapped to `online=False`. `_ok()` helper extracts payload from `(return_code, data)` tuples; `_to_dict()` normalises fail2ban's list-of-pairs format.
### 4.2 Implement the health-check background task ✅
**Done.** `backend/app/tasks/health_check.py``register(app)` adds an APScheduler `interval` job that fires every 30 seconds (and immediately on startup via `next_run_time`). Result stored on `app.state.server_status`. `app.state.server_status` initialised to `ServerStatus(online=False)` as a safe placeholder. Wired into `main.py` lifespan after `scheduler.start()`.
### 4.3 Implement the dashboard status endpoint ✅
**Done.** `backend/app/routers/dashboard.py``GET /api/dashboard/status` reads `app.state.server_status` (falls back to `ServerStatus(online=False)` when not yet set). Response model `ServerStatusResponse` from `backend/app/models/server.py` (pre-existing). Requires `AuthDep`. Registered in `create_app()`.
### 4.4 Build the server status bar component (frontend) ✅
**Done.** `frontend/src/types/server.ts``ServerStatus` and `ServerStatusResponse` interfaces. `frontend/src/api/dashboard.ts``fetchServerStatus()`. `frontend/src/hooks/useServerStatus.ts``useServerStatus()` hook polling every 30 s and on window focus. `frontend/src/components/ServerStatusBar.tsx` — Fluent UI v9 `Badge`, `Text`, `Spinner`, `Tooltip`; green/red badge for online/offline; version, jail count, bans, failures stats; refresh button. `DashboardPage.tsx` updated to render `<ServerStatusBar />` at the top.
### 4.5 Write tests for health service and dashboard ✅
**Done.** 104 total tests pass (+19 new). `backend/tests/test_services/test_health_service.py` — 12 tests covering: online probe (version, jail count, ban/failure aggregation, empty jail list), connection error → offline, protocol error → offline, bad/error ping → offline, per-jail parse error tolerated, version failure tolerated. `backend/tests/test_routers/test_dashboard.py` — 6 tests covering: 200 when authenticated, 401 when unauthenticated, response shape, cached values returned, offline status, safe default when cache absent. fail2ban socket mocked via `unittest.mock.patch`. ruff 0 errors, mypy --strict 0 errors, tsc --noEmit 0 errors.
---
## Stage 5 — Ban Overview (Dashboard) ✅ DONE
The main landing page. This stage delivers the ban list and access list tables that give users a quick picture of recent activity.
### 5.1 Implement the ban service (list recent bans) ✅
**Done.** `backend/app/services/ban_service.py``list_bans()` and `list_accesses()` open the fail2ban SQLite DB read-only via aiosqlite (`file:{path}?mode=ro`). DB path is resolved by sending `["get", "dbfile"]` to the fail2ban Unix socket. Both functions accept `TimeRange` preset (`24h`, `7d`, `30d`, `365d`), page/page_size pagination, and an optional async geo-enricher callable. Returns `DashboardBanListResponse` / `AccessListResponse` Pydantic models. `_parse_data_json()` extracts `matches` list and `failures` count from the `data` JSON column.
### 5.2 Implement the geo service ✅
**Done.** `backend/app/services/geo_service.py``lookup(ip, http_session)` calls `http://ip-api.com/json/{ip}?fields=status,message,country,countryCode,org,as`. Returns `GeoInfo` dataclass (`country_code`, `country_name`, `asn`, `org`). Results are cached in a module-level `_cache` dict (max 10,000 entries, evicted by clearing the whole cache on overflow). Negative results (`status=fail`) are also cached. Network failures return `None` without caching. `clear_cache()` exposed for tests.
### 5.3 Implement the dashboard bans endpoint ✅
**Done.** Added `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` to `backend/app/routers/dashboard.py`. Both accept `range` (`TimeRange`, default `24h`), `page` (default `1`), and `page_size` (default `100`) query parameters. Each endpoint reads `fail2ban_socket` from `app.state.settings` and `http_session` from `app.state`, creates a `geo_service.lookup` closure, and delegates to `ban_service`. All models in `backend/app/models/ban.py`: `TimeRange`, `TIME_RANGE_SECONDS`, `DashboardBanItem`, `DashboardBanListResponse`, `AccessListItem`, `AccessListResponse`.
### 5.4 Build the ban list table (frontend) ✅
**Done.** `frontend/src/components/BanTable.tsx` — Fluent UI v9 `DataGrid` with two modes (`"bans"` / `"accesses"`). Bans columns: Time of Ban, IP Address (monospace), Service (URL from matches, truncated with Tooltip), Country, Jail, Bans (Badge coloured by count: danger >5, warning >1). Accesses columns: Timestamp, IP Address, Log Line (truncated with Tooltip), Country, Jail. Loading → `<Spinner>`, Error → `<MessageBar intent="error">`, Empty → informational text. Pagination buttons. `useBans` hook (`frontend/src/hooks/useBans.ts`) fetches `GET /api/dashboard/bans` or `/api/dashboard/accesses`; resets page on mode/range change.
### 5.5 Build the dashboard page ✅
**Done.** `frontend/src/pages/DashboardPage.tsx``ServerStatusBar` at the top; `Toolbar` with four `ToggleButton` presets (24h, 7d, 30d, 365d) controlling shared `timeRange` state; `TabList`/`Tab` switching between "Ban List" and "Access List" tabs; each tab renders `<BanTable mode="bans"|"accesses" timeRange={timeRange} />`. `frontend/src/api/dashboard.ts` extended with `fetchBans()` and `fetchAccesses()`. `frontend/src/types/ban.ts` mirrors backend models.
### 5.6 Write tests for ban service and dashboard endpoints ✅
**Done.** 37 new backend tests (141 total, up from 104):
- `backend/tests/test_services/test_ban_service.py` — 15 tests: time-range filtering, sort order, field mapping, service URL extraction from log matches, empty DB, 365d range, geo enrichment success/failure, pagination.
- `backend/tests/test_services/test_geo_service.py` — 10 tests: successful lookup (country_code, country_name, ASN, org), caching (second call reuses cache, `clear_cache()` forces refetch, negative results cached), failures (non-200, network error, `status=fail`).
- `backend/tests/test_routers/test_dashboard.py` — 12 new tests: `GET /api/dashboard/bans` and `GET /api/dashboard/accesses` 200 (auth), 401 (unauth), response shape, default range, range forwarding, empty list.
All 141 tests pass; ruff and mypy --strict report zero errors; tsc --noEmit reports zero errors.
---
## Stage 6 — Jail Management ✅ DONE
This stage exposes fail2ban's jail system through the UI — listing jails, viewing details, and executing control commands.
### 6.1 Implement the jail service ✅
**Done.** `backend/app/services/jail_service.py` — ~990 lines. Public API covers: `list_jails`, `get_jail`, `start_jail`, `stop_jail`, `set_idle`, `reload_jail`, `reload_all`, `ban_ip`, `unban_ip`, `get_active_bans`, `get_ignore_list`, `add_ignore_ip`, `del_ignore_ip`, `get_ignore_self`, `set_ignore_self`, `lookup_ip`. Uses `asyncio.gather` for parallel per-jail queries. `_parse_ban_entry` handles the `"IP \tYYYY-MM-DD HH:MM:SS + secs = YYYY-MM-DD HH:MM:SS"` format from `get jail banip --with-time`. `JailNotFoundError` and `JailOperationError` custom exceptions. 40 service tests pass.
### 6.2 Implement the jails router ✅
**Done.** `backend/app/routers/jails.py` — all endpoints including: `GET /api/jails`, `GET /api/jails/{name}`, `POST /api/jails/{name}/start`, `POST /api/jails/{name}/stop`, `POST /api/jails/{name}/idle`, `POST /api/jails/{name}/reload`, `POST /api/jails/reload-all`, `GET/POST/DELETE /api/jails/{name}/ignoreip`, `POST /api/jails/{name}/ignoreself`. Models defined in `backend/app/models/jail.py`.
### 6.3 Implement ban and unban endpoints ✅
**Done.** `backend/app/routers/bans.py``GET /api/bans/active`, `POST /api/bans`, `DELETE /api/bans`. `backend/app/routers/geo.py``GET /api/geo/lookup/{ip}`. New `backend/app/models/geo.py` with `GeoDetail` and `IpLookupResponse`. All three routers registered in `main.py`.
### 6.4 Build the jail overview page (frontend) ✅
**Done.** `frontend/src/pages/JailsPage.tsx` fully implemented. Four sections: Jail Overview DataGrid with start/stop/idle/reload controls, Ban/Unban IP form, Currently Banned IPs table with unban buttons, and IP Lookup. Types in `frontend/src/types/jail.ts`. API module at `frontend/src/api/jails.ts`. Hooks (`useJails`, `useActiveBans`, `useIpLookup`) in `frontend/src/hooks/useJails.ts`.
### 6.5 Build the jail detail page (frontend) ✅
**Done.** `frontend/src/pages/JailDetailPage.tsx` fully implemented. Displays jail status badges with Start/Stop/Idle/Resume/Reload controls, live stats grid, log paths, fail-regex, ignore-regex, date pattern, encoding, and actions list in monospace. Breadcrumb navigation back to the jails list.
### 6.6 Build the ban/unban UI (frontend) ✅
**Done.** Ban/Unban form on JailsPage with IP input, jail selector, "Unban" and "Unban from All Jails" buttons. "Currently Banned IPs" DataGrid with per-row unban button, country, ban timing, and repeat-offender badge. MessageBar feedback on success/error.
### 6.7 Implement IP lookup endpoint and UI ✅
**Done.** `GET /api/geo/lookup/{ip}` returns currently-banned jails and geo info. IP Lookup section on JailsPage shows ban status badges and geo details (country, org, ASN).
### 6.8 Implement the ignore list (whitelist) endpoints and UI ✅
**Done.** All ignore-list endpoints implemented in the jails router. "Ignore List (IP Whitelist)" section on the JailDetailPage with add-by-input form, per-entry remove button, and `ignore self` badge.
### 6.9 Write tests for jail and ban features ✅
**Done.** `backend/tests/test_services/test_jail_service.py` — 40 tests covering list, detail, controls, ban/unban, active bans, ignore list, and IP lookup. `backend/tests/test_routers/test_jails.py`, `test_bans.py`, `test_geo.py` — 36 router tests. Total: 217 tests, all pass. Coverage 76%.
---
## Stage 7 — Configuration View ✅ DONE
This stage lets users inspect and edit fail2ban configuration directly from the web interface.
### 7.1 Implement the config service ✅ DONE
Built `backend/app/services/config_service.py` (~613 lines). Reads active jail config via parallel `asyncio.gather` across 10 socket commands per jail. Writes via `set <jail> <key> <val>` commands. `_replace_regex_list` diffs old/new patterns using `contextlib.suppress(ValueError)`. In-process regex validation via the `re` module with `ConfigValidationError` on failure. `test_regex` is synchronous/pure-Python (no socket). `preview_log` reads file tail via `_read_tail_lines` (executor) and pattern-tests each line. Custom exceptions: `JailNotFoundError`, `ConfigValidationError`, `ConfigOperationError`.
### 7.2 Implement the config router ✅ DONE
Created `backend/app/routers/config.py` (~310 lines) with 9 endpoints:
- `GET /api/config/jails``JailConfigListResponse`
- `GET /api/config/jails/{name}``JailConfigResponse` (404 on unknown jail)
- `PUT /api/config/jails/{name}` → 204 (422 on bad regex, 400 on socket error)
- `GET /api/config/global``GlobalConfigResponse`
- `PUT /api/config/global` → 204
- `POST /api/config/reload` → 204
- `POST /api/config/regex-test``RegexTestResponse`
- `POST /api/config/jails/{name}/logpath` → 204
- `POST /api/config/preview-log``LogPreviewResponse`
Models expanded in `backend/app/models/config.py`: `JailConfig`, `JailConfigResponse`, `JailConfigListResponse`, `JailConfigUpdate`, `GlobalConfigResponse`, `GlobalConfigUpdate`, `AddLogPathRequest`, `LogPreviewRequest`, `LogPreviewLine`, `LogPreviewResponse`.
### 7.3 Implement log observation endpoints ✅ DONE
`POST /api/config/jails/{name}/logpath` — adds a new log path via `set <jail> addlogpath <path> tail|head`. `POST /api/config/preview-log` — reads the last N lines from a server-side log file and tests each line against a provided fail-regex, returning `LogPreviewResponse` with per-line match status and aggregate counts.
### 7.4 Implement the regex tester endpoint ✅ DONE
`POST /api/config/regex-test` implemented as a stateless, synchronous endpoint (no socket). Compiles the provided pattern with `re.compile`, applies it to the sample log line, returns `RegexTestResponse` with `matched` bool, `groups` list, and `error` string on invalid regex.
### 7.5 Implement server settings endpoints ✅ DONE
Created `backend/app/services/server_service.py` (~165 lines) and `backend/app/routers/server.py` (~115 lines):
- `GET /api/server/settings``ServerSettingsResponse` (parallel gather of 6 settings)
- `PUT /api/server/settings` → 204
- `POST /api/server/flush-logs``{"message": str}`
Custom exception: `ServerOperationError`.
### 7.6 Build the configuration page (frontend) ✅ DONE
Created `frontend/src/pages/ConfigPage.tsx` with four tabs:
- **Jails** — Accordion of all jails, each expandable with editable ban_time/find_time/max_retry, `RegexList` component for fail_regex/ignore_regex (add/remove inline), read-only log_paths/backend/actions, Save button per jail, Reload fail2ban button.
- **Global** — log_level dropdown, log_target input, db_purge_age/db_max_matches number inputs, Save button.
- **Server** — same plus read-only db_path/syslog_socket, Flush Logs button.
- **Regex Tester** — pattern + log line inputs, "Test Pattern" button with match badge + groups, plus log file preview section.
### 7.7 Build the regex tester UI (frontend) ✅ DONE
"Regex Tester" tab in `ConfigPage.tsx`. Pattern input (monospace) + sample log-line Textarea. On click calls `POST /api/config/regex-test` via `useRegexTester` hook. Displays match/no-match `Badge` with icon and lists captured groups. Below it: log file preview form calling `POST /api/config/preview-log`, renders each line color-coded (green = matched, neutral = no match) with summary count.
### 7.8 Build the server settings UI (frontend) ✅ DONE
"Server" tab in `ConfigPage.tsx`. Shows all six settings editable (log_level dropdown, log_target, db_purge_age, db_max_matches) plus read-only db_path and syslog_socket fields. Includes "Flush Logs" button via `useServerSettings` hook. All via `frontend/src/api/config.ts` and `frontend/src/hooks/useConfig.ts`.
Also created `frontend/src/types/config.ts` (all TS interfaces) and fixed pre-existing lint errors across the codebase: deprecated `JSX.Element``React.JSX.Element` in 10 files, void/promise patterns in `useServerStatus.ts` and `useJails.ts`, `no-misused-spread` in `client.ts`, `eslint.config.ts` excluded from linting.
### 7.9 Write tests for configuration features ✅ DONE
285 backend tests pass (68 new vs 217 before Stage 7). New test files:
- `backend/tests/test_services/test_config_service.py``TestGetJailConfig`, `TestListJailConfigs`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestTestRegex`, `TestPreviewLog`
- `backend/tests/test_services/test_server_service.py``TestGetSettings`, `TestUpdateSettings`, `TestFlushLogs`
- `backend/tests/test_routers/test_config.py``TestGetJailConfigs`, `TestGetJailConfig`, `TestUpdateJailConfig`, `TestGetGlobalConfig`, `TestUpdateGlobalConfig`, `TestReloadFail2ban`, `TestRegexTest`, `TestAddLogPath`, `TestPreviewLog`
- `backend/tests/test_routers/test_server.py``TestGetServerSettings`, `TestUpdateServerSettings`, `TestFlushLogs`
Backend linters: `ruff check` clean, `mypy app/` clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` clean (0 errors, 0 warnings).
---
## Stage 8 — World Map View ✅ DONE
A geographical visualisation of ban activity. This stage depends on the geo service from Stage 5 and the ban data pipeline from Stage 5.
### 8.1 Implement the map data endpoint ✅ DONE
Added `GET /api/dashboard/bans/by-country` to `backend/app/routers/dashboard.py`. Added `BansByCountryResponse` model (`countries: dict[str, int]`, `country_names: dict[str, str]`, `bans: list[DashboardBanItem]`, `total: int`) to `backend/app/models/ban.py`. Implemented `bans_by_country()` in `backend/app/services/ban_service.py` — fetches up to 2 000 bans from the window, deduplicates IPs, resolves geo concurrently with `asyncio.gather`, then aggregates by ISO alpha-2 country code.
### 8.2 Build the world map component (frontend) ✅ DONE
Created `frontend/src/data/isoNumericToAlpha2.ts` — static 249-entry mapping of ISO 3166-1 numeric codes (as used in world-atlas TopoJSON `geo.id`) to alpha-2 codes. Created `frontend/src/components/WorldMap.tsx` using `react-simple-maps@3.0.0`. Renders a Mercator SVG world map with per-country colour intensity scaled from the maximum ban count. Countries with bans show the count in text. Selected country highlighted with brand accent colour. Uses a nested `GeoLayer` component (inside `ComposableMap`) to call `useGeographies` within the map context. Clicking a country toggles its filter; clicking again clears it.
### 8.3 Build the map page (frontend) ✅ DONE
Replaced placeholder `frontend/src/pages/MapPage.tsx` with a full implementation. Includes a time-range `Select` (24h/7d/30d/365d), the `WorldMap` component, an active-filter info bar showing the selected country name and ban count with a "Clear filter" button, a summary line (total bans + number of countries), and a companion FluentUI `Table` filtered by selected country (columns: IP, Jail, Banned At, Country, Times Banned). Created `frontend/src/hooks/useMapData.ts` and `frontend/src/api/map.ts` with proper abort-controller cleanup and ESLint-clean void patterns. Created `frontend/src/types/map.ts` with `TimeRange`, `MapBanItem`, `BansByCountryResponse`.
### 8.4 Write tests for the map data endpoint ✅ DONE
Added `TestBansByCountry` class (5 tests) to `backend/tests/test_routers/test_dashboard.py`: `test_returns_200_when_authenticated`, `test_returns_401_when_unauthenticated`, `test_response_shape`, `test_accepts_time_range_param`, `test_empty_window_returns_empty_response`. Total backend tests: 290 (all passing). ruff clean, mypy clean (44 files). Frontend: `tsc --noEmit` clean, `eslint` 0 warnings/errors.
---
## Stage 9 — Ban History ✅ DONE
This stage exposes historical ban data from the fail2ban database for forensic exploration.
### 9.1 Implement the history service ✅ DONE
Built `backend/app/services/history_service.py`. `list_history()` queries the fail2ban DB with dynamic WHERE clauses: time range (`range_=None` = all-time, otherwise filters by `timeofban >= now - delta`), jail (exact match), IP (LIKE prefix `%`), and page/page_size. `get_ip_detail()` aggregates all ban events for a given IP into an `IpDetailResponse` with timeline, total bans, total failures, last_ban_at, and geo data — returns `None` if no records. Reuses `_get_fail2ban_db_path`, `_parse_data_json`, `_ts_to_iso` from `ban_service`. Also fixed a latent bug in `_parse_data_json` in `ban_service.py`: `json.loads("null")` returns Python `None` rather than a dict, causing `AttributeError` on `.get()`; fixed by checking `isinstance(parsed, dict)` before assigning `obj`.
### 9.2 Implement the history router ✅ DONE
Created `backend/app/routers/history.py`:
- `GET /api/history` — paginated list with optional filters: `range` (`TimeRange` enum or omit for all-time), `jail` (exact), `ip` (prefix), `page`, `page_size`. Returns `HistoryListResponse`.
- `GET /api/history/{ip}` — per-IP detail returning `IpDetailResponse`; raises `HTTPException(404)` if `get_ip_detail()` returns `None`.
Models defined in `backend/app/models/history.py`: `HistoryBanItem`, `HistoryListResponse`, `IpTimelineEvent`, `IpDetailResponse`. Results enriched with geo data via `geo_service.lookup`. Router registered in `main.py`.
### 9.3 Build the history page (frontend) ✅ DONE
Replaced placeholder `frontend/src/pages/HistoryPage.tsx` with full implementation. Filter bar: time-range `Select` (All time + 4 presets), jail `Input`, IP prefix `Input`, Apply/Clear buttons. FluentUI `DataGrid` table with columns: Banned At, IP (monospace, clickable), Jail, Country, Failures, Times Banned. Rows with `ban_count ≥ 5` highlighted amber. Clicking an IP opens `IpDetailView` sub-component with summary grid and timeline `Table`. Pagination with ChevronLeft/ChevronRight buttons. Created supporting files: `frontend/src/types/history.ts`, `frontend/src/api/history.ts`, `frontend/src/hooks/useHistory.ts` (`useHistory` pagination hook + `useIpHistory` detail hook).
### 9.4 Write tests for history features ✅ DONE
Added `tests/test_routers/test_history.py` (11 tests) and `tests/test_services/test_history_service.py` (16 tests). Service tests use a real temporary SQLite DB seeded with 4 rows across two jails and 3 IPs. Router tests mock the service layer. Coverage: time-range filter, jail filter, IP prefix filter, combined filters, unknown IP → `None`, pagination, null data column, geo enrichment, 404 response, timeline aggregation, total_failures. All 317 backend tests pass (27 new), ruff clean, mypy clean (46 files). Frontend `tsc --noEmit` and `npm run lint` clean (0 errors/warnings).
---
## Stage 10 — External Blocklist Importer ✅ DONE
This stage adds the ability to automatically download and apply external IP blocklists on a schedule.
### 10.1 Implement the blocklist repository ✅ DONE
`backend/app/repositories/blocklist_repo.py` — CRUD for `blocklist_sources` table (`create_source`, `get_source`, `list_sources`, `list_enabled_sources`, `update_source`, `delete_source`). `backend/app/repositories/import_log_repo.py``add_log`, `list_logs` (paginated, optional `source_id` filter), `get_last_log`, `compute_total_pages`. 18 repository tests pass.
### 10.2 Implement the blocklist service ✅ DONE
`backend/app/services/blocklist_service.py` — source CRUD, `preview_source` (downloads max 64 KB, validates, returns sample), `import_source` (downloads, validates IPs via `ipaddress`, bans through fail2ban blocklist jail, logs result), `import_all` (iterates all enabled sources), `get_schedule`/`set_schedule` (JSON in settings table), `get_schedule_info` (config + last run + next run). 19 service tests pass.
### 10.3 Implement the blocklist import scheduled task ✅ DONE
`backend/app/tasks/blocklist_import.py` — APScheduler job with stable ID `"blocklist_import"`. Supports three frequencies: `hourly` (interval trigger, every N hours), `daily` (cron trigger, UTC hour+minute), `weekly` (cron trigger, day_of_week+hour+minute). `register(app)` called at startup; `reschedule(app)` replaces the job after a schedule update.
### 10.4 Implement the blocklist router ✅ DONE
`backend/app/routers/blocklist.py` — 9 endpoints on `/api/blocklists`: list sources, create source (201), trigger manual import (`POST /import`), get schedule info, update schedule, paginated import log, get/update/delete single source, preview blocklist contents. Static path segments (`/import`, `/schedule`, `/log`) registered before `/{source_id}`. All endpoints authenticated. 22 router tests pass.
### 10.5 Build the blocklist management page (frontend) ✅ DONE
`frontend/src/pages/BlocklistsPage.tsx` (≈950 lines) — `SourceFormDialog` (create/edit), `PreviewDialog`, `ImportResultDialog`, `SourcesSection` (table with enable/disable switch), `ScheduleSection` (frequency picker + hour/minute/day-of-week selectors + next run display), `ImportLogSection` (paginated table with error badge). Supporting files: `frontend/src/types/blocklist.ts`, `frontend/src/api/blocklist.ts`, `frontend/src/hooks/useBlocklist.ts`. TypeScript + ESLint clean.
### 10.6 Write tests for blocklist features ✅ DONE
18 repository tests, 19 service tests, 22 router tests — 59 total. All 374 backend tests pass. `ruff` clean, `mypy` clean for Stage 10 files.
---
## Stage 11 — Polish, Cross-Cutting Concerns & Hardening ✅ DONE
This final stage covers everything that spans multiple features or improves the overall quality of the application.
### 11.1 Implement connection health indicator ✅
**Done.** `MainLayout.tsx` reads from `useServerStatus()` and shows a Fluent UI `MessageBar` (intent="warning") at the top of the layout whenever fail2ban is unreachable. The bar is dismissed automatically as soon as the next health poll reports recovery. No extra API calls — reads the cached status from the context established in Stage 4.
### 11.2 Add timezone awareness ✅
**Done.** Added `GET /api/setup/timezone` endpoint (`setup_service.get_timezone`, `SetupTimezoneResponse` model). Created `frontend/src/utils/formatDate.ts` with `formatDate()`, `formatDateShort()`, and `formatRelative()` using `Intl.DateTimeFormat` with IANA timezone support and UTC fallback. Created `frontend/src/providers/TimezoneProvider.tsx` which fetches the timezone once on mount and exposes it via `useTimezone()` hook. `App.tsx` wraps authenticated routes with `<TimezoneProvider>`.
### 11.3 Add responsive layout polish ✅
**Done.** `MainLayout.tsx` initialises the collapsed sidebar state based on `window.innerWidth < 640` and adds a `window.matchMedia("(max-width: 639px)")` listener to auto-collapse/expand on resize. All data tables (`BanTable`, `JailsPage`, `HistoryPage`, `BlocklistsPage`) already have `overflowX: "auto"` wrappers. Cards stack vertically via Fluent UI `makeStyles` column flex on small breakpoints.
### 11.4 Add loading and error states ✅
**Done.** Created `frontend/src/components/PageFeedback.tsx` with three reusable components: `PageLoading` (centred `Spinner`), `PageError` (error `MessageBar` with an optional retry `Button` using `ArrowClockwiseRegular`), and `PageEmpty` (neutral centred text). `BanTable.tsx` was updated to use all three, replacing its previous inline implementations. Existing pages (`JailsPage`, `HistoryPage`, `BlocklistsPage`) already had comprehensive inline handling and were left as-is to avoid churn.
### 11.5 Implement reduced-motion support ✅
**Done.** Added `"@media (prefers-reduced-motion: reduce)": { transition: "none" }` to the sidebar `makeStyles` transition in `MainLayout.tsx`. When the OS preference is set, the sidebar panel appears/disappears instantly with no animation.
### 11.6 Add accessibility audit ✅
**Done.** `WorldMap.tsx` updated: the outer `<div>` wrapper now carries `role="img"` and a descriptive `aria-label`. Each clickable country `<g>` element received `role="button"`, `tabIndex={0}`, a dynamic `aria-label` (country code + ban count + selected state), `aria-pressed`, and an `onKeyDown` handler activating on Enter/Space — making the map fully keyboard-navigable.
### 11.7 Add structured logging throughout ✅
**Done.** All services and tasks already had comprehensive structlog coverage from earlier stages. `health_check.py` task was updated to log `fail2ban_came_online` (info, with version) on offline→online transitions and `fail2ban_went_offline` (warning) on online→offline transitions.
### 11.8 Add global error handling ✅
**Done.** Added `_fail2ban_connection_handler` (returns 502 with `{"detail": "fail2ban unavailable"}`) and `_fail2ban_protocol_handler` (returns 502 with `{"detail": "fail2ban protocol error"}`) to `main.py`. Both handlers log the event with structlog before responding. Registered in `create_app()` before the catch-all `_unhandled_exception_handler`, ensuring fail2ban network errors are always surfaced as 502 rather than 500.
### 11.9 Final test pass and coverage check ✅
**Done.** Added 5 new tests: `TestGetTimezone` in `test_routers/test_setup.py` (3 tests) and `test_services/test_setup_service.py` (2 tests). Full suite: **379 tests passed**. Line coverage: **82 %** (exceeds 80 % target). `ruff check` clean. `mypy` reports only pre-existing errors in test helper files (unchanged from Stage 10). `tsc --noEmit` clean. `eslint` clean (0 warnings, 0 errors).
**Fix:** Added `Makefile` at the project root with targets:
- `make up` — start the debug stack (detached)
- `make down` — stop the debug stack
- `make restart` — restart the debug stack
- `make logs` — tail all logs
- `make clean``compose down -v --remove-orphans` (removes all debug volumes)

42
Makefile Normal file
View File

@@ -0,0 +1,42 @@
# ──────────────────────────────────────────────────────────────
# BanGUI — Project Makefile
#
# Compatible with both Docker Compose and Podman Compose.
# Auto-detects which compose binary is available.
#
# Usage:
# make up — start the debug stack
# make down — stop the debug stack
# make clean — stop and remove all debug containers, volumes, and images
# make logs — tail logs for all debug services
# make restart — restart the debug stack
# ──────────────────────────────────────────────────────────────
COMPOSE_FILE := Docker/compose.debug.yml
# Detect available compose binary.
COMPOSE := $(shell command -v podman-compose 2>/dev/null \
|| echo "podman compose")
.PHONY: up down restart logs clean
## Start the debug stack (detached).
up:
$(COMPOSE) -f $(COMPOSE_FILE) up -d
## Stop the debug stack.
down:
$(COMPOSE) -f $(COMPOSE_FILE) down
## Restart the debug stack.
restart: down up
## Tail logs for all debug services.
logs:
$(COMPOSE) -f $(COMPOSE_FILE) logs -f
## Stop containers and remove ALL debug volumes (database, node_modules, fail2ban data).
## This returns the environment to a clean first-run state.
clean:
$(COMPOSE) -f $(COMPOSE_FILE) down -v --remove-orphans
@echo "All debug volumes removed. Run 'make up' to start fresh."

View File

@@ -7,6 +7,7 @@ survive server restarts.
from __future__ import annotations
import asyncio
import secrets
from typing import TYPE_CHECKING
@@ -25,9 +26,12 @@ from app.utils.time_utils import add_minutes, utc_now
log: structlog.stdlib.BoundLogger = structlog.get_logger()
def _check_password(plain: str, hashed: str) -> bool:
async def _check_password(plain: str, hashed: str) -> bool:
"""Return ``True`` if *plain* matches the bcrypt *hashed* password.
Runs in a thread executor so the blocking bcrypt operation does not stall
the asyncio event loop.
Args:
plain: The plain-text password to verify.
hashed: The stored bcrypt hash string.
@@ -35,7 +39,12 @@ def _check_password(plain: str, hashed: str) -> bool:
Returns:
``True`` on a successful match, ``False`` otherwise.
"""
return bool(bcrypt.checkpw(plain.encode(), hashed.encode()))
plain_bytes = plain.encode()
hashed_bytes = hashed.encode()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: bool(bcrypt.checkpw(plain_bytes, hashed_bytes))
)
async def login(
@@ -61,7 +70,7 @@ async def login(
log.warning("bangui_login_no_hash")
raise ValueError("No password is configured — run setup first.")
if not _check_password(password, stored_hash):
if not await _check_password(password, stored_hash):
log.warning("bangui_login_wrong_password")
raise ValueError("Incorrect password.")

View File

@@ -7,6 +7,7 @@ enforcing the rule that setup can only run once.
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import bcrypt
@@ -72,8 +73,13 @@ async def run_setup(
log.info("bangui_setup_started")
# Hash the master password — bcrypt automatically generates a salt.
# Run in a thread executor so the blocking bcrypt operation does not stall
# the asyncio event loop.
password_bytes = master_password.encode()
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode()
loop = asyncio.get_running_loop()
hashed: str = await loop.run_in_executor(
None, lambda: bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode()
)
await settings_repo.set_setting(db, _KEY_PASSWORD_HASH, hashed)
await settings_repo.set_setting(db, _KEY_DATABASE_PATH, database_path)

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import inspect
from pathlib import Path
import aiosqlite
@@ -30,6 +32,50 @@ async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc]
await conn.close()
@pytest.fixture
async def db_no_setup(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc]
"""Provide an initialised DB with no setup performed."""
conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "auth_nosetup.db"))
conn.row_factory = aiosqlite.Row
await init_db(conn)
yield conn
await conn.close()
class TestCheckPasswordAsync:
async def test_check_password_is_coroutine_function(self) -> None:
"""_check_password must be a coroutine function (runs in thread executor)."""
assert inspect.iscoroutinefunction(auth_service._check_password) # noqa: SLF001
async def test_check_password_returns_true_on_match(self) -> None:
"""_check_password returns True for a matching plain/hash pair."""
import bcrypt
hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode()
result = await auth_service._check_password("secret", hashed) # noqa: SLF001
assert result is True
async def test_check_password_returns_false_on_mismatch(self) -> None:
"""_check_password returns False when the password does not match."""
import bcrypt
hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode()
result = await auth_service._check_password("wrong", hashed) # noqa: SLF001
assert result is False
async def test_check_password_does_not_block_event_loop(self) -> None:
"""_check_password awaits without blocking; event-loop tasks can interleave."""
import bcrypt
hashed = bcrypt.hashpw(b"secret", bcrypt.gensalt()).decode()
# Running two concurrent checks must complete without deadlock.
results = await asyncio.gather(
auth_service._check_password("secret", hashed), # noqa: SLF001
auth_service._check_password("wrong", hashed), # noqa: SLF001
)
assert results == [True, False]
class TestLogin:
async def test_login_returns_session_on_correct_password(
self, db: aiosqlite.Connection
@@ -47,6 +93,13 @@ class TestLogin:
with pytest.raises(ValueError, match="Incorrect password"):
await auth_service.login(db, password="wrongpassword", session_duration_minutes=60)
async def test_login_raises_when_no_hash_configured(
self, db_no_setup: aiosqlite.Connection
) -> None:
"""login() raises ValueError when setup has not been run."""
with pytest.raises(ValueError, match="No password is configured"):
await auth_service.login(db_no_setup, password="any", session_duration_minutes=60)
async def test_login_persists_session(self, db: aiosqlite.Connection) -> None:
"""login() stores the session in the database."""
from app.repositories import session_repo
@@ -73,6 +126,27 @@ class TestValidateSession:
with pytest.raises(ValueError, match="not found"):
await auth_service.validate_session(db, "deadbeef" * 8)
async def test_validate_raises_for_expired_session(
self, db: aiosqlite.Connection
) -> None:
"""validate_session() raises ValueError and removes an expired session."""
from app.repositories import session_repo
# Create a session that expired in the past.
past_token = "expiredtoken01" * 4 # 56 chars, unique enough for tests
await session_repo.create_session(
db,
token=past_token,
created_at="2000-01-01T00:00:00+00:00",
expires_at="2000-01-01T01:00:00+00:00",
)
with pytest.raises(ValueError, match="expired"):
await auth_service.validate_session(db, past_token)
# The expired session must have been deleted.
assert await session_repo.get_session(db, past_token) is None
class TestLogout:
async def test_logout_removes_session(self, db: aiosqlite.Connection) -> None:

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
import inspect
from pathlib import Path
import aiosqlite
@@ -115,3 +117,34 @@ class TestGetTimezone:
session_duration_minutes=60,
)
assert await setup_service.get_timezone(db) == "America/New_York"
class TestRunSetupAsync:
"""Verify the async/non-blocking bcrypt behavior of run_setup."""
async def test_run_setup_is_coroutine_function(self) -> None:
"""run_setup must be declared as an async function."""
assert inspect.iscoroutinefunction(setup_service.run_setup)
async def test_password_hash_does_not_block_event_loop(
self, db: aiosqlite.Connection
) -> None:
"""run_setup completes without blocking; other coroutines can interleave."""
async def noop() -> str:
"""A trivial coroutine that should run concurrently with setup."""
await asyncio.sleep(0)
return "ok"
setup_coro = setup_service.run_setup(
db,
master_password="mypassword1",
database_path="bangui.db",
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
timezone="UTC",
session_duration_minutes=60,
)
# Both tasks should finish without error.
results = await asyncio.gather(setup_coro, noop())
assert results[1] == "ok"
assert await setup_service.is_setup_complete(db) is True

View File

@@ -7,8 +7,8 @@
* 3. `AuthProvider` — manages session state and exposes `useAuth()`.
*
* Routes:
* - `/setup` — first-run setup wizard (always accessible)
* - `/login` — master password login
* - `/setup` — first-run setup wizard (always accessible; redirects to /login if already done)
* - `/login` — master password login (redirects to /setup if not done)
* - `/` — dashboard (protected, inside MainLayout)
* - `/map` — world map (protected)
* - `/jails` — jail list (protected)
@@ -25,6 +25,7 @@ import { lightTheme } from "./theme/customTheme";
import { AuthProvider } from "./providers/AuthProvider";
import { TimezoneProvider } from "./providers/TimezoneProvider";
import { RequireAuth } from "./components/RequireAuth";
import { SetupGuard } from "./components/SetupGuard";
import { MainLayout } from "./layouts/MainLayout";
import { SetupPage } from "./pages/SetupPage";
import { LoginPage } from "./pages/LoginPage";
@@ -45,18 +46,29 @@ function App(): React.JSX.Element {
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Public routes */}
{/* Setup wizard — always accessible; redirects to /login if already done */}
<Route path="/setup" element={<SetupPage />} />
<Route path="/login" element={<LoginPage />} />
{/* Protected routes — all rendered inside MainLayout */}
{/* Login — requires setup to be complete */}
<Route
path="/login"
element={
<SetupGuard>
<LoginPage />
</SetupGuard>
}
/>
{/* Protected routes — require setup AND authentication */}
<Route
element={
<RequireAuth>
<TimezoneProvider>
<MainLayout />
</TimezoneProvider>
</RequireAuth>
<SetupGuard>
<RequireAuth>
<TimezoneProvider>
<MainLayout />
</TimezoneProvider>
</RequireAuth>
</SetupGuard>
}
>
<Route index element={<DashboardPage />} />

View File

@@ -8,15 +8,20 @@
import { api } from "./client";
import { ENDPOINTS } from "./endpoints";
import type { LoginRequest, LoginResponse, LogoutResponse } from "../types/auth";
import { sha256Hex } from "../utils/crypto";
/**
* Authenticate with the master password.
*
* The password is SHA-256 hashed client-side before transmission so that
* the plaintext never leaves the browser. The backend bcrypt-verifies the
* received hash against the stored bcrypt(sha256) digest.
*
* @param password - The master password entered by the user.
* @returns The login response containing the session token.
*/
export async function login(password: string): Promise<LoginResponse> {
const body: LoginRequest = { password };
const body: LoginRequest = { password: await sha256Hex(password) };
return api.post<LoginResponse>(ENDPOINTS.authLogin, body);
}

View File

@@ -0,0 +1,65 @@
/**
* Route guard component.
*
* Protects all routes by ensuring the initial setup wizard has been
* completed. If setup is not done yet, the user is redirected to `/setup`.
* While the status is loading a full-screen spinner is shown.
*/
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { Spinner } from "@fluentui/react-components";
import { getSetupStatus } from "../api/setup";
type Status = "loading" | "done" | "pending";
interface SetupGuardProps {
/** The protected content to render when setup is complete. */
children: React.JSX.Element;
}
/**
* Render `children` only when setup has been completed.
*
* Redirects to `/setup` if setup is still pending.
*/
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
const [status, setStatus] = useState<Status>("loading");
useEffect(() => {
let cancelled = false;
getSetupStatus()
.then((res): void => {
if (!cancelled) setStatus(res.completed ? "done" : "pending");
})
.catch((): void => {
// If the check fails, optimistically allow through — the backend will
// redirect API calls to /api/setup anyway.
if (!cancelled) setStatus("done");
});
return (): void => {
cancelled = true;
};
}, []);
if (status === "loading") {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<Spinner size="large" label="Loading…" />
</div>
);
}
if (status === "pending") {
return <Navigate to="/setup" replace />;
}
return children;
}

View File

@@ -6,7 +6,7 @@
* All fields use Fluent UI v9 components and inline validation.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Button,
Field,
@@ -21,7 +21,8 @@ import {
import { useNavigate } from "react-router-dom";
import type { ChangeEvent, FormEvent } from "react";
import { ApiError } from "../api/client";
import { submitSetup } from "../api/setup";
import { getSetupStatus, submitSetup } from "../api/setup";
import { sha256Hex } from "../utils/crypto";
// ---------------------------------------------------------------------------
// Styles
@@ -105,6 +106,17 @@ export function SetupPage(): React.JSX.Element {
const [apiError, setApiError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Redirect to /login if setup has already been completed.
useEffect(() => {
getSetupStatus()
.then((res) => {
if (res.completed) navigate("/login", { replace: true });
})
.catch(() => {
/* ignore — stay on setup page */
});
}, [navigate]);
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
@@ -149,8 +161,11 @@ export function SetupPage(): React.JSX.Element {
setSubmitting(true);
try {
// Hash the password client-side before transmission — the plaintext
// never leaves the browser. The backend bcrypt-hashes the received hash.
const hashedPassword = await sha256Hex(values.masterPassword);
await submitSetup({
master_password: values.masterPassword,
master_password: hashedPassword,
database_path: values.databasePath,
fail2ban_socket: values.fail2banSocket,
timezone: values.timezone,

View File

@@ -0,0 +1,23 @@
/**
* Client-side cryptography utilities.
*
* Uses the browser-native SubtleCrypto API so no third-party bundle is required.
*/
/**
* Return the SHA-256 hex digest of `input`.
*
* Hashing passwords before transmission means the plaintext never leaves the
* browser, even when HTTPS is not enforced in a development environment.
* The backend then applies bcrypt on top of the received hash.
*
* @param input - The string to hash (e.g. the master password).
* @returns Lowercase hex-encoded SHA-256 digest.
*/
export async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}