feat: add auth subsystem with WebAuthn passkeys support

New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password,
lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth

Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users

Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps,
install script, server.py, app.js, and websocket/api clients
This commit is contained in:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+40 -12
View File
@@ -1,4 +1,4 @@
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=10';
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=10';
import DashboardPage from '/static/pages/dashboard.js?v=11';
import InterfacesPage from '/static/pages/interfaces.js?v=9';
@@ -12,9 +12,12 @@ import CertsPage from '/static/pages/certs.js?v=9';
import WireguardPage from '/static/pages/wireguard.js?v=9';
import LogsPage from '/static/pages/logs.js?v=9';
import NotFoundPage from '/static/pages/notfound.js?v=9';
import LoginPage from '/static/pages/login.js';
import PasskeysPage from '/static/pages/passkeys.js';
import UsersPage from '/static/pages/users.js';
/* ── Navigation items ──────────────────────────────────────── */
const Nav = [
const _NavBase = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/interfaces', label: 'Interfaces' },
{ path: '/zones', label: 'Zones' },
@@ -28,6 +31,15 @@ const Nav = [
{ path: '/logs', label: 'Logs' },
];
function getNav() {
const nav = [..._NavBase];
const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null');
if (perms && perms.auth === 'rw') {
nav.push({ path: '/users', label: 'Users' });
}
return nav;
}
modelRegister('firewall', {
subsystem: 'firewall',
fetch: async () => {
@@ -174,14 +186,17 @@ modelRegister('status', {
},
});
/* ── Initial fetch ─────────────────────────────────────────── */
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
modelFetch(name);
/* ── Initial fetch (after auth check) ───────────────────────── */
function fetchInitialData() {
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
modelFetch(name);
}
modelFetch('logs', 'journal');
}
modelFetch('logs', 'journal');
/* ── Page map ──────────────────────────────────────────────── */
const Pages = {
login: LoginPage,
dashboard: DashboardPage,
interfaces: InterfacesPage,
zones: ZonesPage,
@@ -193,11 +208,14 @@ const Pages = {
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;
@@ -213,10 +231,11 @@ window.addEventListener('hashchange', () => {
/* ── 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 =>
nav.map(item =>
Link({
path: item.path,
class: current === item.path ? 'active' : '',
@@ -236,17 +255,26 @@ function MainContent() {
}
/* ── Init ──────────────────────────────────────────────────── */
export function initApp() {
export async function initApp() {
const sidebarEl = document.getElementById('sidebar');
const mainEl = document.getElementById('main');
if (sidebarEl && mainEl) {
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
}
// Defer connect() after the first render microtask settles to prevent
// the initial requestUpdate() from triggering a second commit while
// the vnode tree is still being finalized.
setTimeout(connect, 0);
// Check auth state before connecting WS
const ok = await initAuth();
if (ok) {
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') {