fix: daemon /run spawn hardening, auth guard before first paint, WS refresh cap, interfaces runtime state
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.
This commit is contained in:
+49
-19
@@ -219,21 +219,45 @@ const Pages = {
|
||||
/* ── Router ────────────────────────────────────────────────── */
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
isAuthenticated: false,
|
||||
component() {
|
||||
const name = this.state.path.replace(/^\//, '');
|
||||
const { path } = this.state;
|
||||
// Auth guard: unauthenticated users see the login page for any
|
||||
// protected route (manual hash entry, back/forward, runtime
|
||||
// expiry). Reactive — the auth model's data mutation re-renders
|
||||
// this function, so the real page appears the instant login
|
||||
// completes.
|
||||
if (path !== '/login' && !isAuthenticated()) {
|
||||
return hComp(LoginPage, '/login');
|
||||
}
|
||||
const name = path.replace(/^\//, '');
|
||||
const page = Pages[name] || NotFoundPage;
|
||||
return hComp(page, this.state.path);
|
||||
return hComp(page, path);
|
||||
},
|
||||
};
|
||||
|
||||
// Set once the bootstrap session check settles (and implicitly on every
|
||||
// later login/logout transition — isAuthenticated flips reactively). Until
|
||||
// then the hashchange clamp below must NOT force unauthenticated hashes to
|
||||
// #/login: a valid-session reload arrives with its 'check' still in flight,
|
||||
// and clamping early would strand the user on login.
|
||||
let authChecked = false;
|
||||
|
||||
window.location.hash || (window.location.hash = router.state.path);
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||
const raw = location.hash.slice(1) || '/dashboard';
|
||||
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
|
||||
router.state.path = path;
|
||||
// Keep the URL in sync with the clamped path (loop-safe: the follow-up
|
||||
// hashchange lands on the already-clamped '/login').
|
||||
if (location.hash.slice(1) !== path) location.hash = path;
|
||||
});
|
||||
|
||||
/* ── Sidebar render root ───────────────────────────────────── */
|
||||
function Sidebar() {
|
||||
// No nav when logged out — unauthenticated users get the full-bleed
|
||||
// login page. Reactive: the auth model's terminal transition (logout /
|
||||
// session expiry) re-renders this root back to null.
|
||||
if (!isAuthenticated()) return null;
|
||||
const current = router.state.path;
|
||||
const nav = getNav();
|
||||
return h('div', { class: 'sidebar' },
|
||||
@@ -260,16 +284,8 @@ function MainContent() {
|
||||
|
||||
/* ── Init ──────────────────────────────────────────────────── */
|
||||
export async function initApp() {
|
||||
const sidebarEl = document.getElementById('sidebar');
|
||||
const mainEl = document.getElementById('main');
|
||||
if (sidebarEl && mainEl) {
|
||||
render(sidebarEl, Sidebar);
|
||||
render(mainEl, MainContent);
|
||||
}
|
||||
|
||||
// Listen for login events to update router state after auth
|
||||
window.addEventListener('auth:login', () => {
|
||||
router.isAuthenticated = true;
|
||||
// Defer to a macrotask: at dispatch time (microtask) the login form's
|
||||
// hash change has not run yet — router.state.path is still '/login'.
|
||||
// The deferred check runs after the hashchange task, so a fresh login
|
||||
@@ -290,17 +306,31 @@ export async function initApp() {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
// Check auth state before connecting WS
|
||||
// Check auth state BEFORE mounting the shell: an unauthenticated visitor
|
||||
// must never flash the sidebar or a protected page before the redirect
|
||||
// to #/login lands.
|
||||
await modelFetch('auth', { action: 'check' });
|
||||
authChecked = true;
|
||||
if (isAuthenticated()) {
|
||||
router.isAuthenticated = true;
|
||||
// A session restored at bootstrap (or a reload) may leave the URL on
|
||||
// #/login — the auth guard renders the login form for that hash even
|
||||
// when authenticated. Bounce to the default page so a valid session
|
||||
// never strands the user on a stale login screen.
|
||||
if (router.state.path === '/login') {
|
||||
window.location.hash = '/dashboard';
|
||||
}
|
||||
fetchInitialData();
|
||||
setTimeout(connect, 0);
|
||||
} else {
|
||||
// No valid session — redirect to login
|
||||
if (router.state.path !== '/login') {
|
||||
window.location.hash = '/login';
|
||||
}
|
||||
} else if (router.state.path !== '/login') {
|
||||
// No valid session — redirect to login before the first paint.
|
||||
window.location.hash = '/login';
|
||||
}
|
||||
|
||||
const sidebarEl = document.getElementById('sidebar');
|
||||
const mainEl = document.getElementById('main');
|
||||
if (sidebarEl && mainEl) {
|
||||
render(sidebarEl, Sidebar);
|
||||
render(mainEl, MainContent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
* 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';
|
||||
@@ -19,6 +25,11 @@ 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 }[] */
|
||||
@@ -55,6 +66,8 @@ function _wsConnect() {
|
||||
_wsConn.onopen = () => {
|
||||
_wsReconnectMs = 0;
|
||||
_wsFailCount = 0;
|
||||
_wsRefreshStreak = 0;
|
||||
_wsGivingUp = false;
|
||||
_wsClosingHandled = false;
|
||||
};
|
||||
|
||||
@@ -62,11 +75,25 @@ function _wsConnect() {
|
||||
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;
|
||||
|
||||
@@ -53,10 +53,13 @@ export default definePage({
|
||||
const ifaces = allNames.map(name => {
|
||||
const fw = fwIfaces.find(f => f.name === name);
|
||||
const netEntry = netIfaces[name] || {};
|
||||
// /api/network/interfaces returns {config, runtime} per interface —
|
||||
// state fields (state, addresses, mac) live under runtime.
|
||||
const runtime = netEntry.runtime || {};
|
||||
const traffic = sysTraffic[name] || {};
|
||||
const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : [];
|
||||
const addrs = netEntry?.addresses || [];
|
||||
const isUp = ['routable', 'degraded'].some(s => (netEntry.state || '').startsWith(s));
|
||||
const addrs = runtime.addresses || [];
|
||||
const isUp = ['routable', 'degraded', 'carrier'].some(s => (runtime.state || '').startsWith(s));
|
||||
return {
|
||||
name,
|
||||
mac: fw?.mac || null,
|
||||
|
||||
@@ -52,7 +52,8 @@ export default definePage({
|
||||
const zones = fwZones.available || [];
|
||||
const activeZones = fwZones.active || {};
|
||||
|
||||
const ifaces = Object.entries(netData).map(([name, entry]) => {
|
||||
// Loopback has no networkd config to manage — show real NICs only.
|
||||
const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => {
|
||||
let zone = null;
|
||||
for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
|
||||
if ((zIfaces || []).includes(name)) {
|
||||
|
||||
@@ -95,6 +95,11 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Full-bleed main when the sidebar renders nothing (logged-out / login view) */
|
||||
#sidebar:empty ~ .main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
@@ -255,6 +260,68 @@ body {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.login-form .form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
min-height: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-login,
|
||||
.btn-passkey {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user