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.
- 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
- 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
- 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
- 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
- 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
Replace the legacy top-level management key with a unified paths-based
model. Each domain now contains a paths map where each entry defines its
own backend, auth, headers, and flags (is_management, is_websocket).
- Add _migrate_config() to auto-migrate legacy formats on first load
- Remove set_management_proxy() and POST_NGINX_MANAGEMENT endpoint
- Update server_block.conf template to iterate paths with per-location auth
- Update daemon handler, API blueprint, state collector, and install script
- Add server config generation tests for paths, WebSocket, auth inheritance
- Update frontend proxy page to display per-path rows with flags
- Move acme.sh utilities (_run_acme, _find_acme, etc.) from lib/state to lib/acme
- Rewrite _parse_list_output to support pipe, tab, and column-separated formats
- Add ConflictError (409) to block issuing when cert already exists
- Move _find_issuance helper to detect in-progress issuance per domain
- Update issue_cert to check existing certs and return issuance status
- Fix start_polling to accept event loop explicitly
- Add sudoers entry for chown on vacuum-wall.conf
- Extend systemd ReadWritePaths for /run/nginx.pid and /var/log/nginx
- Update frontend to handle 'existing' issuance status
- Register, view, and deactivate ACME accounts via API and UI
- 16-check validation framework for certificate issuance readiness
- DNS resolution, port, nginx, and firewall pre-flight checks
- External IP detection with NAT support and fallback providers
- Account card and settings modal in certificates page
- Guard certificate issuance behind account registration
- Update modal CSS to overlay-based approach
- 1000+ lines of tests for validation and account handlers
Add hoover model.js as a central reactive store per subsystem, replacing
per-component data fetching with a single source of truth.
- Add hoover/model.js with modelRegister, modelFetch, and WS invalidation
- Refactor websocket.js to route messages to model refresh (drop per-component
subscribe/unsubscribe)
- Simplify component.js by removing WS subscription management
- Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort
- Rewrite all pages to use getModel() instead of inline data fetching
- Bootstrap model registrations in app.js
- Add GET /api/firewall/state endpoint
- Fix restart-services.sh restart order and add service health verification
- Update hoover.md docs with model layer architecture
Phase 1-4: Networkd subsystem
- lib/network.py: systemd-networkd config renderer (.network INI files)
with full schema support: [Match], [Link], [Network], [Address], [Route],
[DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec.
Route sections use #N suffix per systemd.syntax(7).
- lib/network.py: generate_network_files() with 50-<name>.network prefix
and stale file cleanup
- lib/network.py: collect_upstream_dns() filters local/private DNS
- lib/network.py: infer_dhcp_ranges() and infer_zones() helpers
- daemon/handlers/network.py: routes for GET/POST /network/interfaces
and full apply with DNS upstream sync to dnsmasq
- webui/api/network.py: Flask blueprint for /api/network/* endpoints
- webui/api: interfaces page updated with IP config inline editing
- lib/state.py: networkd collector using parse_networkctl_status()
- system/sudoers.d/vacuum-walld: networkctl + systemd-network rules
- system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network
- install.sh: ACME email now optional, configured from WebUI
- lib/acme.py: get_email() falls back to declarative config
Phase 5: Code review fixes
- daemon/server.py: path params now win over JSON body and query params
in request body merge (prevents config save name override)
- daemon/server.py: remove dead 'import re'
- daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir
for /etc/systemd/network (ProtectSystem=strict compatibility)
- system/sudoers.d/vacuum-walld: pin systemctl to specific commands
(reload/is-active dnsmasq instead of wildcard)
- system/sudoers.d/vacuum-walld: restore !requiretty and section comment
- lib/network.py: remove unused _MANAGEMENT_PORTS constant
- webui/api/network.py: remove redundant body[\name\] = name in save_interface
Tests: 332 passing (110 new/updated), ruff clean
Add comprehensive docstrings to firewall, DHCP, proxy, wireguard, certs,
and logs API endpoints. Document parameters, return values, and error cases
for the documentation system.
- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
- wireguard: POST /peers with JSON encoding (was /add-peer)
- rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render
- nat: port forward delete uses URL path params to match blueprint
- nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch)
- app.js renderers updated to use URL path deletes for rules and forwards
- remove TODO.md
New two-step config flow: POST /config saves desired state to
config/firewall/config.json, GET /config/pending diffs against live
firewalld state, POST /config/apply synchronizes live state. Adds target
normalization helpers and full test coverage for config CRUD and pending
diff logic.
- dashboard.html: Fix zones, leases, wg_status, cert key names, add services var
- server.py: Pass services to dashboard template via _get_service_status()
- lib/acme.py: Fix dead third date format (%Y%m%d%H%M%z) using astimezone(UTC)
- lib/wireguard.py: Add -- separator to cp command to match sudoers rule
- lib/nginx.py: Replace shallow dict.copy() with {**...} for DEFAULT_SSL
- AGENTS.md: Update test count 149 -> 154
- docs/api.md: Rename cert field expiry -> expires_at