diff --git a/AGENTS.md b/AGENTS.md index 5c5d4ed..7ca96d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ through the daemon client over a Unix socket. ### Code Layout -- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja). All other paths return 404. +- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) serves `index.html` (no templating). All other paths return 404. - `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api//`. All call `daemon.client` instead of `lib/` directly. - `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints. - `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling. diff --git a/daemon/handlers/auth.py b/daemon/handlers/auth.py index bd17da7..47e4327 100644 --- a/daemon/handlers/auth.py +++ b/daemon/handlers/auth.py @@ -34,7 +34,6 @@ from lib.auth import ( blacklist_token, check_login_rate, check_webauthn_rate, - clear_active_refresh_token, generate_tokens, get_access_ttl, record_login_failure, @@ -70,11 +69,6 @@ from lib.webauthn import ( logger = logging.getLogger(__name__) -def _clear_refresh_token_after_rotation(username: str) -> None: - """Remove the user's entry from refresh_tokens after a successful refresh rotation.""" - clear_active_refresh_token(username) - - @registry.register(POST_AUTH_LOGIN) def auth_login(_request: Any, body: Any) -> dict[str, Any]: """Handle user login. @@ -196,8 +190,6 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]: jti = payload.get("jti") if jti: blacklist_token(jti, token_type="refresh") - if username: - _clear_refresh_token_after_rotation(username) return { "tokens": tokens, diff --git a/daemon/server.py b/daemon/server.py index 6c1c523..5432488 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -375,17 +375,24 @@ async def _handle_ws(request: web.Request) -> web.Response: Authentication: JWT access token passed via: 1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer ") - 2. X-Auth-Token header (nginx-injected) + — the bundled client path. + 2. X-Auth-Token header — fallback for custom nginx setups that inject it + (not set by the bundled nginx config). """ + from aiohttp import hdrs + from lib.auth import validate_token token_param = None matched_proto = None - # Prefer subprotocol header (client JS sends "Bearer ") - subprotocols = request.get_subprotocols() - for proto in subprotocols or []: - if proto and proto.startswith("Bearer "): + # Prefer subprotocol header (client JS sends "Bearer "). + # Sec-WebSocket-Protocol is a comma-separated list; parse it the same + # way aiohttp's own handshake does (Request has no subprotocol helper). + protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "") + subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()] + for proto in subprotocols: + if proto.startswith("Bearer "): token_param = proto[7:] matched_proto = proto break diff --git a/docs/api.md b/docs/api.md index db3f896..707dff1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -206,7 +206,13 @@ Create a new user with password and per-subsystem permissions. | `password` | `string` | Yes | Plain-text password | | `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | -**Response:** `data` is `null` on success. +**Response (`data`):** + +| Field | Type | Description | +|---|---|---| +| `id` | `int` | User ID | +| `username` | `string` | Username | +| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | Returns HTTP `409` if username already exists. @@ -226,7 +232,13 @@ Update user's permissions. (To change a password, use `POST /api/auth/password`. |---|---|---|---| | `permissions` | `object` | No | New per-subsystem permissions | -**Response:** `data` is `null` on success. +**Response (`data`):** + +| Field | Type | Description | +|---|---|---| +| `id` | `int` | User ID | +| `username` | `string` | Username | +| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | Returns HTTP `404` if user not found. @@ -240,7 +252,7 @@ Delete a user and all associated permissions and WebAuthn credentials (CASCADE). **Auth:** `auth: "rw"` required. Cannot delete self. -**Response:** `data` is `null` on success. +**Response:** `data` is `{"ok": true}` on success. Returns HTTP `404` if user not found. diff --git a/docs/architecture.md b/docs/architecture.md index 6ab9948..039983e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -287,7 +287,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact data/ ├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds ├── nginx/ -│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI +│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only) │ └── sites-enabled/ # Generated nginx server block .conf files (one per domain) ├── dnsmasq/ │ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim) @@ -328,7 +328,7 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh ### Request Flow (Frontend) ``` -Client requests / ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution) +Client requests / ──→ nginx ──→ Flask (serves index.html) Client loads /static/app.js ──→ Hoover initializes, checkSession() → if no valid session, render #login Authenticated ──→ mounts #sidebar and #main render roots apiFetch() ──→ injects Authorization: Bearer header ──→ Flask REST API diff --git a/docs/hoover.md b/docs/hoover.md index de4f74f..7cd8c18 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -34,7 +34,7 @@ index.html — static shell with #sidebar, #main, #modal-root └── connect() — WebSocket lifecycle ``` -The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing. +The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM. @@ -544,7 +544,7 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] }) ### `connect()` -Start the WebSocket connection to the daemon at `ws:///ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s). +Start the WebSocket connection to the daemon at `ws:///ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s). The JWT is read from the auth model and sent in the WebSocket subprotocol header (`Bearer `). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise. diff --git a/docs/security.md b/docs/security.md index 7d0ac85..f8deca6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -97,14 +97,14 @@ JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The 1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued. 2. **Validation**: Every request to Flask includes `Authorization: Bearer `. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions. 3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued. -4. **Blacklist**: On logout (`POST /api/auth/logout`) or password change, the current token's `jti` is inserted into `token_blacklist`. The expired blacklist entries are cleaned on every refresh operation via `Q_DELETE_EXPIRED`. +4. **Blacklist**: On logout (`POST /api/auth/logout`), password change, or user deletion, the affected token's `jti` is inserted into `token_blacklist`. On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (default 60s) and by a probabilistic check inside `blacklist_token()`. Token theft protection: - Short-lived access tokens (15 min) limit the window of exploitation - Token blacklist prevents reuse after logout or password change - XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain -**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the token is passed via the `Sec-WebSocket-Protocol` subprotocol or an nginx-injected `X-Auth-Token` header. This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk. +**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled nginx config passes the token via the `Sec-WebSocket-Protocol` subprotocol header (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk. ### WebAuthn Security diff --git a/lib/auth.py b/lib/auth.py index 9e685ab..13b748d 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -217,19 +217,6 @@ def blacklist_active_refresh_token(username: str) -> None: db.run(Q_DELETE_REFRESH_TOKEN, (username,)) -def clear_active_refresh_token(username: str) -> None: - """Remove the user's stored refresh token entry without blacklisting. - - Used when the refresh token has already been blacklisted (e.g., during - a successful refresh rotation). - - Args: - username: The username. - """ - db = get_db() - db.run(Q_DELETE_REFRESH_TOKEN, (username,)) - - def _extract_unverified_sub(token_string: str) -> str | None: """Extract the ``sub`` claim from a JWT payload without signature verification. @@ -254,6 +241,8 @@ def _extract_unverified_sub(token_string: str) -> str | None: payload_b64 += "=" * padding payload_json = base64.urlsafe_b64decode(payload_b64) payload = json.loads(payload_json) + if not isinstance(payload, dict): + return None return payload.get("sub") except (ValueError, json.JSONDecodeError, UnicodeDecodeError): return None diff --git a/lib/auth_users.py b/lib/auth_users.py index e846a13..9e31bd6 100644 --- a/lib/auth_users.py +++ b/lib/auth_users.py @@ -207,6 +207,31 @@ def update_password(username: str, old_password: str, new_password: str) -> bool return True +def reset_password(username: str, new_password: str) -> None: + """Force-reset a user's password without verifying the old one. + + Non-interactive variant for install-time and lockout recovery: the + installer does not know the previous password by construction. Rotates + the user's JWT secret and blacklists the active refresh token, + invalidating all existing sessions. + + Args: + username: The user to reset. + new_password: New plain-text password. + + Raises: + ValueError: If the user does not exist. + """ + if find_user(username) is None: + raise ValueError(f"User {username!r} not found") + + blacklist_active_refresh_token(username) + new_hash = hash_password(new_password) + rotate_user_secret(username) + db = get_db() + db.run(Q_UPDATE_PASSWORD, (new_hash, username)) + + def update_permissions(username: str, permissions: dict[str, str]) -> None: """Update a user's permissions and invalidate all existing tokens. diff --git a/lib/db.py b/lib/db.py index eff93c4..dc61ab1 100644 --- a/lib/db.py +++ b/lib/db.py @@ -19,6 +19,7 @@ from __future__ import annotations import logging import os import secrets +import threading from abc import ABC, abstractmethod from pathlib import Path from typing import Any, ClassVar @@ -163,16 +164,22 @@ class Database(ABC): def __init__(self, connection_string: str) -> None: self._connection_string = connection_string - self._conn: Any = None self._prepared: dict[str, Any] = {} self._in_transaction = False + # Per-thread connections: backend connection objects (e.g. sqlite3) + # are bound to the thread that created them. The Flask WebUI runs + # requests in worker threads while the daemon uses a single event-loop + # thread, so each thread lazily gets its own connection. + self._local = threading.local() @property def conn(self) -> Any: - """Return the cached database connection, creating it lazily.""" - if self._conn is None: - self._conn = self._connect(self._connection_string) - return self._conn + """Return this thread's cached database connection, creating it lazily.""" + conn = getattr(self._local, "conn", None) + if conn is None: + conn = self._connect(self._connection_string) + self._local.conn = conn + return conn @abstractmethod def _connect(self, cs: str) -> Any: ... @@ -300,12 +307,22 @@ def _seed_builtin_admin(db: Database) -> None: placeholder_hash = hash_password(random_password) jwt_secret = secrets.token_urlsafe(32) - with db.in_transaction() as tx: - tx.run_one( - Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret) + try: + with db.in_transaction() as tx: + tx.run_one( + Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret) + ) + for subsystem in ALL_SUBSYSTEMS: + tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw")) + except Exception as exc: + rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,)) + if not rows: + raise + logger.warning( + "Concurrent builtin admin seed detected (%s); proceeding with existing user", + exc, ) - for subsystem in ALL_SUBSYSTEMS: - tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw")) + return auth_log = Path("/var/log/vacuum-wall/auth.log") auth_log_written = False diff --git a/lib/db_sqlite.py b/lib/db_sqlite.py index fb34418..32bedd3 100644 --- a/lib/db_sqlite.py +++ b/lib/db_sqlite.py @@ -134,6 +134,10 @@ class SQLiteBackend(Database): """Create a SQLite connection with WAL mode and row factory.""" conn = sqlite3.connect(cs, isolation_level=None) conn.execute("PRAGMA journal_mode=WAL") + # Multiple threads/processes hold distinct connections (see + # Database.conn); wait up to 5s for writers instead of failing + # immediately with SQLITE_BUSY. + conn.execute("PRAGMA busy_timeout=5000") conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row return conn diff --git a/lib/nginx.py b/lib/nginx.py index 48c0e03..91afd6f 100644 --- a/lib/nginx.py +++ b/lib/nginx.py @@ -54,6 +54,11 @@ WEBUI_BACKEND: dict[str, Any] = { "/": { "backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"}, "is_management": True, + # The WebUI is protected by JWT at the Flask layer; nginx must + # not gate it with auth_basic (the SPA sends Bearer tokens, which + # suppress the browser's automatic Basic credentials). auth=None + # renders `auth_basic off` even if legacy auth was harvested. + "auth": None, }, "/ws": { "backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"}, diff --git a/lib/system_import.py b/lib/system_import.py index d6af2da..5a82144 100644 --- a/lib/system_import.py +++ b/lib/system_import.py @@ -223,7 +223,14 @@ def _parse_addr_directive(line: str) -> dict[str, Any] | None: def import_wireguard() -> bool: """Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json.""" - if not WG_CONF.exists(): + try: + exists = WG_CONF.exists() + except OSError: + # Parent dir may be unreadable to the daemon user (e.g. /etc/wireguard + # is 0700). Treat as not present rather than failing the import. + logger.debug("Skipping wireguard: cannot stat %s", WG_CONF) + return False + if not exists: logger.debug("Skipping wireguard: %s not found", WG_CONF) return False diff --git a/scripts/bootstrap_auth.py b/scripts/bootstrap_auth.py index 0fbc1f5..e5330be 100644 --- a/scripts/bootstrap_auth.py +++ b/scripts/bootstrap_auth.py @@ -1,7 +1,12 @@ """Bootstrap auth: initialize DB and seed admin user at install time. -Run once during installation. Writes config/auth/config.json with a -generated JWT secret and creates the admin user in SQLite. +Idempotent — safe to run on every install (and re-install): + +- Writes config/auth/config.json only if it does not exist (existing + JWT/WebAuthn settings are preserved). +- Creates the admin user if missing; if the user already exists, updates + the admin password to the provided value (docs/deployment.md: "On + re-run, updates the admin password if already present"). Usage: python scripts/bootstrap_auth.py --project-dir /path/to/project \ @@ -35,38 +40,48 @@ def main() -> None: os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite" os.environ["VACUUM_WALL_DB_PATH"] = db_path - from lib.auth_users import ALL_SUBSYSTEMS, create_user + from lib.auth_users import ( + ALL_SUBSYSTEMS, + create_user, + find_user, + reset_password, + ) - # Write config + # Write config — only if missing, so re-runs never clobber existing + # JWT/WebAuthn settings (e.g. a customized rp_id/origin). config_dir = project_dir / "config" / "auth" config_dir.mkdir(parents=True, exist_ok=True) config_path = config_dir / "config.json" - config = { - "jwt": { - "access_token_ttl": 300, - "refresh_token_ttl": 604800, - "algorithm": "HS256", - }, - "webauthn": { - "rp_name": "Vacuum Wall", - "rp_id": args.domain, - "origin": f"https://{args.domain}", - }, - } + if not config_path.exists(): + config = { + "jwt": { + "access_token_ttl": 300, + "refresh_token_ttl": 604800, + "algorithm": "HS256", + }, + "webauthn": { + "rp_name": "Vacuum Wall", + "rp_id": args.domain, + "origin": f"https://{args.domain}", + }, + } + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + f.write("\n") + print(f"Wrote auth config: {config_path}") + else: + print(f"Auth config already present, leaving unchanged: {config_path}") - with open(config_path, "w") as f: - json.dump(config, f, indent=2) - f.write("\n") - - print(f"Wrote auth config: {config_path}") - - # Initialize DB and create admin user - permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS} - user = create_user(args.username, args.password, permissions) - - print(f"Created admin user: {user['username']} (id={user['id']})") - print(f"Permissions: {len(permissions)} subsystems, all 'rw'") + # Initialize DB and create the admin user, or sync the password on re-run + if find_user(args.username) is not None: + reset_password(args.username, args.password) + print(f"Updated existing user: {args.username} (password synced)") + else: + permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS} + user = create_user(args.username, args.password, permissions) + print(f"Created admin user: {user['username']} (id={user['id']})") + print(f"Permissions: {len(permissions)} subsystems, all 'rw'") if __name__ == "__main__": diff --git a/scripts/install.sh b/scripts/install.sh index b26af8a..d757ffb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -212,15 +212,26 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall} mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme} mkdir -p /etc/wireguard mkdir -p /etc/dnsmasq -# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev +# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev. +# The top-level .git (directory or worktree pointer file) is left untouched so +# the repo owner's git isn't tripped by git's dubious-ownership check. if [[ "$_cli_is_dev" == true ]]; then _dev_owner="$USER_NAME" else _dev_owner="$USER_DAEMON_NAME" fi -chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR" -chmod -R g+rwX "$PROJECT_DIR" -find "$PROJECT_DIR" -type d -exec chmod g+s '{}' + +( + shopt -s dotglob nullglob + for _entry in "$PROJECT_DIR"/*; do + [[ "$(basename "$_entry")" == ".git" ]] && continue + chown -R "$_dev_owner:$USER_GROUP" "$_entry" + chmod -R g+rwX "$_entry" + find "$_entry" -type d -exec chmod g+s '{}' + + done + # Top dir: ownership + shared-group access (never .git) + chown "$_dev_owner:$USER_GROUP" "$PROJECT_DIR" + chmod g+rwX,g+s "$PROJECT_DIR" +) # --- 4. Template rendering function --- # Renders Jinja2 templates by injecting env vars as template context. @@ -344,21 +355,26 @@ else echo "" echo " Setting up initial management configuration..." - # Bootstrap auth: generate config + seed admin user + # Bootstrap auth: generate config + seed admin user. bootstrap_auth.py + # is idempotent — on re-run it preserves the existing config and + # updates the admin password to MGMT_PASS (docs/deployment.md). if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then echo "" echo " Bootstrapping auth (creating admin user: $MGMT_USER)..." - - "${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \ - --project-dir "$PROJECT_DIR" \ - --username "$MGMT_USER" \ - --password "$MGMT_PASS" \ - --domain "$DOMAIN" - - chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth" - chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true + else + echo "" + echo " Syncing admin password for existing user: $MGMT_USER..." fi + "${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \ + --project-dir "$PROJECT_DIR" \ + --username "$MGMT_USER" \ + --password "$MGMT_PASS" \ + --domain "$DOMAIN" + + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth" + chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true + # Helper: POST JSON to daemon API over Unix socket _daemon_post() { local endpoint="$1" @@ -381,7 +397,6 @@ else _daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate" # 1C. Management proxy domain (no auth — JWT auth is handled by Flask) - local mgmt_json mgmt_json="$(jq -n \ --arg domain "$DOMAIN" \ '{ diff --git a/system/systemd/vacuum-wall.service b/system/systemd/vacuum-wall.service index 0d14931..2dfa382 100644 --- a/system/systemd/vacuum-wall.service +++ b/system/systemd/vacuum-wall.service @@ -22,7 +22,8 @@ Environment=HOME={{ PROJECT_DIR }} # Security hardening NoNewPrivileges=yes ProtectSystem=strict -ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp +ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp /var/log/vacuum-wall +LogsDirectory=vacuum-wall PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index a22a2fb..5004639 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -22,9 +22,11 @@ Environment=HOME={{ PROJECT_DIR }} RuntimeDirectory=vacuum-wall RuntimeDirectoryMode=0750 +LogsDirectory=vacuum-wall + # Security hardening ProtectSystem=strict -ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx +ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/tests/test_auth.py b/tests/test_auth.py index 3ba4b28..57e56d5 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -28,6 +28,7 @@ from lib.auth_users import ( delete_user, get_user, list_users, + reset_password, update_password, update_permissions, verify_user_password, @@ -132,6 +133,62 @@ class TestDBLayer: conn2 = db.conn assert conn1 is conn2 + def test_connections_are_thread_local(self, tmp_path): + """DB access from multiple threads must work (regression test). + + The Flask WebUI validates JWTs in lib.db from worker threads while + the daemon uses its event-loop thread. A single shared connection + raises sqlite3.ProgrammingError ("SQLite objects created in a + thread can only be used in that same thread") on the first + cross-thread query. + """ + import threading + + reset_db_for_test() + db_path = str(tmp_path / "thread_local.db") + os.environ["VACUUM_WALL_DB_PATH"] = db_path + try: + db = get_db() + db.run(Q_INSERT_USER, ("touser", "$argon2id$hash", "test-secret")) + + results: list = [] + threads = [ + threading.Thread( + target=lambda: results.append( + db.query(Q_SELECT_USER_BY_NAME, ("touser",)) + ) + ) + for _ in range(4) + ] + for t in threads: + t.start() + for t in threads: + t.join() + + finally: + reset_db_for_test() + + assert len(results) == 4 + for rows in results: + assert isinstance(rows, list), f"query raised or returned {rows!r}" + assert len(rows) == 1 + assert rows[0]["username"] == "touser" + + def test_connections_are_distinct_per_thread(self, db): + """Each thread gets its own connection object.""" + import threading + + def conn_in_thread(result: list) -> None: + result.append(db.conn) + + main_conn = db.conn + result: list = [] + t = threading.Thread(target=conn_in_thread, args=(result,)) + t.start() + t.join() + assert len(result) == 1 + assert result[0] is not main_conn + def test_insert_user(self, db): uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret")) assert isinstance(uid, int) @@ -350,6 +407,17 @@ class TestUserManagement: with pytest.raises(ValueError, match="incorrect"): update_password("upwfail", "wrong_old", "newpass123") + def test_reset_password_without_old(self, db): + """Installer lockout recovery: reset works without knowing the old password.""" + create_user("rstuser", "unknown-old-pass") + reset_password("rstuser", "freshpass123") + assert verify_user_password("rstuser", "freshpass123") is not None + assert verify_user_password("rstuser", "unknown-old-pass") is None + + def test_reset_password_not_found(self, db): + with pytest.raises(ValueError, match="not found"): + reset_password("ghostuser", "newpass123") + def test_update_permissions(self, db): create_user("permuser", "password123", {"firewall": "rw"}) update_permissions("permuser", {"firewall": "read", "network": "rw"}) @@ -1191,3 +1259,158 @@ class TestPermissionMiddleware: assert _subsystem_from_path("/api/dhcp/leases/subpath") == "dhcp" assert _subsystem_from_path("/") is None assert _subsystem_from_path("/static/app.js") is None + + +class TestRefreshRotationLogout: + """Refresh-rotation + logout interaction (regression for the reorder in + 0889ef0: clearing the refresh_tokens row after rotation left logout with + nothing to blacklist, so the rotated token stayed valid).""" + + def test_logout_revokes_rotated_refresh_token(self) -> None: + """Logout must blacklist the current refresh token after rotation.""" + from daemon.handlers.auth import auth_logout, auth_refresh + + create_user("rotuser", "password123", {"auth": "rw"}) + tokens = generate_tokens("rotuser", {"auth": "rw"}) + + rotated = auth_refresh( + MagicMock(), + { + "refresh_token": tokens["refresh_token"], + "session_id": tokens["session_id"], + }, + )["tokens"] + + # The rotated token must be valid before logout (rotation works). + payload = validate_token( + rotated["refresh_token"], "refresh", session_id=rotated["session_id"] + ) + assert payload is not None + + auth_logout(MagicMock(), {"jti": None, "username": "rotuser"}) + + with pytest.raises(ValueError, match="Invalid or expired refresh token"): + auth_refresh( + MagicMock(), + { + "refresh_token": rotated["refresh_token"], + "session_id": rotated["session_id"], + }, + ) + + def test_old_refresh_token_blacklisted_on_rotation(self) -> None: + """The pre-rotation refresh token must be blacklisted immediately.""" + from daemon.handlers.auth import auth_refresh + + create_user("rotuser2", "password123", {"auth": "rw"}) + tokens = generate_tokens("rotuser2", {"auth": "rw"}) + + auth_refresh( + MagicMock(), + { + "refresh_token": tokens["refresh_token"], + "session_id": tokens["session_id"], + }, + ) + + assert ( + validate_token( + tokens["refresh_token"], "refresh", session_id=tokens["session_id"] + ) + is None + ) + + +class TestMalformedTokenPayload: + """Malformed/untrusted JWT payloads must be rejected (401), not 500.""" + + def test_decode_non_object_payload_returns_none(self) -> None: + """A payload segment decoding to non-object JSON is rejected.""" + hdr = ( + base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}') + .decode() + .rstrip("=") + ) + payload = base64.urlsafe_b64encode(b'"hello"').decode().rstrip("=") + token = f"{hdr}.{payload}.signature" + assert decode_token(token) is None + + def test_middleware_crafted_token_returns_401(self) -> None: + """Crafted Bearer token on a protected route returns JSON 401, not 500.""" + from webui.server import app + + client = app.test_client() + hdr = ( + base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}') + .decode() + .rstrip("=") + ) + payload = base64.urlsafe_b64encode(b'"hello"').decode().rstrip("=") + token = f"{hdr}.{payload}.signature" + + res = client.get( + "/api/auth/session", + headers={"Authorization": f"Bearer {token}", "X-Session-Id": "x"}, + ) + assert res.status_code == 401 + assert res.get_json() == {"ok": False, "error": "unauthorized"} + + +class TestBuiltinAdminSeeding: + """Fallback seeding of the builtin admin user (lib.db._seed_builtin_admin).""" + + def test_seed_runs_and_creates_admin(self) -> None: + """A fresh (empty) DB gets the builtin admin with full permissions.""" + from lib.auth_users import ALL_SUBSYSTEMS + from lib.db import _seed_builtin_admin + + db = get_db() + _seed_builtin_admin(db) + + user = get_user("admin") + assert user is not None + assert user["permissions"] == {s: "rw" for s in ALL_SUBSYSTEMS} + + def test_seed_noop_when_admin_exists(self) -> None: + """Seeding is a no-op when the admin user already exists.""" + from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin + + db = get_db() + _seed_builtin_admin(db) + + real_query = db.query + calls = {"n": 0} + + def counting_query(query_id, params=()): + if query_id == Q_SELECT_USER_BY_NAME: + calls["n"] += 1 + return real_query(query_id, params) + + with patch.object(db, "query", side_effect=counting_query): + _seed_builtin_admin(db) + # Early-return path: only the existence check runs. + assert calls["n"] >= 1 + + def test_seed_concurrent_lose_race(self) -> None: + """Concurrent seeding: if the insert loses a race, the loser re-checks, + finds the winner's admin, and returns instead of raising IntegrityError.""" + from lib.db import Q_SELECT_USER_BY_NAME, _seed_builtin_admin + + db = get_db() + # get_db() already seeded admin for this fresh in-memory DB. + assert get_user("admin") is not None + + real_query = db.query + calls = {"n": 0} + + def fake_query(query_id, params=()): + if query_id == Q_SELECT_USER_BY_NAME and params and params[0] == "admin": + calls["n"] += 1 + if calls["n"] == 1: + return [] # stale view: existence check misses concurrent seeder + return real_query(query_id, params) + + with patch.object(db, "query", side_effect=fake_query): + _seed_builtin_admin(db) # must not raise + + assert get_user("admin") is not None diff --git a/tests/test_server.py b/tests/test_server.py index 0c38aaf..6e63b1d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -39,14 +39,17 @@ class TestSPARoutes: assert data.get("error") == "unauthorized" -class TestWsUrlGeneration: - def test_ws_url_ipv4_host(self, client): - resp = client.get("/", headers={"Host": "192.168.1.1:9090"}) - assert b"ws://192.168.1.1:9090/ws" in resp.data +class TestSpaRoot: + def test_serves_index_html_as_is(self, client): + resp = client.get("/") + assert resp.status_code == 200 + assert b"/static/app.js" in resp.data - def test_ws_url_ipv6_host(self, client): - resp = client.get("/", headers={"Host": "[::1]:9090"}) - assert b"ws://[::1]:9090/ws" in resp.data + def test_no_ws_url_substitution(self, client): + """index.html is served verbatim — no WS URL placeholder substitution.""" + resp = client.get("/", headers={"Host": "192.168.1.1:9090"}) + assert b"__WS_URL_PLACEHOLDER__" not in resp.data + assert b"ws://" not in resp.data class TestBlueprintsRegistered: diff --git a/webui/api/auth.py b/webui/api/auth.py index 04bb727..edb0fcc 100644 --- a/webui/api/auth.py +++ b/webui/api/auth.py @@ -9,7 +9,7 @@ import logging from flask import Blueprint, request -from daemon.client import delete, get, post +from daemon.client import Conflict, delete, get, post from daemon.iface import ( DELETE_AUTH_USER, DELETE_AUTH_WEBAUTHN_CREDENTIAL, @@ -167,6 +167,8 @@ def create_user(): try: body = request.get_json(silent=True) or {} return _ok(post(POST_AUTH_USER_CREATE, body)) + except Conflict as exc: + return _error(str(exc), 409) except Exception as exc: logger.error("Create user failed: %s", exc) return _error(str(exc), 400) diff --git a/webui/server.py b/webui/server.py index 6a368ba..49a0c81 100644 --- a/webui/server.py +++ b/webui/server.py @@ -307,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor" @app.route("/") def spa_root(): """Serve the SPA entry point. No catch-all — client handles routing.""" - scheme = "wss" if request.is_secure else "ws" - ws_url = f"{scheme}://{request.host}/ws" - html = (SPA_DIR / "index.html").read_text() - return html.replace("__WS_URL_PLACEHOLDER__", ws_url) + return (SPA_DIR / "index.html").read_text() @app.route("/vendor/") diff --git a/webui/static/app.js b/webui/static/app.js index 688bffa..6c60c68 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -27,8 +27,9 @@ const _NavBase = [ { path: '/proxy', label: 'Proxy' }, { path: '/backends', label: 'Backends' }, { path: '/certs', label: 'Certs' }, - { path: '/wireguard', label: 'WireGuard' }, - { path: '/logs', label: 'Logs' }, + { path: '/wireguard', label: 'WireGuard' }, + { path: '/logs', label: 'Logs' }, + { path: '/passkeys', label: 'Passkeys' }, ]; function getNav() { diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 1db7a43..d72164f 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -12,6 +12,21 @@ import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js'; import { requestUpdate } from './reactivity.js'; import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js'; +/** + * Public auth endpoints that may legitimately 401 (bad credentials) while a + * valid session exists elsewhere. 401 recovery (refresh → retry → logout) + * is skipped for these so a failed login doesn't tear down a live session. + */ +const _PUBLIC_AUTH_URLS = new Set([ + '/api/auth/login', + '/api/auth/webauthn/authenticate-begin', + '/api/auth/webauthn/authenticate-finish', +]); + +function _isPublicAuthUrl(url) { + return _PUBLIC_AUTH_URLS.has(String(url).split('?')[0]); +} + /** * JSON-friendly fetch wrapper. * @@ -49,7 +64,7 @@ export async function apiFetch(url, options = {}) { if (safeOpts.signal?.aborted) { return { ok: false, data: null, error: 'Aborted', status: 0 }; } - if (res.status === 401 && token) { + if (res.status === 401 && token && !_isPublicAuthUrl(url)) { await refreshAuth(); const auth = getAuthData(); if (auth?.token) { diff --git a/webui/static/hoover/components/auth.js b/webui/static/hoover/components/auth.js index 97629e2..799d767 100644 --- a/webui/static/hoover/components/auth.js +++ b/webui/static/hoover/components/auth.js @@ -11,7 +11,6 @@ * - WebAuthn (passkey) ceremony helpers — not state management */ -import { apiFetch } from '../api.js'; import { modelFetch } from '../model.js'; import { getAuthData } from '../auth_model.js'; @@ -65,20 +64,6 @@ export function webauthnSupported() { return typeof window !== 'undefined' && !!window.PublicKeyCredential; } -/** - * Check if WebAuthn is enabled and available on the current domain. - * Calls GET /api/auth/webauthn/capable to query the server. - * - * @returns {Promise} { enabled, rp_id, rp_name, origin, reason? } - */ -export async function checkWebAuthnCapable() { - const result = await apiFetch('/api/auth/webauthn/capable'); - if (!result.ok) { - return { enabled: false, reason: 'Unable to check WebAuthn capability' }; - } - return result.data || { enabled: false, reason: 'Server returned no data' }; -} - /* ─── Base64url helpers ──────────────────────────────────────────────── */ /** diff --git a/webui/static/hoover/index.js b/webui/static/hoover/index.js index 71fbb17..d140dfa 100644 --- a/webui/static/hoover/index.js +++ b/webui/static/hoover/index.js @@ -30,7 +30,7 @@ export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoa from './api.js'; /* ── UI Components: Auth ──────────────────────────────────────── */ -export { logout, doLogin, webauthnSupported, checkWebAuthnCapable, +export { logout, doLogin, webauthnSupported, startRegistration, startAuthentication } from './components/auth.js'; /* ── Auth model ───────────────────────────────────────────────── */ diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 4b9da0c..6b0fdf8 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -25,12 +25,10 @@ let _wsClosingHandled = false; const _directHandlers = []; /** - * Build the WebSocket URL. Supports an override via `window.__WS_URL__` - * (useful for proxy setups). Falls back to port 9091 when the current - * origin has no port (nginx fronting the WS on a different port). + * Build the WebSocket URL from the current origin. nginx proxies /ws to + * the daemon's WebSocket port. */ function _wsUrl() { - if (window.__WS_URL__) return window.__WS_URL__; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; return proto + '//' + location.host + '/ws'; } diff --git a/webui/static/index.html b/webui/static/index.html index 80e7904..b1851fb 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -14,7 +14,6 @@ -