Files
vacuum-wall/webui/static/hoover/websocket.js
T
mteehan 0ed275835d 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.
2026-08-17 01:45:15 +00:00

161 lines
5.0 KiB
JavaScript

/**
* Hoover — websocket.js
*
* WebSocket connection manager with auto-reconnect. WS messages are routed
* to model-based refresh and direct onMessage handlers.
* Page-level subscribe/unsubscribe is replaced by the model layer.
*
* The JWT is read from the auth model (single source of truth). After 3
* failed close attempts a token refresh is triggered through the auth
* model; the reconnect decision branches on the model's token state —
* never on the refresh promise. Terminal (no-token) transitions are
* handled by the auth model's onSuccess (clears storage, redirects,
* dispatches auth:logout).
*/
import { refreshByTopic } from './model.js';
import { refreshAuth, getAuthToken } from './auth_model.js';
let _wsConn = null;
let _wsReconnectMs = 0;
let _wsFailCount = 0;
let _wsClosingHandled = false;
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
const _directHandlers = [];
/**
* Build the WebSocket URL from the current origin. nginx proxies /ws to
* the daemon's WebSocket port.
*/
function _wsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws';
}
/** Attempt a WebSocket connection.
* Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol)
* instead of a query parameter, keeping it out of logs and browser history.
* No token: no socket is created — the daemon 401s unauthenticated WS
* connections and connect() only runs while authenticated.
*/
function _wsConnect() {
if (_wsConn && _wsConn.readyState <= 1) return;
_wsClosingHandled = false;
const token = getAuthToken();
if (!token) return;
_wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]);
_wsConn.onopen = () => {
_wsReconnectMs = 0;
_wsFailCount = 0;
_wsClosingHandled = false;
};
_wsConn.onclose = async () => {
if (_wsClosingHandled) return;
_wsClosingHandled = true;
if (!getAuthToken()) return;
_wsFailCount++;
if (_wsFailCount >= 3) {
const oldConn = _wsConn;
_wsFailCount = 0;
await refreshAuth(); // never rejects; failure path handled by model onSuccess
if (getAuthToken()) {
_wsReconnectMs = 0;
_wsConn = null;
if (oldConn) oldConn.close();
setTimeout(_wsConnect, 100);
}
// No token after the refresh: onSuccess already cleared storage
// and redirected to #/login; the no-token guard at the top of
// onclose stops further reconnect attempts.
return;
}
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
};
_wsConn.onerror = () => {
_wsConn.close();
};
_wsConn.onmessage = (ev) => {
try {
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
handleMessage(msg);
} catch (err) {
console.warn('[WS] Failed to parse message:', err);
}
};
}
/**
* Route an incoming WS message to model refresh and direct handlers.
*
* Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
* { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', … }
*/
function handleMessage(msg) {
const topics = [];
if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
} else if (msg.type === 'notify') {
topics.push(msg.topic);
} else if (msg.type === 'status') {
topics.push(msg.topic || '*');
}
// Refresh models for each topic
for (const topic of topics) {
refreshByTopic(topic);
}
// Notify direct onMessage handlers
for (const h of _directHandlers) {
if (h.unsubscribed) continue;
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); }
}
}
}
/**
* Public subscribe API for direct one-off usage (e.g. from page code).
* Handler receives the raw parsed message when a matching topic arrives.
* @param {string|string[]} topics
* @param {function} handler
* @returns {function} unsubscribe
*/
export function onMessage(topics, handler) {
const tArray = Array.isArray(topics) ? topics : [topics];
const entry = { topics: tArray, handler, unsubscribed: false };
_directHandlers.push(entry);
return () => {
entry.unsubscribed = true;
const idx = _directHandlers.indexOf(entry);
if (idx !== -1) _directHandlers.splice(idx, 1);
};
}
/** Start the WebSocket connection. */
export function connect() {
_wsConnect();
}
/** Close the WS socket (terminal auth transition). */
export function disconnect() {
if (_wsConn) {
_wsConn.close();
_wsConn = null;
}
}