/** * Hoover — websocket.js * * WebSocket connection manager with auto-reconnect. WS messages carry * state data directly: a full snapshot on connect, then per-subsystem * deltas. handleMessage patches the matching models in place via * modelSet — no HTTP round-trip for auto-refresh. * * 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). * * The refresh path is capped at 2 consecutive failing episodes (3 closed * connections each): if refresh + reconnect still cannot establish a * socket, the WS path itself is dead, and retrying would loop token * rotation forever. Reconnection is then abandoned until the page is * reloaded; the UI keeps working via the REST API. */ import { modelSet } from './model.js'; import { refreshAuth, getAuthToken } from './auth_model.js'; // Maps subsystem name → registered model name. // Most subsystems use the same name. `networkd` maps to `network`. const _SUBSYSTEM_TO_MODEL = { firewall: 'firewall', dnsmasq: 'dnsmasq', nginx: 'nginx', acme: 'acme', wireguard: 'wireguard', networkd: 'network', system: 'system', }; let _wsConn = null; let _wsReconnectMs = 0; let _wsFailCount = 0; // Consecutive refresh→reconnect episodes that still failed. Capped so a // dead WS path cannot loop `refreshAuth()` forever (each 200 refresh rotates // the user's token pair, so an unbounded loop storms the refresh endpoint). let _wsRefreshStreak = 0; let _wsGivingUp = false; let _wsClosingHandled = false; /** * 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 as the WebSocket subprotocol name (Sec-WebSocket-Protocol) * instead of a query parameter, keeping it out of logs and browser history. * The token is sent as-is, WITHOUT a "Bearer " prefix: subprotocol names * must be valid RFC 6455 tokens and a JWT (base64url + dots) is one, but * the space in "Bearer " is not a token character — the browser * rejects the whole constructor with a SyntaxError. * 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(), [token]); _wsConn.onopen = () => { _wsReconnectMs = 0; _wsFailCount = 0; _wsRefreshStreak = 0; _wsGivingUp = false; _wsClosingHandled = false; }; _wsConn.onclose = async () => { if (_wsClosingHandled) return; _wsClosingHandled = true; if (!getAuthToken()) return; if (_wsGivingUp) return; _wsFailCount++; if (_wsFailCount >= 3) { const oldConn = _wsConn; _wsFailCount = 0; _wsRefreshStreak++; if (_wsRefreshStreak >= 2) { // Refresh + reconnect has failed twice in a row — the WS path // is dead (not just the token). Stop retrying: the page keeps // working API-only, and a fresh page load (or the next // successful socket) restarts the cycle. _wsGivingUp = true; console.error( '[WS] giving up after repeated refresh+reconnect failures; ' + 'live updates paused until the page is reloaded', ); return; } 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); } }; } /** * Patch models in place from a data-carrying WS message. * * Expected message shapes (daemon → client): * { type: 'snapshot', data: {subsystem: state|null, …} } // on connect * { type: 'versions', subsystem: 'firewall', data: state } // structural change * { type: 'tick', subsystem: 'system', data: state } // volatile change * * The daemon only sends these three types after the WS push-stream * migration; unknown / retired types (refresh/notify/status, legacy * versions.updated, tick.subsystems) are ignored — no backward compat. */ function handleMessage(msg) { if (msg.type === 'snapshot') { // Full state on connect — set all models (null = collector failed, skip) for (const [subsystem, data] of Object.entries(msg.data)) { if (data !== null) { const modelName = _SUBSYSTEM_TO_MODEL[subsystem] || subsystem; modelSet(modelName, data); } } return; } if ((msg.type === 'versions' || msg.type === 'tick') && msg.subsystem && msg.data != null) { // Delta for one subsystem — patch the corresponding model. // Guard is `!= null` (not `!== undefined`): a null payload means the // collector failed — never overwrite good model data (defense in depth; // the daemon skips null broadcasts). const modelName = _SUBSYSTEM_TO_MODEL[msg.subsystem] || msg.subsystem; modelSet(modelName, msg.data); return; } // Everything else is unknown / retired — ignored. } /** Start the WebSocket connection. */ export function connect() { _wsConnect(); } /** Close the WS socket (terminal auth transition). */ export function disconnect() { if (_wsConn) { _wsConn.close(); _wsConn = null; } }