fix: auth review fixes — token revocation, WS auth, seeding, and hardening
Refresh/logout and token robustness - drop the post-rotation refresh_tokens row delete in auth_refresh so logout blacklists the current (rotated) refresh token; remove the dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token - reject non-object JWT payloads in _extract_unverified_sub so crafted Authorization headers return 401 instead of crashing with 500 SQLite user store - make builtin-admin seeding idempotent: on a concurrent first start the losing seeder re-checks, finds the winner, and returns instead of raising IntegrityError - per-thread sqlite connections + busy_timeout so Flask worker threads don't hit cross-thread ProgrammingError / SQLITE_BUSY - add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both systemd units so the fallback admin password actually lands on disk Frontend - skip apiFetch 401-recovery for public auth endpoints so a failed login no longer logs out a valid session - add /passkeys to the nav (passkey registration was unreachable); remove the dead checkWebAuthnCapable export - drop the CSP-blocked inline WS-URL script and the __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location Daemon / WS - parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx fallback only — docstring and security docs corrected Install / system - bootstrap_auth.py is now idempotent: preserves existing auth config and syncs the admin password on re-runs (new reset_password helper) - WebUI server block renders auth_basic off (the UI is JWT-protected) - install.sh chown/chmod skips .git to avoid git dubious-ownership breakage - tolerate unreadable /etc/wireguard during system import Contracts / docs - create_user returns 409 on duplicate username per docs/api.md - correct docs/api.md response shapes, docs/security.md blacklist cleanup wording + one-refresh-per-user caveat, stale WS-URL references, and the .htpasswd description Tests: +7 regression tests (rotation/logout revocation, crafted-token 401, concurrent seeding); placeholder-substitution tests replaced with serve-as-is SPA root tests.
This commit is contained in:
@@ -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/<subsystem>/`. 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+12
-5
@@ -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 <token>")
|
||||
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 <token>")
|
||||
subprotocols = request.get_subprotocols()
|
||||
for proto in subprotocols or []:
|
||||
if proto and proto.startswith("Bearer "):
|
||||
# Prefer subprotocol header (client JS sends "Bearer <token>").
|
||||
# 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
|
||||
|
||||
+15
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <token> header ──→ Flask REST API
|
||||
|
||||
+2
-2
@@ -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://<host>/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://<host>/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 <token>`). 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.
|
||||
|
||||
|
||||
+2
-2
@@ -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 <token>`. 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
|
||||
|
||||
|
||||
+2
-13
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+43
-28
@@ -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__":
|
||||
|
||||
+30
-15
@@ -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" \
|
||||
'{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-7
@@ -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:
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+1
-4
@@ -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/<path:filename>")
|
||||
|
||||
+3
-2
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<object>} { 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 ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user