- POST /acme/renew returns a request_id and spawns a background task
(renew/deploy/refresh steps); dedups per-domain like issue
- completes as "skipped" when acme.sh reports the renewal window
has not been reached (no --force)
- new GET /acme/renew/status endpoint (iface + handler + blueprint)
- run acme.sh subprocesses off the event loop (asyncio.to_thread)
in both issuance and renewal
- ActionCell: busy/busyLabel props; certs page disables the Renew
button and polls renewal status with toasts for success/skip/fail
- 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.
Add require_session parameter to validate_token to enforce session_id
matching for refresh operations. Attacker with stolen refresh token can
no longer bypass session binding by omitting session_id from request.
Also adds backend guard against deleting builtin admin user (was only
blocked at Flask blueprint layer), and removes unused _ALL_RW variable.
- 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
The warning message referenced 'data/auth.log' but the actual file is
/var/log/vacuum-wall/auth.log. Also add error logging when the write
fails, instead of silently swallowing the OSError.
- update_permissions: swap to upsert-first-then-delete-stale so a
failed upsert mid-loop rolls back cleanly, leaving the user's
permissions intact. Adds Q_DELETE_PERMISSION_SUBSYSTEM for
targeted removal.
- auth_refresh: generate and persist the new refresh token before
blacklisting/clearing the old one, so a failure in generate_tokens
doesn't leave the user locked out with no valid refresh token.
- 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
Browsers cannot send custom X-Session-Id header on WebSocket connections,
so decode the token payload to extract session_id. Add WebAuthn
success/failure recording to support rate limiter counter resets.
- Raise ValueError on missing request body in auth_logout
- Add username check in verify_authentication to prevent credential reuse
- Replace N+1 queries in list_users with single JOIN query
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.
The blacklist table grew indefinitely since cleanup only ran on
password changes and user deletions. Now each blacklist_token()
call has a 2% chance of triggering blacklist_expired() to prune
expired entries. Redundant cleanup calls in update_password()
and delete_user() are removed.
- 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