- 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
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.
- websocket.js passes the raw JWT as the Sec-WebSocket-Protocol subprotocol (no 'Bearer ' prefix): subprotocol names must be valid RFC 6455 tokens, and the space in 'Bearer <token>' made the browser reject the constructor with a SyntaxError.
- daemon accepts a JWT-shaped subprotocol plus the legacy 'Bearer <token>' form via _extract_ws_token; unit tests in tests/test_ws_auth.py.
- vdom.js applies inline styles through el.style (CSSOM) instead of setAttribute, which the management-domain CSP (no 'unsafe-inline') blocks.
- install.sh opens http/https/ssh on the public zone alongside WAN setup.
- docs (hoover.md, security.md) updated to match.
Auth seeding (last-resort guard)
- `_seed_builtin_admin()` in get_db() now skips when
VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already
contains any user — previously a fresh service start after a non-default
bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an
unrecoverable random password, shadowing the operator's account
- bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap
creates the operator user itself on a fresh install, so exactly one
account exists and no seeded admin can appear
Frontend (session recovery)
- on page load/restore the in-memory TTL timer is gone, so a valid
7-day refresh token could sit in sessionStorage while the access token
is already expired server-side: the session `check` now attempts
exactly one refresh (POST /api/auth/refresh with the stored refresh
token) on 401 before treating the session as dead
- extract shared `_doRefresh()` used by both the `check` 401 fallback and
the `refresh` action (removes the duplicated rotation logic)
Tests
- update seeding tests to the new any-user-present check; add
test_seed_skipped_when_users_exist, test_seed_skipped_via_env,
test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS
test suite (tests/test-auth-model.js)
Docs
- AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN
- architecture.md / hoover.md / security.md: describe the bootstrap
check 401 → one-refresh fallback path
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.
- Move get_all_credential_counts import to top-level in daemon/handlers/auth.py
- Remove unused PROJECT_DIR in scripts/bootstrap_auth.py
- Fix leading space in api.js tryRefreshToken function
- Document WebSocket session binding limitation in docs/security.md
- Add session_id to refresh tokens and enforce it during validation,
preventing stolen refresh tokens from being usable without the
originating browser session
- Set router.isAuthenticated via auth:login event after successful
login (previously only set at page load)
- Add console.warn logging to WS message parse/handler errors
- Improve _refreshPromise error handling in token refresh flow
- Document rate limiter in-memory limitation and CSP connect-src
same-origin requirement
- Add 3 tests for session-bound refresh token validation
- Truncate admin password in logs; write full password to data/auth.log (0o600)
- Persist access token in sessionStorage so it survives page reloads
- Simplify tryRefreshToken to use GSAP-style promise deduplication
- Remove spurious POST redirect on 401 during token refresh
- Guard passkey button reference in login finally block
- Make session_id parameter optional in validate_token — only enforced when
provided, allowing WebSocket auth which cannot carry custom headers
- Remove decode_token round-trip from daemon WS handler
- Override WebAuthn registration username from JWT user_ctx to prevent users
from registering credentials under another user's account
- Frontend no longer sends username for WebAuthn registration
- Redirect to login on 401 after token refresh fails for POST requests
- Remove redundant auth session check from login page load
- Add tests for session_id semantics and WebAuthn ownership guard
Session binding was bypassable: if the X-Session-Id header was absent,
validate_token skipped the check entirely, allowing a stolen JWT to be
used without the originating session.
Server-side: reject 401 early in Flask middleware and daemon WebSocket
handler when X-Session-Id is missing, before calling validate_token.
Updated validate_token to always enforce session_id matching for access
tokens (refresh tokens are unaffected as they carry no session_id claim).
Frontend: removed dead if (stored.session_id) guards in api.js since
the header is now always required. Added X-Session-Id to logout request
headers and always store session_id on login/refresh.
- Rate limiter tracks failures only; success resets counter
- Record failures/successes after password verification, not before
- WebAuthn rp_id/origin resolved dynamically from request domain
- Management domains auto-discovered from nginx backend config
- All WebAuthn operations validate domain against management list
- Add GET /api/auth/webauthn/capable endpoint for frontend checks
- Frontend checkWebAuthnCapable() function for domain-gated UI
- Timing side-channel fix: pre-compute dummy hash at module load
- Builtin admin seeded with random password (logged at WARNING)
- Logout handler returns consistent response shape
- update_permissions now calls blacklist_active_refresh_token and
rotate_user_secret to immediately invalidate stale tokens
- create_user uses returned id from tx.run_one instead of redundant SELECT
- websocket reconnect explicitly closes old connection after token refresh
to prevent onclose handler race condition
Drop ?v=N version pins from all JS imports and HTML <link>/<script> tags.
Cache invalidation is now handled solely by server-side cache-control headers.
Update docs and AGENTS.md accordingly.
component.js now creates an AbortController for each page mount, passing
it to load(). On unmount, the controller is aborted to cancel in-flight
requests that would otherwise mutate unmounted state.
Page load functions consistently pass the signal to apiFetch and guard
state mutations with abort checks. This eliminates the need for per-page
abortController boilerplate and prevents stale errors from appearing on
rapid navigation.
Users page now guards catch block and loading state cleanup against
aborted requests, matching passkeys.js pattern.
The token refresh callback in websocket.js ignored its ok parameter, causing
an infinite reconnect loop when the server rejected the refresh attempt. On
failure, redirect to login instead of reconnecting with a cleared token.
clearAuthTokens() now also removes vw:permissions from sessionStorage to
prevent stale permissions from persisting across logout/login cycles. Also
removed duplicate vw:user removeItem call.
Aligns user and permissions storage with the existing sessionStorage-based
token model. Eliminates the dual-write pattern and stale cross-session data.
- websocket.js: schedule reconnect backoff when token refresh fails,
otherwise WebSocket stays dead after 3+ disconnects with failed refresh
- daemon/handlers/auth.py: remove two redundant try/except ValueError: raise
blocks in webauthn register/authenticate finish handlers
- docs/api.md: mark permissions as optional in Create User endpoint
- Pass client IP (X-Real-IP header) through Flask to daemon for both
password login and WebAuthn authenticate-finish endpoints
- Rate limiter now checks both IP and username buckets: IP layer catches
enumeration/brute-force attacks across multiple usernames; username
layer protects against single-account targeting from multiple IPs
- Add _wsRefreshing flag to prevent double-scheduling reconnect when
onclose fires during token refresh; simplify async IIFE to .then()/.catch()
- Reset _wsRefreshing on websocket onopen for safety
webui/static/hoover/api.js
Guard retryRes.json() with .catch(() => null) so non-JSON
responses (e.g. nginx 502/503) don't throw and lose the
actual status code. Falls back to 'HTTP <status>' error string.
lib/db_sqlite.py
Replace unsafe sql.split(';') loop with conn.executescript()
which properly handles semicolons inside string literals.
webui/server.py
Add _AUTH_PERSONAL set and _is_personal_auth() so personal
auth operations (session, password, logout, webauthn creds)
skip subsystem permission checks. Users with only firewall:read
can now manage their own credentials without needing auth:rw.
Apply ruff line-wrapping formatting to docs and test files.
Clarify auth middleware: extract user_permissions once before
subsystem check, removing conditional variable scoping.
- websocket: clear tokens on refresh failure to prevent infinite 401 loop
- api: write vw:user to sessionStorage on refresh for consistency with WS
- api: remove vw:user from sessionStorage in clearAuthTokens
- login: guard listener setup with flags to prevent duplicate attachment
- modal: skip inline button disable when handler uses processing state
- users: remove unused requestUpdate import
- WireGuard: refactor to multi-interface 'access classes' model; extract config
generation and helpers into lib/wireguard.py; add per-class up/down endpoints
and API routes; update UI with class management pages and QR code component
- Firewall: fix zone creation with --new-zone before --set-target; skip
masquerade on public zone; add masquerade propagation for nftables backend
so NAT works when internal zones exit via public
- Network: rename sync event subsystem 'network' -> 'networkd'; always stamp
config hash even when deployment fails (fixes pending-changes detection)
- DHCP: add new API endpoint and update frontend page
- State/Sync: update state collectors and sync buses for new subsystems
- Docs: update API and config documentation for new endpoints and schemas
Add get_interface_ip() helper to resolve an interface's IPv4 address
via 'ip -o addr show'. Use it to back-propagate gateway into DHCP
ranges so clients receive their default route.
- set_dhcp_range() resolves gateway: explicit > existing range > iface IP
- DnsToFirewallSync and FirewallToDhcpSync sync ensure gateways are set
- Remove automatic masquerade toggle from DnsToFirewallSync
- Fix dnsmasq lease file path to /var/lib/misc/dnsmasq.leases
- Rename lease state field expires_at -> expires (ISO string)
- Add 'ip -o addr show' to sudo whitelist
- lib/system_import: new module to import system configs into JSON at daemon startup
- daemon/server.py: call import_all() during startup for config reconciliation
- daemon/handlers/nginx.py: simplify add_domain auth handling, remove duplicate code
- scripts/install.sh: replace inline Python setup with curl-based daemon API calls; apply IP forwarding at runtime
- hoover: bump internal asset versions to v=8
- pages: bump asset versions to v=9
- update-vendor.sh now creates webui/vendor symlinks (htm.js)
- install.sh calls update-vendor.sh after package install
- Add vendor/.empty and webui/vendor/.empty as directory placeholders in git