9c9f92ad04
systemd: pre-create volatile /run paths so vacuum-walld's ProtectSystem=strict namespace setup cannot fail with 226/NAMESPACE — RuntimeDirectory=vacuum-wall nginx plus a tmpfiles.d spec (installed to /etc/tmpfiles.d/) covering /run/firewalld and /run/nginx.pid. Drop /run/sudo from ReadWritePaths: NOPASSWD children never need it, and its absence crash-looped restarts after sudo removed /run/sudo.
webui: run the auth session check before mounting the shell so logged-out visitors never flash the sidebar or a protected page; router guard and sidebar now react to auth state, and the login page renders full-bleed.
ws: cap refresh->reconnect episodes at 2 consecutive failures; if the WS path stays dead after a token refresh, abandon reconnection instead of looping refreshAuth forever (UI keeps working via REST until reload).
api: GET /api/network/interfaces now includes loopback and returns per-interface {config, runtime}; dashboard reads runtime.state (carrier counts as up) and the interfaces page filters lo client-side.
daemon: re-collect nginx state after lazy config migration (cached list went stale when the on-disk format changed under it), skip system_import.nginx when config.json already exists (re-parsing vacuum-wall's own generated sites is lossy), and poll nginx (60s) / acme (300s) state so file drift self-heals.
192 lines
6.6 KiB
JavaScript
192 lines
6.6 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).
|
|
*
|
|
* 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 { refreshByTopic } from './model.js';
|
|
import { refreshAuth, getAuthToken } from './auth_model.js';
|
|
|
|
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;
|
|
|
|
/** 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 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);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|