Files
vacuum-wall/webui/static/app.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

312 lines
13 KiB
JavaScript

import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
import DashboardPage from '/static/pages/dashboard.js';
import InterfacesPage from '/static/pages/interfaces.js';
import ZonesPage from '/static/pages/zones.js';
import RulesPage from '/static/pages/rules.js';
import NatPage from '/static/pages/nat.js';
import DhcpPage from '/static/pages/dhcp.js';
import ProxyPage from '/static/pages/proxy.js';
import BackendsPage from '/static/pages/backends.js';
import CertsPage from '/static/pages/certs.js';
import WireguardPage from '/static/pages/wireguard.js';
import LogsPage from '/static/pages/logs.js';
import NotFoundPage from '/static/pages/notfound.js';
import LoginPage from '/static/pages/login.js';
import PasskeysPage from '/static/pages/passkeys.js';
import UsersPage from '/static/pages/users.js';
/* ── Navigation items ──────────────────────────────────────── */
const _NavBase = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/interfaces', label: 'Interfaces' },
{ path: '/zones', label: 'Zones' },
{ path: '/rules', label: 'Rules' },
{ path: '/nat', label: 'NAT' },
{ path: '/dhcp', label: 'DHCP' },
{ path: '/proxy', label: 'Proxy' },
{ path: '/backends', label: 'Backends' },
{ path: '/certs', label: 'Certs' },
{ path: '/wireguard', label: 'WireGuard' },
{ path: '/logs', label: 'Logs' },
{ path: '/passkeys', label: 'Passkeys' },
];
function getNav() {
const perms = getAuthData()?.permissions;
const nav = [..._NavBase];
if (perms && perms.auth === 'rw') {
nav.push({ path: '/users', label: 'Users' });
}
return nav;
}
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
modelRegister('auth', createAuthModel());
modelRegister('firewall', {
subsystem: 'firewall',
fetch: async () => {
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
apiFetch('/api/firewall/config'),
apiFetch('/api/firewall/zones'),
apiFetch('/api/firewall/services'),
apiFetch('/api/firewall/interfaces'),
apiFetch('/api/firewall/state'),
]);
const result = {};
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
return result;
},
});
modelRegister('network', {
subsystem: 'networkd',
fetch: async () => {
const r = await apiFetch('/api/network/interfaces');
if (!r.ok) throw new Error(r.error);
return r.data || { interfaces: {} };
},
});
modelRegister('dnsmasq', {
subsystem: 'dnsmasq',
fetch: async () => {
const [cfg, status, leases] = await Promise.allSettled([
apiFetch('/api/dhcp/config'),
apiFetch('/api/dhcp/status'),
apiFetch('/api/dhcp/leases'),
]);
const result = {};
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
return result;
},
});
modelRegister('nginx', {
subsystem: 'nginx',
fetch: async () => {
const r = await apiFetch('/api/proxy/domains');
if (!r.ok) throw new Error(r.error);
return { domains: r.data || [] };
},
});
modelRegister('backends', {
subsystem: 'nginx',
fetch: async () => {
const r = await apiFetch('/api/proxy/backends');
if (!r.ok) throw new Error(r.error);
return r.data || {};
},
});
modelRegister('acme', {
subsystem: 'acme',
fetch: async () => {
const [listR, acctR] = await Promise.allSettled([
apiFetch('/api/certs/list'),
apiFetch('/api/certs/account'),
]);
const result = {};
if (listR.status === 'fulfilled' && listR.value.ok) {
result.certs = listR.value.data || [];
} else if (listR.status === 'rejected' || !listR.value.ok) {
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
}
if (acctR.status === 'fulfilled' && acctR.value.ok) {
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
}
return result;
},
});
modelRegister('wireguard', {
subsystem: 'wireguard',
fetch: async () => {
const [stR, pR, cfgR] = await Promise.allSettled([
apiFetch('/api/wireguard/status'),
apiFetch('/api/wireguard/peers'),
apiFetch('/api/wireguard/config'),
]);
const result = {};
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
return result;
},
});
const LOG_TABS = {
journal: '/api/logs/journal',
'nginx-access': '/api/logs/nginx/access',
'nginx-error': '/api/logs/nginx/error',
dnsmasq: '/api/logs/dnsmasq',
app: '/api/logs/app',
};
modelRegister('logs', {
subsystem: '*',
fetch: async (signal, tab) => {
const tabKey = tab || 'journal';
const url = LOG_TABS[tabKey];
if (!url) throw new Error('Unknown log tab: ' + tabKey);
const r = await apiFetch(url, { signal });
if (!r.ok) throw new Error(r.error);
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
},
});
modelRegister('status', {
subsystem: '*',
fetch: async () => {
const [pendingR, metricsR] = await Promise.allSettled([
apiFetch('/api/status/pending'),
apiFetch('/api/status/system-metrics'),
]);
const result = {};
if (pendingR.status === 'fulfilled' && pendingR.value.ok) {
result.pending = pendingR.value.data || {};
}
if (metricsR.status === 'fulfilled' && metricsR.value.ok) {
result.metrics = metricsR.value.data || {};
}
return result;
},
});
/* ── Initial fetch (after auth check) ───────────────────────── */
function fetchInitialData() {
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
modelFetch(name);
}
modelFetch('logs', 'journal');
}
/* ── Page map ──────────────────────────────────────────────── */
const Pages = {
login: LoginPage,
dashboard: DashboardPage,
interfaces: InterfacesPage,
zones: ZonesPage,
rules: RulesPage,
nat: NatPage,
dhcp: DhcpPage,
proxy: ProxyPage,
backends: BackendsPage,
certs: CertsPage,
wireguard: WireguardPage,
logs: LogsPage,
passkeys: PasskeysPage,
users: UsersPage,
};
/* ── Router ────────────────────────────────────────────────── */
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
isAuthenticated: false,
component() {
const name = this.state.path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
},
};
window.location.hash || (window.location.hash = router.state.path);
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
/* ── Sidebar render root ───────────────────────────────────── */
function Sidebar() {
const current = router.state.path;
const nav = getNav();
return h('div', { class: 'sidebar' },
h('div', { class: 'logo' }, 'Vacuum Wall'),
h('nav', null,
nav.map(item =>
Link({
path: item.path,
class: current === item.path ? 'active' : '',
children: [item.label],
}),
),
),
);
}
/* ── Main content render root ──────────────────────────────── */
function MainContent() {
return [
router.component(),
ToastContainer(),
];
}
/* ── 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
// fetches all models. connect() is idempotent (_wsConnect no-ops with
// a live connection) and gives the post-login session its WS — today
// WS only connects on an authenticated page load (pre-existing gap).
setTimeout(() => {
connect();
if (!router.state.path.startsWith('/login')) {
fetchInitialData();
}
}, 0);
});
// Terminal auth transition — close the WS socket so a same-tab relogin
// establishes a fresh connection with the new user's token.
window.addEventListener('auth:logout', () => {
disconnect();
});
// Check auth state before connecting WS
await modelFetch('auth', { action: 'check' });
if (isAuthenticated()) {
router.isAuthenticated = true;
fetchInitialData();
setTimeout(connect, 0);
} else {
// No valid session — redirect to login
if (router.state.path !== '/login') {
window.location.hash = '/login';
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initApp);
} else {
initApp();
}