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:
2026-08-17 01:45:15 +00:00
parent 1980043afd
commit 0ed275835d
27 changed files with 442 additions and 128 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ through the daemon client over a Unix socket.
### Code Layout ### 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/*.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. - `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. - `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
-8
View File
@@ -34,7 +34,6 @@ from lib.auth import (
blacklist_token, blacklist_token,
check_login_rate, check_login_rate,
check_webauthn_rate, check_webauthn_rate,
clear_active_refresh_token,
generate_tokens, generate_tokens,
get_access_ttl, get_access_ttl,
record_login_failure, record_login_failure,
@@ -70,11 +69,6 @@ from lib.webauthn import (
logger = logging.getLogger(__name__) 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) @registry.register(POST_AUTH_LOGIN)
def auth_login(_request: Any, body: Any) -> dict[str, Any]: def auth_login(_request: Any, body: Any) -> dict[str, Any]:
"""Handle user login. """Handle user login.
@@ -196,8 +190,6 @@ def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
jti = payload.get("jti") jti = payload.get("jti")
if jti: if jti:
blacklist_token(jti, token_type="refresh") blacklist_token(jti, token_type="refresh")
if username:
_clear_refresh_token_after_rotation(username)
return { return {
"tokens": tokens, "tokens": tokens,
+12 -5
View File
@@ -375,17 +375,24 @@ async def _handle_ws(request: web.Request) -> web.Response:
Authentication: JWT access token passed via: Authentication: JWT access token passed via:
1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer <token>") 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 from lib.auth import validate_token
token_param = None token_param = None
matched_proto = None matched_proto = None
# Prefer subprotocol header (client JS sends "Bearer <token>") # Prefer subprotocol header (client JS sends "Bearer <token>").
subprotocols = request.get_subprotocols() # Sec-WebSocket-Protocol is a comma-separated list; parse it the same
for proto in subprotocols or []: # way aiohttp's own handshake does (Request has no subprotocol helper).
if proto and proto.startswith("Bearer "): 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:] token_param = proto[7:]
matched_proto = proto matched_proto = proto
break break
+15 -3
View File
@@ -206,7 +206,13 @@ Create a new user with password and per-subsystem permissions.
| `password` | `string` | Yes | Plain-text password | | `password` | `string` | Yes | Plain-text password |
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) | | `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. 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 | | `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. 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. **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. Returns HTTP `404` if user not found.
+2 -2
View File
@@ -287,7 +287,7 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
data/ data/
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds ├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
├── nginx/ ├── 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) │ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/ ├── dnsmasq/
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim) │ └── 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) ### 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 Client loads /static/app.js ──→ Hoover initializes, checkSession() → if no valid session, render #login
Authenticated ──→ mounts #sidebar and #main render roots Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
+2 -2
View File
@@ -34,7 +34,7 @@ index.html — static shell with #sidebar, #main, #modal-root
└── connect() — WebSocket lifecycle └── 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. 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()` ### `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. 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
View File
@@ -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. 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. 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. 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: Token theft protection:
- Short-lived access tokens (15 min) limit the window of exploitation - Short-lived access tokens (15 min) limit the window of exploitation
- Token blacklist prevents reuse after logout or password change - Token blacklist prevents reuse after logout or password change
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain - 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 ### WebAuthn Security
+2 -13
View File
@@ -217,19 +217,6 @@ def blacklist_active_refresh_token(username: str) -> None:
db.run(Q_DELETE_REFRESH_TOKEN, (username,)) 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: def _extract_unverified_sub(token_string: str) -> str | None:
"""Extract the ``sub`` claim from a JWT payload without signature verification. """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_b64 += "=" * padding
payload_json = base64.urlsafe_b64decode(payload_b64) payload_json = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_json) payload = json.loads(payload_json)
if not isinstance(payload, dict):
return None
return payload.get("sub") return payload.get("sub")
except (ValueError, json.JSONDecodeError, UnicodeDecodeError): except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None return None
+25
View File
@@ -207,6 +207,31 @@ def update_password(username: str, old_password: str, new_password: str) -> bool
return True 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: def update_permissions(username: str, permissions: dict[str, str]) -> None:
"""Update a user's permissions and invalidate all existing tokens. """Update a user's permissions and invalidate all existing tokens.
+27 -10
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging import logging
import os import os
import secrets import secrets
import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar from typing import Any, ClassVar
@@ -163,16 +164,22 @@ class Database(ABC):
def __init__(self, connection_string: str) -> None: def __init__(self, connection_string: str) -> None:
self._connection_string = connection_string self._connection_string = connection_string
self._conn: Any = None
self._prepared: dict[str, Any] = {} self._prepared: dict[str, Any] = {}
self._in_transaction = False 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 @property
def conn(self) -> Any: def conn(self) -> Any:
"""Return the cached database connection, creating it lazily.""" """Return this thread's cached database connection, creating it lazily."""
if self._conn is None: conn = getattr(self._local, "conn", None)
self._conn = self._connect(self._connection_string) if conn is None:
return self._conn conn = self._connect(self._connection_string)
self._local.conn = conn
return conn
@abstractmethod @abstractmethod
def _connect(self, cs: str) -> Any: ... def _connect(self, cs: str) -> Any: ...
@@ -300,12 +307,22 @@ def _seed_builtin_admin(db: Database) -> None:
placeholder_hash = hash_password(random_password) placeholder_hash = hash_password(random_password)
jwt_secret = secrets.token_urlsafe(32) jwt_secret = secrets.token_urlsafe(32)
with db.in_transaction() as tx: try:
tx.run_one( with db.in_transaction() as tx:
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret) 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: return
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
auth_log = Path("/var/log/vacuum-wall/auth.log") auth_log = Path("/var/log/vacuum-wall/auth.log")
auth_log_written = False auth_log_written = False
+4
View File
@@ -134,6 +134,10 @@ class SQLiteBackend(Database):
"""Create a SQLite connection with WAL mode and row factory.""" """Create a SQLite connection with WAL mode and row factory."""
conn = sqlite3.connect(cs, isolation_level=None) conn = sqlite3.connect(cs, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL") 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.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
+5
View File
@@ -54,6 +54,11 @@ WEBUI_BACKEND: dict[str, Any] = {
"/": { "/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"}, "backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True, "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": { "/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"}, "backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
+8 -1
View File
@@ -223,7 +223,14 @@ def _parse_addr_directive(line: str) -> dict[str, Any] | None:
def import_wireguard() -> bool: def import_wireguard() -> bool:
"""Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json.""" """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) logger.debug("Skipping wireguard: %s not found", WG_CONF)
return False return False
+43 -28
View File
@@ -1,7 +1,12 @@
"""Bootstrap auth: initialize DB and seed admin user at install time. """Bootstrap auth: initialize DB and seed admin user at install time.
Run once during installation. Writes config/auth/config.json with a Idempotent — safe to run on every install (and re-install):
generated JWT secret and creates the admin user in SQLite.
- 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: Usage:
python scripts/bootstrap_auth.py --project-dir /path/to/project \ 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_BACKEND"] = "sqlite"
os.environ["VACUUM_WALL_DB_PATH"] = db_path 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 = project_dir / "config" / "auth"
config_dir.mkdir(parents=True, exist_ok=True) config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.json" config_path = config_dir / "config.json"
config = { if not config_path.exists():
"jwt": { config = {
"access_token_ttl": 300, "jwt": {
"refresh_token_ttl": 604800, "access_token_ttl": 300,
"algorithm": "HS256", "refresh_token_ttl": 604800,
}, "algorithm": "HS256",
"webauthn": { },
"rp_name": "Vacuum Wall", "webauthn": {
"rp_id": args.domain, "rp_name": "Vacuum Wall",
"origin": f"https://{args.domain}", "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: # Initialize DB and create the admin user, or sync the password on re-run
json.dump(config, f, indent=2) if find_user(args.username) is not None:
f.write("\n") reset_password(args.username, args.password)
print(f"Updated existing user: {args.username} (password synced)")
print(f"Wrote auth config: {config_path}") else:
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
# Initialize DB and create admin user user = create_user(args.username, args.password, permissions)
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS} print(f"Created admin user: {user['username']} (id={user['id']})")
user = create_user(args.username, args.password, permissions) print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
print(f"Created admin user: {user['username']} (id={user['id']})")
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
if __name__ == "__main__": if __name__ == "__main__":
+30 -15
View File
@@ -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 "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq 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 if [[ "$_cli_is_dev" == true ]]; then
_dev_owner="$USER_NAME" _dev_owner="$USER_NAME"
else else
_dev_owner="$USER_DAEMON_NAME" _dev_owner="$USER_DAEMON_NAME"
fi fi
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR" (
chmod -R g+rwX "$PROJECT_DIR" shopt -s dotglob nullglob
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' + 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 --- # --- 4. Template rendering function ---
# Renders Jinja2 templates by injecting env vars as template context. # Renders Jinja2 templates by injecting env vars as template context.
@@ -344,21 +355,26 @@ else
echo "" echo ""
echo " Setting up initial management configuration..." 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 if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
echo "" echo ""
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..." echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
else
"${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \ echo ""
--project-dir "$PROJECT_DIR" \ echo " Syncing admin password for existing user: $MGMT_USER..."
--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
fi 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 # Helper: POST JSON to daemon API over Unix socket
_daemon_post() { _daemon_post() {
local endpoint="$1" local endpoint="$1"
@@ -381,7 +397,6 @@ else
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate" _daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask) # 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
local mgmt_json
mgmt_json="$(jq -n \ mgmt_json="$(jq -n \
--arg domain "$DOMAIN" \ --arg domain "$DOMAIN" \
'{ '{
+2 -1
View File
@@ -22,7 +22,8 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening # Security hardening
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict 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 PrivateTmp=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
+3 -1
View File
@@ -22,9 +22,11 @@ Environment=HOME={{ PROJECT_DIR }}
RuntimeDirectory=vacuum-wall RuntimeDirectory=vacuum-wall
RuntimeDirectoryMode=0750 RuntimeDirectoryMode=0750
LogsDirectory=vacuum-wall
# Security hardening # Security hardening
ProtectSystem=strict 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 PrivateTmp=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
+223
View File
@@ -28,6 +28,7 @@ from lib.auth_users import (
delete_user, delete_user,
get_user, get_user,
list_users, list_users,
reset_password,
update_password, update_password,
update_permissions, update_permissions,
verify_user_password, verify_user_password,
@@ -132,6 +133,62 @@ class TestDBLayer:
conn2 = db.conn conn2 = db.conn
assert conn1 is conn2 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): def test_insert_user(self, db):
uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret")) uid = db.run_one(Q_INSERT_USER, ("testuser", "$argon2id$hash", "test-secret"))
assert isinstance(uid, int) assert isinstance(uid, int)
@@ -350,6 +407,17 @@ class TestUserManagement:
with pytest.raises(ValueError, match="incorrect"): with pytest.raises(ValueError, match="incorrect"):
update_password("upwfail", "wrong_old", "newpass123") 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): def test_update_permissions(self, db):
create_user("permuser", "password123", {"firewall": "rw"}) create_user("permuser", "password123", {"firewall": "rw"})
update_permissions("permuser", {"firewall": "read", "network": "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("/api/dhcp/leases/subpath") == "dhcp"
assert _subsystem_from_path("/") is None assert _subsystem_from_path("/") is None
assert _subsystem_from_path("/static/app.js") 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
View File
@@ -39,14 +39,17 @@ class TestSPARoutes:
assert data.get("error") == "unauthorized" assert data.get("error") == "unauthorized"
class TestWsUrlGeneration: class TestSpaRoot:
def test_ws_url_ipv4_host(self, client): def test_serves_index_html_as_is(self, client):
resp = client.get("/", headers={"Host": "192.168.1.1:9090"}) resp = client.get("/")
assert b"ws://192.168.1.1:9090/ws" in resp.data assert resp.status_code == 200
assert b"/static/app.js" in resp.data
def test_ws_url_ipv6_host(self, client): def test_no_ws_url_substitution(self, client):
resp = client.get("/", headers={"Host": "[::1]:9090"}) """index.html is served verbatim — no WS URL placeholder substitution."""
assert b"ws://[::1]:9090/ws" in resp.data 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: class TestBlueprintsRegistered:
+3 -1
View File
@@ -9,7 +9,7 @@ import logging
from flask import Blueprint, request from flask import Blueprint, request
from daemon.client import delete, get, post from daemon.client import Conflict, delete, get, post
from daemon.iface import ( from daemon.iface import (
DELETE_AUTH_USER, DELETE_AUTH_USER,
DELETE_AUTH_WEBAUTHN_CREDENTIAL, DELETE_AUTH_WEBAUTHN_CREDENTIAL,
@@ -167,6 +167,8 @@ def create_user():
try: try:
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_USER_CREATE, body)) return _ok(post(POST_AUTH_USER_CREATE, body))
except Conflict as exc:
return _error(str(exc), 409)
except Exception as exc: except Exception as exc:
logger.error("Create user failed: %s", exc) logger.error("Create user failed: %s", exc)
return _error(str(exc), 400) return _error(str(exc), 400)
+1 -4
View File
@@ -307,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor"
@app.route("/") @app.route("/")
def spa_root(): def spa_root():
"""Serve the SPA entry point. No catch-all — client handles routing.""" """Serve the SPA entry point. No catch-all — client handles routing."""
scheme = "wss" if request.is_secure else "ws" return (SPA_DIR / "index.html").read_text()
ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text()
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
@app.route("/vendor/<path:filename>") @app.route("/vendor/<path:filename>")
+3 -2
View File
@@ -27,8 +27,9 @@ const _NavBase = [
{ path: '/proxy', label: 'Proxy' }, { path: '/proxy', label: 'Proxy' },
{ path: '/backends', label: 'Backends' }, { path: '/backends', label: 'Backends' },
{ path: '/certs', label: 'Certs' }, { path: '/certs', label: 'Certs' },
{ path: '/wireguard', label: 'WireGuard' }, { path: '/wireguard', label: 'WireGuard' },
{ path: '/logs', label: 'Logs' }, { path: '/logs', label: 'Logs' },
{ path: '/passkeys', label: 'Passkeys' },
]; ];
function getNav() { function getNav() {
+16 -1
View File
@@ -12,6 +12,21 @@ import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
import { requestUpdate } from './reactivity.js'; import { requestUpdate } from './reactivity.js';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.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. * JSON-friendly fetch wrapper.
* *
@@ -49,7 +64,7 @@ export async function apiFetch(url, options = {}) {
if (safeOpts.signal?.aborted) { if (safeOpts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 }; return { ok: false, data: null, error: 'Aborted', status: 0 };
} }
if (res.status === 401 && token) { if (res.status === 401 && token && !_isPublicAuthUrl(url)) {
await refreshAuth(); await refreshAuth();
const auth = getAuthData(); const auth = getAuthData();
if (auth?.token) { if (auth?.token) {
-15
View File
@@ -11,7 +11,6 @@
* - WebAuthn (passkey) ceremony helpers not state management * - WebAuthn (passkey) ceremony helpers not state management
*/ */
import { apiFetch } from '../api.js';
import { modelFetch } from '../model.js'; import { modelFetch } from '../model.js';
import { getAuthData } from '../auth_model.js'; import { getAuthData } from '../auth_model.js';
@@ -65,20 +64,6 @@ export function webauthnSupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential; 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 ──────────────────────────────────────────────── */ /* ─── Base64url helpers ──────────────────────────────────────────────── */
/** /**
+1 -1
View File
@@ -30,7 +30,7 @@ export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoa
from './api.js'; from './api.js';
/* ── UI Components: Auth ──────────────────────────────────────── */ /* ── UI Components: Auth ──────────────────────────────────────── */
export { logout, doLogin, webauthnSupported, checkWebAuthnCapable, export { logout, doLogin, webauthnSupported,
startRegistration, startAuthentication } from './components/auth.js'; startRegistration, startAuthentication } from './components/auth.js';
/* ── Auth model ───────────────────────────────────────────────── */ /* ── Auth model ───────────────────────────────────────────────── */
+2 -4
View File
@@ -25,12 +25,10 @@ let _wsClosingHandled = false;
const _directHandlers = []; const _directHandlers = [];
/** /**
* Build the WebSocket URL. Supports an override via `window.__WS_URL__` * Build the WebSocket URL from the current origin. nginx proxies /ws to
* (useful for proxy setups). Falls back to port 9091 when the current * the daemon's WebSocket port.
* origin has no port (nginx fronting the WS on a different port).
*/ */
function _wsUrl() { function _wsUrl() {
if (window.__WS_URL__) return window.__WS_URL__;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws'; return proto + '//' + location.host + '/ws';
} }
-1
View File
@@ -14,7 +14,6 @@
</div> </div>
</div> </div>
<div id="modal-root"></div> <div id="modal-root"></div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js"></script> <script type="module" src="/static/app.js"></script>
</body> </body>
</html> </html>