332d14e37d
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
188 lines
6.7 KiB
JavaScript
188 lines
6.7 KiB
JavaScript
/**
|
|
* 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 <token>" 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;
|
|
}
|
|
}
|