diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 93310c5..40df264 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -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=` | +| `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.) | --- diff --git a/Docs/Features.md b/Docs/Features.md index aba3372..1b9794c 100644 --- a/Docs/Features.md +++ b/Docs/Features.md @@ -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 diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 2fbebe8..e12b78e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -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 `` and ``. - -### 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 `` 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=` 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 ` 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 `` 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 → ``, 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 ``. `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 ` 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 addlogpath 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 ``. - -### 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 `
` wrapper now carries `role="img"` and a descriptive `aria-label`. Each clickable country `` 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) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8fdd56d --- /dev/null +++ b/Makefile @@ -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." diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 18f7a2a..a947bcf 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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.") diff --git a/backend/app/services/setup_service.py b/backend/app/services/setup_service.py index d79df07..e8a2e9a 100644 --- a/backend/app/services/setup_service.py +++ b/backend/app/services/setup_service.py @@ -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) diff --git a/backend/tests/test_services/test_auth_service.py b/backend/tests/test_services/test_auth_service.py index 639cd33..d30a8b5 100644 --- a/backend/tests/test_services/test_auth_service.py +++ b/backend/tests/test_services/test_auth_service.py @@ -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: diff --git a/backend/tests/test_services/test_setup_service.py b/backend/tests/test_services/test_setup_service.py index 153d3dd..7991b15 100644 --- a/backend/tests/test_services/test_setup_service.py +++ b/backend/tests/test_services/test_setup_service.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed0abfa..3b73134 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { - {/* Public routes */} + {/* Setup wizard — always accessible; redirects to /login if already done */} } /> - } /> - {/* Protected routes — all rendered inside MainLayout */} + {/* Login — requires setup to be complete */} + + + + } + /> + + {/* Protected routes — require setup AND authentication */} - - - - + + + + + + + } > } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index d652ed2..6fc8266 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -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 { - const body: LoginRequest = { password }; + const body: LoginRequest = { password: await sha256Hex(password) }; return api.post(ENDPOINTS.authLogin, body); } diff --git a/frontend/src/components/SetupGuard.tsx b/frontend/src/components/SetupGuard.tsx new file mode 100644 index 0000000..f448f9d --- /dev/null +++ b/frontend/src/components/SetupGuard.tsx @@ -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("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 ( +
+ +
+ ); + } + + if (status === "pending") { + return ; + } + + return children; +} diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index 838a869..d719950 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -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(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, diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts new file mode 100644 index 0000000..721b521 --- /dev/null +++ b/frontend/src/utils/crypto.ts @@ -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 { + 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(""); +}