Compare commits

..

71 Commits

Author SHA1 Message Date
mteehan 55309cfd86 status: cancel-all reverts pending changes to last applied config
- lib.common.revert_to_applied(): restore a config file from its
  _last_applied_config snapshot (stamped hash); no baseline -> skip with
  reason, file untouched
- firewall config_apply now stamps the applied baseline like the other
  subsystems; GET /firewall/config and the state collector strip the
  internal _last_applied_* keys
- POST /status/cancel-all + /api/status/cancel-all: revert pending
  subsystems, {cancelled, skipped, errors}, partial-failure safe
- dashboard: "Cancel All Changes" button with confirm modal
  (CancelConfirm, reuses the pending-changes modal rows); the pending
  changes card is hidden entirely when nothing is pending
- tests: revert_to_applied, status_cancel_all, firewall stamping/meta
  stripping, /api/status/cancel-all route, node tests for CancelConfirm;
  firewall _config_apply tests no longer write the real repo config
- docs: api.md, state-model.md, config.md, hoover.md
2026-08-21 02:10:05 +00:00
mteehan 30b51ad7d3 state: applied-config snapshots + per-field pending diffs
- lib/common: stamp_applied() now records a _last_applied_config
  snapshot alongside the hash; strip_apply_meta() centralizes
  bookkeeping-key stripping; deep_diff() reports field-level changes
- state collectors (dnsmasq/nginx/wireguard/networkd) expose
  pending_diff so the dashboard can show exactly which fields
  changed since the last apply (wireguard diff excludes
  private_key paths)
- dashboard pending-changes card renders per-change lines with a
  generic fallback when no snapshot is recorded
- firewall: firewalld built-in zones no longer flagged as
  unmanaged; public-zone masquerade skipped in pending changes
  since apply drives it via nftables propagation
- schema: PendingChange TypedDict; pending_diff on DnsmasqStatus /
  WgStatus; tests in test_common.py, test_firewall.py, test_state.py
2026-08-21 00:59:19 +00:00
mteehan a77cee821b Restart script re-execs itself with sudo when not run as root
The script restarts system services via systemctl, which requires root.
Previously it would only fail per-command when run by an unprivileged
user. Add an EUID guard at the top that re-execs the script through
sudo, preserving the original path and arguments.
2026-08-20 23:05:54 +00:00
mteehan 94705490b9 certs: async renewal via background task + status polling
- 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
2026-08-20 13:10:16 +00:00
mteehan 332d14e37d ws: migrate push stream to data streaming
- 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
2026-08-20 01:38:00 +00:00
mteehan 9c9f92ad04 fix: daemon /run spawn hardening, auth guard before first paint, WS refresh cap, interfaces runtime state
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.
2026-08-19 15:32:36 +00:00
mteehan 4bd4c374fd fix: send WS JWT as bare subprotocol name; CSSOM inline styles; open mgmt services on public zone
- 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.
2026-08-18 13:36:55 +00:00
mteehan 183904faad fix: seed builtin admin only on empty DB; recover page-load sessions with one refresh
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
2026-08-18 00:00:09 +00:00
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
mteehan 1980043afd docs: update hoover.md, audit residual storage reads, verify 2026-08-15 08:25:40 +00:00
mteehan 4f74192302 app: use auth model for init, sidebar, login 2026-08-15 02:08:16 +00:00
mteehan 64f3a77411 auth: slim components/auth.js to ceremony helpers + doLogin 2026-08-15 01:54:50 +00:00
mteehan 11a398ce89 ws: decouple reconnection from auth internals, use model 2026-08-15 01:41:09 +00:00
mteehan 382bbd989b api: remove auth helpers, route 401 through auth model 2026-08-15 00:53:05 +00:00
mteehan 5d84710d1a auth: create auth model definition (hoover/auth_model.js) 2026-08-14 23:42:56 +00:00
mteehan 61d95b99a4 model: add onSuccess/onFailure lifecycle hooks to modelRegister 2026-08-14 23:22:39 +00:00
mteehan c7593f8a1e fix: close refresh token session binding bypass
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.
2026-08-12 20:06:05 +00:00
mteehan 9ae2cca801 fix: clean up lazy import, dead var, whitespace; document WS session caveat
- 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
2026-08-12 19:18:28 +00:00
mteehan 818721ae5c Fix incorrect password log path in _seed_builtin_admin
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.
2026-08-12 18:21:05 +00:00
mteehan 85d8770ba6 fix: address auth subsystem issues from ws-debug review
- lib/auth: make RateLimiter.is_allowed read-only (no dict mutation on read)
- daemon/server: add periodic blacklist_expired cleanup to poll loop (60s interval)
- daemon/server: negotiate only matched Bearer subprotocol on WebSocket connect
- webui/server: rewrite _is_personal_auth with path-prefix matching, cover WebAuthn register routes
- daemon/handlers/auth: eliminate redundant get_user call in auth_update_user
2026-08-12 17:51:09 +00:00
mteehan 0889ef0d08 fix: prevent data loss in update_permissions and token_refresh
- 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.
2026-08-12 16:37:06 +00:00
mteehan 6404508519 fix: harden auth with refresh token session binding, logging, and router state
- 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
2026-08-12 15:53:17 +00:00
mteehan 76300e281f security: harden builtin admin pwd logging and fix auth token persistence
- 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
2026-08-12 14:54:07 +00:00
mteehan e01574c67e auth: make session binding optional, enforce WebAuthn credential ownership
- 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
2026-08-12 14:16:49 +00:00
mteehan 3654209b78 auth: fix WS session_id extraction and track WebAuthn success/failure
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.
2026-08-12 04:27:14 +00:00
mteehan 6f728cf853 fix auth: validate logout body, harden WebAuthn, optimize list_users
- 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
2026-08-12 03:58:54 +00:00
mteehan ba0c7bfa9b remove unused token query param fallback from WS auth
The ?token= fallback leaked JWTs in server logs and was never used by
the client, which always sends the token via WebSocket subprotocol
header.
2026-08-12 01:46:00 +00:00
mteehan c64f988ba2 fix: dhcp auto-sync UI refresh, users.js null guard, and login toast escaping 2026-08-12 01:46:00 +00:00
mteehan b69ca330f4 enforce mandatory X-Session-Id header for access token validation
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.
2026-07-30 22:55:16 +00:00
mteehan 43b44ad340 fix: add probabilistic cleanup to token blacklist
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.
2026-07-29 03:41:15 +00:00
mteehan 48f8d0be18 Auth: rate limiter, WebAuthn domain awareness, misc fixes
- 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
2026-07-29 02:48:53 +00:00
mteehan 6d30f1387e fix: indentation in frontend handlers and restore dhcp auto-sync toast 2026-07-28 19:32:17 +00:00
mteehan 8ae60ab8cf fix: harden auth and fix frontend issues
- Add builtin admin user with full access, immutable permissions (lib/db.py, lib/auth_users.py, webui/static/pages/users.js)
- Fix passkeys TypeError on string throws (webui/static/pages/passkeys.js)
- Add zero-permission warning in create user modal (webui/static/pages/users.js)
- Restore readonly on proxy paths textarea (webui/static/pages/proxy.js)
- Mask credential ownership errors to prevent enumeration (lib/webauthn.py, tests/test_auth.py)
2026-07-28 18:52:03 +00:00
mteehan a82578f342 fix: invalidate tokens on permission change (medium), optimize create_user query, fix ws reconnect race
- 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
2026-07-28 18:02:14 +00:00
mteehan 8bb3619ddc refactor: extract shared utilities and standardize page patterns
- Add fmtBytes() and csvToArr() helpers to hoover/helpers.js
- Replace inline async patterns with ActionButton/ConfirmDelete in wireguard.js
- Convert addDomain/editDomain to QuickModal + apiSubmit in proxy.js
- Convert settingsModal handlers to formAction in certs.js
- Remove redundant synced handling from dhcp.js apply button
- Add onComplete callback to ConfirmDelete (fixes users.js onRefresh bug)
- Fix passkeys.js ActionCell/Table usage (invalid component API)
- Remove duplicate fmtBytes from dashboard.js
2026-07-28 17:32:51 +00:00
mteehan 244576b8eb fix: render Add passkey button on empty state
The Empty component ignores children, so the button was silently dropped
when using <Empty> as an htm wrapper. Inline the card structure instead.
2026-07-28 16:45:34 +00:00
mteehan 3de82e3b9b Remove query-string cache-busting from static assets
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.
2026-07-28 13:50:22 +00:00
mteehan c943d17bb3 Fix typo: 'PassKey' -> 'Passkey' in toast message 2026-07-28 13:44:52 +00:00
mteehan f77473c13c fix: missing brace in users.js, ws double-increment, b64url stack overflow, docs storage 2026-07-28 13:17:12 +00:00
mteehan ca27ea5522 feature: framework-level abort handling for page lifecycle
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.
2026-07-28 02:52:28 +00:00
mteehan d52a0fad12 fix: check tryRefreshToken result in WS reconnect, clear permissions on logout
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.
2026-07-28 02:44:09 +00:00
mteehan c4a10c7129 refactor: switch auth data from localStorage to sessionStorage
Aligns user and permissions storage with the existing sessionStorage-based
token model. Eliminates the dual-write pattern and stale cross-session data.
2026-07-28 02:16:24 +00:00
mteehan 739253b2e5 fix: WS reconnection deadlock, remove redundant try/except, fix docs
- 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
2026-07-28 01:44:10 +00:00
mteehan 358573567d fix: dual-key rate limiting for auth + websocket reconnect guard
- 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
2026-07-28 00:54:48 +00:00
mteehan 76cd219050 fix: harden retry JSON parsing and exempt personal auth routes
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.
2026-07-27 20:38:50 +00:00
mteehan e48ba72b81 fix: 401-401 retry on non-401, move login DOM bindings into page lifecycle, add abort support 2026-07-27 19:46:30 +00:00
mteehan cc5679a1cd fix: deduplicate token refresh, serialize concurrent attempts, clean up logout path 2026-07-27 19:15:27 +00:00
mteehan ca110c321d style: format docs, fix user_permissions variable scoping in auth middleware
Apply ruff line-wrapping formatting to docs and test files.
Clarify auth middleware: extract user_permissions once before
subsystem check, removing conditional variable scoping.
2026-07-27 18:37:11 +00:00
wall d4213fb93b fix: auth reconnection loop, duplicate login listeners, modal double-disable
- 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
2026-07-24 04:02:44 +00:00
mteehan edaf16a433 fix: remove dead auth_refresh code, fix logout storage, align bootstrap TTL, add hash rehash, tighten CSP
- Remove duplicate dead code in daemon/handlers/auth.py (auth_refresh)
- Fix logout reading refresh token from localStorage instead of sessionStorage
- Align bootstrap auth config access_token_ttl (900 -> 300) with hardened default
- Add password hash rehash check on successful login (needs_rehash was unused)
- Remove 'unsafe-inline' from CSP style-src (all styles are applied via JS DOM API)
2026-07-24 03:09:25 +00:00
mteehan a365059976 security: harden JWT auth with session binding, CSP headers, and sessionStorage
- Reduce access_token_ttl from 900s to 300s (5 min) to shrink XSS exploit window
- Add session_id claim to JWT tokens tied to browser session (X-Session-Id header)
- Flask middleware validates session_id matches header on every request
- CSP headers: default-src/script-src 'self', no unsafe-inline/eval, frame-ancestors none
- X-Content-Type-Options: nosniff on all responses
- Move refresh token from localStorage to sessionStorage (tab-scoped, cleared on close)
- Timing-safe password verification (dummy Argon2id for unknown users)
- WebSocket auth also validates session_id header
- Add 5 session_id tests and 3 CSP header tests
2026-07-24 03:09:07 +00:00
mteehan 56b200d233 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
2026-07-24 01:21:39 +00:00
mteehan 04417cf05c WireGuard access classes, firewall nftables fixes, network sync event refactor
- 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
2026-07-20 03:57:16 +00:00
mteehan dadabd7954 feat: add system metrics dashboard with resource monitoring
- Add system metrics endpoint (CPU load, memory, swap, network traffic)
- Collect metrics from /proc and /sys (no subprocess required)
- Overhaul dashboard to pull from per-subsystem models
- Remove deprecated /status/all monolithic endpoint
- Improve networkd import to handle optional priority prefix
- Fix CSS duplicate .grid-4 rule and unused dashboard imports
2026-07-15 00:24:43 +00:00
mteehan c21639b7f1 docs: add comprehensive docstrings and inline comments
Add docstrings to all handler functions in daemon/handlers/firewall.py, covering
params, return values, and raised exceptions. Add inline comments to
_config_apply() reconciliation steps and the request body merge order.

Add docstrings across lib/ modules for emit helpers (_emit_str, _emit_int, etc.),
volatile stripping logic, two-layer diff strategy, sync event dispatch, and all
cross-subsystem sync subscribers (DnsToFirewall, WgToFirewall, FirewallToDhcp,
NetworkToAllSync).

Document WireGuard/networkd config parsers and key-value mappers in
system_import.py. Add docstrings to _ep(), Registry.decorator,
setup_logging, and _replace helper across daemon/ and lib/.
2026-07-13 17:26:45 +00:00
mteehan 2e49dec633 feat: auto-generate self-signed certs, hoover modal processing guard, refactor backends/proxy pages 2026-07-13 14:30:35 +00:00
mteehan 05524f3756 fix: critical bugs + security hardening
Phase 1 (critical bugs):
- Fix firewall import string-to-list bug (system_import.py)
- Add rich rules removal in firewall config apply (handlers/firewall.py)

Phase 2 (security hardening):
- Restrict sudo wildcards to specific paths (sudoers.d/vacuum-walld)
- Fix TOCTOU: use /run/vacuum-wall/ for temp files (nginx, dnsmasq, network handlers)
- Remove unnecessary sudo from wg genkey/pubkey (handlers/wireguard.py)

Phase 3 (validation):
- Validate poll intervals > 0 (daemon/server.py)
- Restrict sysctl to whitelisted parameters (handlers/network.py)

Phase 4 (defensive programming):
- Enforce shell=False in run() and run_proc() (lib/common.py)
- Track issuance tasks for graceful shutdown (handlers/acme.py)
- Add nginx template marker consistency tests (tests/test_system_import.py)
2026-07-11 12:21:36 +00:00
mteehan 803258cf18 dhcp: auto-populate gateway from interface IP for DHCP ranges
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
2026-07-09 00:58:09 +00:00
mteehan 5135de0921 feat: add system config import, refactor install script and nginx auth
- 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
2026-07-08 02:20:28 +00:00
mteehan fb39af126a docs: update documentation and project structure
- Update AGENTS.md, README.md, and docs/* with revisions
- Refactor lib/acme.py and lib/state.py
- Add tests for acme module
- Remove install.sh and restart-services.sh (moved to scripts/)
- Normalize vendor files (acme.sh, htm.js)
2026-07-02 14:41:02 +00:00
mteehan b4d13c4bd5 Add restart-services script to scripts/ directory 2026-07-02 14:39:35 +00:00
mteehan 20266e4a2f rename vendor files to put extension after version string
Flask's send_file uses mimetypes.guess_type() which needs the file extension
at the end of the path. Previously files were named acme.sh-3.1.3 and
htm.js-3.1.1 — now acme-3.1.3.sh and htm-3.1.1.js.

Also: update-vendor.sh now owns ACME_HOME installation, removing duplicate
logic from install.sh.
2026-07-02 04:01:52 +00:00
mteehan 8c13ad55ce Add update-vendor.sh symlink support, unify install.sh vendor flow
- 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
2026-07-01 00:55:03 +00:00
mteehan 575cf06a4b hoover: move ToastContainer to separate module, add reactive re-render, bump module cache v8 2026-06-30 04:01:41 +00:00
mteehan 9088f34345 sync: add cross-subsystem event bus for config consistency
Add EventBus with loop guards to keep firewall, dnsmasq, wireguard,
and network configs consistent. Handlers emit SyncEvent after mutations;
subscribers compute diffs and write JSON without manual cascade loops.
2026-06-30 01:18:44 +00:00
mteehan 348bbfbca6 dhcp: track pending config changes with hash, update UI button 2026-06-28 16:26:32 +00:00
mteehan baa441fa13 dhcp: auto-populate IP range when selecting interface
- QuickModal: add postRender callback for DOM-level initialization
- dhcp: derive start/end from interface CIDR on change
2026-06-28 13:04:51 +00:00
mteehan 391466664e Update status checks and add optgroup support to form modals
- modal.js: add optgroup support for select options in formModal
- dhcp.js: use zone-based interface selector for DHCP ranges
- dashboard.js, wireguard.js: use status.up for state checks
- proxy.js: use inner.querySelector for modal field lookup
2026-06-28 12:47:59 +00:00
mteehan d8d8425340 docs: update API conflict status, ACME config, request flow, deployment, and security sudoers 2026-06-28 01:58:56 +00:00
mteehan 25a1943fce Optimize firewall state collection and improve daemon shutdown
- Replace per-zone --list-all calls with single --list-all-zones in _collect_firewall
- Add _parse_all_zones_output() parser with rich rules/rich-rules normalization
- Convert daemon shutdown to async with proper runner cleanup and socket unlink
- Add TimeoutStopSec=15 to vacuum-walld.service for graceful stop
- Fix exception handling in _collect_dnsmasq
- Remove management badge from proxy path rows
2026-06-28 00:54:01 +00:00
mteehan 80dd4e3272 proxy: refactor modals and add cert selector options 2026-06-27 23:59:12 +00:00
129 changed files with 23752 additions and 10969 deletions
+6
View File
@@ -8,6 +8,12 @@ __pycache__/
# Package build
*.egg-info/
# vendor dirs
vendor/*
!vendor/.empty
webui/static/vendor/*
!webui/static/vendor/.empty
# Tool caches
.pytest_cache/
.ruff_cache/
+64 -56
View File
@@ -8,7 +8,7 @@ Deploys on Debian 13 (trixie). Serves from repo root by default.
## Architecture
```
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
Client ──→ nginx (TLS; basic auth on basic-authed proxy domains only) ──→ Flask (127.0.0.1:9090)
Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock)
vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ system service
```
@@ -16,6 +16,15 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ syste
Blueprints are thin proxies — they never call `lib/` directly. All operations flow
through the daemon client over a Unix socket.
**Authentication:** the management UI gets **no** nginx-level `auth_basic` — the mgmt
server block's `location /` is a bare proxy and `/ws` is `auth_basic off`. Management
auth is the Flask-layer JWT middleware (`POST /api/auth/login`
`Authorization: Bearer <token>`; public paths: static files, `/vendor/`, auth endpoints)
plus the daemon WS handshake (raw JWT as the `Sec-WebSocket-Protocol` subprotocol).
Basic auth (`.htpasswd`) renders **only** for proxy domains whose
`config/nginx/config.json` has an `auth` block — never for the management domain
(see `docs/security.md`, "Management Interface").
### Two-User Model with Shared Group
- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns socket. Primary group is the WebUI user's primary group. Daemon user name is derived: `USER_NAME` + `d`.
@@ -24,74 +33,73 @@ through the daemon client over a Unix socket.
### Code Layout
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA catch-all renders `index.html` with server-side `__WS_URL_PLACEHOLDER__` substitution (no Jinja).
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`. SPA root route (`/`) serves `index.html` (no templating). All other paths return 404.
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh.
- `daemon/server.py` — aiohttp server, route registry, batch routing, WebSocket broadcast, state refresh, periodic polling.
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
- `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming an endpoint here auto-updates both server registry and client calls. All blueprints and handlers import from here.
- `daemon/handlers/*.py` — Privileged operation handlers (all `sudo` calls live here).
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on request. Backs WebSocket versioning/broadcast.
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `validate_interface_name()`. All `lib/` modules use these instead of defining local helpers.
- `lib/logging.py` — Logging setup used by both webui and daemon.
- `daemon/iface.py`**Single source of truth** for all daemon API endpoints. Every endpoint is a frozen `(method, path)` tuple. Renaming here auto-updates both server registry and client calls.
- `daemon/handlers/*.py` — Privileged operation handlers. All `sudo` calls live here.
- `lib/state.py` — In-memory state store with per-subsystem collectors. Populated at daemon startup, refreshed on mutation/poll. Backs the WebSocket push stream: `get_snapshot()` (full state on WS connect), `poll()` two-layer diff (structural `versions` broadcast vs volatile-only `tick` broadcast, per-subsystem, each carrying the full subsystem data), `register_volatile(subsystem, keys)` to mark volatile fields, `get_versions()`/`bump()`. Per-subsystem poll intervals via `_DEFAULT_POLL_INTERVALS` (system 1s, firewall 30s, wireguard/dnsmasq/networkd 10s, nginx 60s, acme 300s).
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`, `config_hash()`, `validate_interface_name()`.
- `lib/logging.py` — Logging setup used by both webui and daemon. Reads `VACUUM_WALL_LOG_LEVEL`.
- `lib/*.py` — Backend modules (parsing, config, shared logic). Full type hints and `__all__` exports. No sudo calls.
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htmx`, `json-enc`).
- `vendor/` — Vendored scripts and JS libraries (`acme.sh`, `htm`).
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth).
- `system/` — System file templates. `systemd/` (units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`).
`__init__.py` files in `webui/`, `lib/`, and `daemon/` are intentionally empty.
### Frontend (hoover)
Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference.
Conventions:
- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components).
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default.
- All imports from `/static/hoover/index.js` (barrel export).
- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })`.
- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS.
- `h()` builds VNodes; `html` tag (from htm) enables JSX-like templates; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff.
- Events: `h()` uses `on:click` prefix. `html` templates use camelCase `onClick` (adapter translates to `on:click`).
- `h()` builds VNodes with `on:click` prefix. `html` tag (htm) templates use camelCase `onClick` (adapter translates).
- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root.
- No build step — ES modules served raw. Assets versioned via `?v=N` query string.
- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission.
- No build step — ES modules served raw. Cache controlled via HTTP headers.
### Daemon Endpoints
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET` env var)
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state change notifications
- Can be started as `python -m daemon.server` or via the `vacuum-walld` console script
- Unix socket at `data/daemon.sock` (configurable via `VACUUM_WALLD_SOCKET`)
- WebSocket at `127.0.0.1:9091` (configurable via `VACUUM_WALLD_WS_PORT`) for real-time state streaming: full `snapshot` on connect, then per-subsystem data-carrying `versions` (structural) / `tick` (volatile-only) deltas. The client patches reactive models in place via `modelSet()` — no HTTP round-trip for auto-refresh.
- Periodic polling per subsystem via `lib.state._DEFAULT_POLL_INTERVALS`, overridable with `VACUUM_WALL_POLL_INTERVALS` env var (format `subsystem:seconds,subsystem:seconds`)
- Start as `python -m daemon.server` or via the `vacuum-walld` console script
## Environment Variables
- `VACUUM_WALL_DEV` — dev mode flag; when set, disables aggressive static asset caching
- `VACUUM_WALLD_SOCKET` — override daemon socket path
- `VACUUM_WALL_DEV` — dev mode flag; disables aggressive static asset caching
- `VACUUM_WALL_LOG_LEVEL` — log level (default `INFO`)
- `VACUUM_WALLD_SOCKET` — override daemon socket path (default `data/daemon.sock`)
- `VACUUM_WALLD_WS_PORT` — override WebSocket port (default `9091`)
## Deployment
`install.sh` installs only system components and configures them; the project serves from the repo root by default. Options via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override. Use `--dev` to auto-detect repo owner as service user; non-dev requires `--user`.
All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs at `PROJECT_DIR/data/acme/`.
### Service Start Order
`firewalld``avahi-daemon``dnsmasq``vacuum-walld``vacuum-wall`
- `VACUUM_WALL_POLL_INTERVALS` — override poll intervals, e.g. `firewall:60,wireguard:5`
- `VACUUM_WALL_EXTERNAL_IP_URL` — custom URL for external IP detection (acme handler)
- `VACUUM_WALL_SEED_BUILTIN_ADMIN` — set to `0` to skip the last-resort builtin admin seed in `get_db()`. The seed only runs on a completely empty DB (no users); `scripts/bootstrap_auth.py` always sets this since bootstrap creates the operator user itself.
## Local Dev
```bash
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
# Setup
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
bash scripts/update-vendor.sh # fetches acme.sh + htm.js
# Start (Flask only, binds 127.0.0.1:9090)
.venv/bin/python webui/server.py
```
Reload running Flask via SIGHUP (auto-reloads `webui.*` and `lib.*` modules, then SIGTERM restart).
In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking.
## Blueprint / Handler / lib Mapping
## Blueprint ↔ Handler ↔ lib Mapping
| Blueprint | URL Prefix | Handler Module | lib Module |
|-----------------------|---------------------|--------------------------|-----------------|
| Blueprint | URL Prefix | Handler | lib Module |
|-----------------------|---------------------|----------------------------|-----------------|
| `webui/api/firewall` | `/api/firewall/` | `daemon/handlers/firewall` | `lib.firewall` |
| `webui/api/dhcp` | `/api/dhcp/` | `daemon/handlers/dnsmasq` | `lib.dnsmasq` |
| `webui/api/proxy` | `/api/proxy/` | `daemon/handlers/nginx` | `lib.nginx` |
@@ -99,6 +107,7 @@ In production: systemd units run with `NoNewPrivileges`, `ProtectSystem=strict`,
| `webui/api/wireguard` | `/api/wireguard/` | `daemon/handlers/wireguard`| `lib.wireguard` |
| `webui/api/network` | `/api/network/` | `daemon/handlers/network` | `lib.network` |
| `webui/api/logs` | `/api/logs/` | `daemon/handlers/logs` | — |
| `webui/api/status` | `/api/status/` | `daemon/handlers/status` | — |
## Privileged Operations
@@ -111,20 +120,27 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
## API Response Contract
- Success: `{"ok": true, "data": <value>}` helper `_ok(data)` from `webui.api.common` (Flask) or `ok(data)` from `daemon.server` (aiohttp).
- Error: `{"ok": false, "error": "msg"}` helper `_error(msg, code=400)` from `webui.api.common` or `error(msg, code)` from `daemon.server`.
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except.
- HTTP codes: `400` bad request, `404` not found, `500` internal failure.
- Success: `{"ok": true, "data": <value>}``_ok(data)` (Flask) or `ok(data)` (aiohttp)
- Error: `{"ok": false, "error": "msg"}``_error(msg, code)` (Flask) or `error(msg, code)` (aiohttp)
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
- HTTP codes: `400` bad request, `404` not found, `409` conflict, `500` internal failure
## Deploy
`install.sh` is the single deploy script. Run as root. Only `MGMT_PASS` is strictly required; `MGMT_DOMAIN` is auto-detected from hostname.
`scripts/install.sh` is the single deploy script. Run as root. CLI flags take precedence over env vars.
`MGMT_PASS` is strictly required — it is the **SQLite DB** password for the initial `admin`
user (full `rw` on all subsystems; default username `admin`), **not** an nginx htpasswd.
`MGMT_DOMAIN` auto-detected from hostname.
### Service Start Order
`firewalld``avahi-daemon``dnsmasq``vacuum-walld``vacuum-wall`
## Lint and Tests
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`.
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml`.
**Tests:** pytest in `tests/`. Tests mock out subprocess calls — no system services required.
**Tests:** pytest in `tests/` (17 modules). All subprocess calls are mocked — no system services required.
```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint
@@ -132,24 +148,16 @@ Adding a new privileged command requires a sudoers entry **and** the `daemon/han
.venv/bin/python -m pytest tests/ -v # test
```
Install dev tooling with `pip install -e ".[dev]"`.
## Docs
`docs/` contains the authoritative reference for each subsystem. **Before reasoning about any subsystem**, read the relevant doc(s) below.
`docs/` contains the authoritative reference for each subsystem. Read the relevant doc before reasoning about a subsystem.
| Doc | Contents |
|-----|----------|
| `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management |
| `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels |
| `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting |
| `docs/deployment.md` | Install script options, what scripts/install.sh does, post-install setup, troubleshooting |
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
| `docs/hoover.md` | Custom frontend framework API reference |
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
## Important Rules
1. Ask, don't assume. If something is unclear, ask before writing a single line.
2. Simplest solution first. Always implement the simplest thing that could work.
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it.
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so.
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
+4 -3
View File
@@ -28,13 +28,13 @@ MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \
MGMT_USER="admin" \
ACME_EMAIL="admin@example.com" \
bash install.sh
bash scripts/install.sh
```
### Install (Development)
```bash
./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
./scripts/install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com"
```
`--dev` auto-detects the repo's file owner as the service user, skips the safety warning about running as a regular user, and keeps file ownership dev-friendly.
@@ -52,7 +52,7 @@ bash install.sh
| `--wan-iface` | `WAN_IFACE` | No | WAN interface (auto-detected) |
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interfaces, comma-separated (auto-detected) |
CLI flags take precedence over environment variables. Run `./install.sh --help` for full usage.
CLI flags take precedence over environment variables. Run `./scripts/install.sh --help` for full usage.
After installation, access the WebUI at `https://<MGMT_DOMAIN>`. The initial certificate is self-signed — use the Certs tab to issue a real one once DNS is propagating.
@@ -73,6 +73,7 @@ See [docs/deployment.md](docs/deployment.md) for the full guide, including troub
```bash
git clone <repo-url> && cd vacuum-wall
bash scripts/update-vendor.sh
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
```
+9
View File
@@ -94,6 +94,15 @@ def _format_path(path: str, params: dict[str, Any] | None) -> str:
return path
def _replace(m: re.Match[str]) -> str:
"""Regex callback that replaces ``<key>`` segments with URL-encoded values.
Args:
m: Match object containing the parameter name.
Returns:
URL-encoded value from params dict, or original text if key
not found.
"""
key = m.group(1)
if key in params:
return urllib.parse.quote(str(params[key]), safe="")
+139 -29
View File
@@ -26,6 +26,7 @@ from daemon.iface import (
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
GET_ACME_PATHS,
GET_ACME_RENEW_STATUS,
POST_ACME_ACCOUNT_REGISTER,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
@@ -55,7 +56,9 @@ _WEBROOT = PROJECT_DIR / "data" / "acme" / "www"
# In-memory store for active issuance requests.
_ISSUANCES: dict[str, "IssueRequest"] = {}
_ISSUANCE_TTL = 300 # seconds to keep completed requests
_ISSUANCE_TASKS: dict[str, asyncio.Task] = {}
def _find_issuance(domain: str) -> "IssueRequest | None":
@@ -172,6 +175,21 @@ def _clean_expired_issuances() -> None:
del _ISSUANCES[rid]
def _fail_op(req: IssueRequest, exc: Exception) -> None:
"""Record the error on the first running step and mark the request failed."""
for step in req.steps:
if step.status == "running":
step.status = "error"
step.message = str(exc)
break
else:
req.steps.append(
IssueStep(name="error", label="Error", status="error", message=str(exc))
)
req.status = "failed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
# ---------------------------------------------------------------------------
# Validation helpers
@@ -783,7 +801,8 @@ async def issue_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, An
_ISSUANCES[request_id] = req
# Spawn background task
_task = asyncio.create_task(_run_issue(req)) # noqa: RUF006 — task runs to completion on its own
_task = asyncio.create_task(_run_issue(req))
_ISSUANCE_TASKS[request_id] = _task
return {"request_id": request_id, "domain": domain}
@@ -818,13 +837,17 @@ async def _run_issue(req: IssueRequest) -> None:
if account_email:
args.extend(["-m", account_email])
args.append("--force")
output = _run_acme(args)
# acme.sh is a blocking subprocess — run it off the event loop so
# polling, WS broadcasts, and other requests keep responding.
output = await asyncio.to_thread(_run_acme, args)
req.steps[0].status = "done"
req.steps[0].message = output.strip()[:200]
# Step 2: deploy
req.steps[1].status = "running"
_run_acme(["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK])
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
@@ -841,44 +864,131 @@ async def _run_issue(req: IssueRequest) -> None:
)
except Exception as exc:
# Mark current running step as error, overall as failed
for step in req.steps:
if step.status == "running":
step.status = "error"
step.message = str(exc)
break
else:
req.steps.append(
IssueStep(name="error", label="Error", status="error", message=str(exc))
)
req.status = "failed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
_fail_op(req, exc)
logger.error("Cert issuance for %s failed: %s", req.domain, exc)
finally:
_ISSUANCE_TASKS.pop(req.request_id, None)
@registry.register(POST_ACME_RENEW)
def renew_cert(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /acme/renew — renew a certificate for the given domain.
"""POST /acme/renew — start a certificate renewal request (async).
Deduplicates in-progress requests per domain. Spawns a background task
for the actual renewal. When ``force`` is not set, acme.sh skips the
renewal if the certificate's renewal window has not been reached yet
(the request then completes with status "skipped").
Args:
force: Force renewal regardless of expiry.
Returns:
A dictionary containing the renewal request id (and "existing"
status when a renewal for the domain is already running).
Raises:
ValueError: When domain is missing.
"""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
domain = (body.get("domain") or "").strip()
if not domain:
raise ValueError("'domain' is required")
force = body.get("force", False)
args: list[str] = ["--renew", "-d", domain]
if force:
args.append("--force")
output = _run_acme(args)
_run_acme(["--deploy", "-d", domain, "--deploy-hook", _DEPLOY_HOOK])
logger.info("Certificate for %s renewed", domain)
refresh_state(["acme"])
return {"domain": domain, "output": output.strip()}
force = bool(body.get("force", False))
_clean_expired_issuances()
# Dedup: if domain already has an active request, return it
existing = _find_issuance(domain)
if existing:
return {
"request_id": existing.request_id,
"domain": domain,
"status": "existing",
}
request_id = uuid4().hex[:12]
steps = [
IssueStep(name="renew", label="Renewing certificate"),
IssueStep(name="deploy", label="Registering deploy hook"),
IssueStep(name="refresh", label="Refreshing certificate state"),
]
req = IssueRequest(request_id=request_id, domain=domain, steps=steps)
_ISSUANCES[request_id] = req
# Spawn background task
_task = asyncio.create_task(_run_renew(req, force))
_ISSUANCE_TASKS[request_id] = _task
return {"request_id": request_id, "domain": domain}
@registry.register(GET_ACME_RENEW_STATUS)
def get_renew_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /acme/renew/status — poll status of a renewal request.
Raises:
ValueError: When id is missing.
NotFoundError: When request_id is unknown.
"""
request_id = (body or {}).get("id", "").strip()
if not request_id:
raise ValueError("'id' is required")
req = _ISSUANCES.get(request_id)
if not req:
raise NotFoundError(f"Renewal request {request_id} not found")
return req.to_dict()
async def _run_renew(req: IssueRequest, force: bool) -> None:
"""Background task: renew the certificate, register the deploy hook, refresh state."""
try:
# Step 1: renew (acme.sh skips when the cert's renewal window has not
# been reached unless force is set)
req.steps[0].status = "running"
args: list[str] = ["--renew", "-d", req.domain]
if force:
args.append("--force")
output = await asyncio.to_thread(_run_acme, args)
if "Skipping." in output:
req.steps[0].status = "done"
req.steps[0].message = "Renewal not yet due — skipped"
req.status = "skipped"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.info(
"Renewal for %s skipped (request %s)", req.domain, req.request_id
)
return
req.steps[0].status = "done"
req.steps[0].message = output.strip()[:200]
# Step 2: deploy
req.steps[1].status = "running"
await asyncio.to_thread(
_run_acme, ["--deploy", "-d", req.domain, "--deploy-hook", _DEPLOY_HOOK]
)
req.steps[1].status = "done"
req.steps[1].message = "Deploy hook registered"
# Step 3: refresh state
req.steps[2].status = "running"
refresh_state(["acme"])
req.steps[2].status = "done"
req.steps[2].message = "State refreshed"
req.status = "completed"
req.expires_at = datetime.now(UTC).timestamp() + _ISSUANCE_TTL
logger.info(
"Certificate for %s renewed (request %s)", req.domain, req.request_id
)
except Exception as exc:
_fail_op(req, exc)
logger.error("Renewal for %s failed: %s", req.domain, exc)
finally:
_ISSUANCE_TASKS.pop(req.request_id, None)
@registry.register(DELETE_ACME_REMOVE)
@@ -976,10 +1086,10 @@ def generate_self_signed(_request: Any, body: dict[str, Any] | None) -> dict[str
raise ValueError("'domain' is required")
days = body.get("days", 365)
cert_dir = _ACME_HOME / domain
cert_dir.mkdir(parents=True, exist_ok=True)
cert_file = cert_dir / "fullchain.cer"
key_file = cert_dir / f"{domain}.key"
certs_dir = PROJECT_DIR / "data" / "certs"
certs_dir.mkdir(parents=True, exist_ok=True)
cert_file = certs_dir / f"{domain}.crt"
key_file = certs_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file():
logger.info("Self-signed cert for %s already exists, skipping", domain)
+593
View File
@@ -0,0 +1,593 @@
"""Authentication daemon handlers.
Handles login, logout, token refresh, session management, password change,
user CRUD, and WebAuthn operations.
"""
from __future__ import annotations
import logging
from typing import Any
from daemon.iface import (
DELETE_AUTH_USER,
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
GET_AUTH_SESSION,
GET_AUTH_USERS,
GET_AUTH_WEBAUTHN_CAPABLE,
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
GET_AUTH_WEBAUTHN_CREDENTIALS,
POST_AUTH_LOGIN,
POST_AUTH_LOGOUT,
POST_AUTH_PASSWORD,
POST_AUTH_REFRESH,
POST_AUTH_USER_CREATE,
POST_AUTH_USER_UPDATE,
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
)
from daemon.server import ConflictError, NotFoundError, registry
from lib.auth import (
blacklist_active_refresh_token,
blacklist_token,
check_login_rate,
check_webauthn_rate,
generate_tokens,
get_access_ttl,
record_login_failure,
record_login_success,
record_webauthn_failure,
record_webauthn_success,
validate_token,
)
from lib.auth_users import (
create_user,
delete_user,
get_user,
list_users,
update_password,
update_permissions,
verify_user_password,
)
from lib.webauthn import (
create_authentication_options,
create_registration_options,
get_all_credential_counts,
get_management_domains,
get_rp_name,
list_credentials,
remove_credential,
verify_authentication,
verify_registration,
)
from lib.webauthn import (
is_enabled as webauthn_is_enabled,
)
logger = logging.getLogger(__name__)
@registry.register(POST_AUTH_LOGIN)
def auth_login(_request: Any, body: Any) -> dict[str, Any]:
"""Handle user login.
Args:
_request: Unused.
body: Dict with ``username`` and ``password``.
Returns:
Dict with ``tokens``, ``user``, and ``permissions``.
Raises:
ValueError: If credentials are invalid.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
username = body.get("username")
password = body.get("password")
if not username or not password:
raise ValueError("username and password are required")
client_ip = body.get("client_ip")
if not check_login_rate(username, client_ip):
raise ValueError("Too many login attempts. Please try again later.")
user = verify_user_password(username, password)
if user is None:
record_login_failure(username, client_ip)
raise ValueError("Invalid credentials")
record_login_success(username, client_ip)
permissions = user["permissions"]
tokens = generate_tokens(username, permissions)
return {
"tokens": tokens,
"access_ttl": get_access_ttl(),
"user": {
"id": user["id"],
"username": user["username"],
},
"permissions": permissions,
}
@registry.register(POST_AUTH_LOGOUT)
def auth_logout(request: Any, body: Any) -> dict[str, Any]:
"""Handle user logout by blacklisting the access and refresh tokens.
Args:
request: The aiohttp request.
body: Dict with ``jti``, ``username`` from Flask user context, and
``refresh_token`` from the client.
Returns:
Success response.
"""
if not body:
raise ValueError("Request body is required")
jti = body.get("jti")
if jti:
blacklist_token(jti)
username = body.get("username")
if username:
blacklist_active_refresh_token(username)
return {}
@registry.register(POST_AUTH_REFRESH)
def auth_refresh(_request: Any, body: Any) -> dict[str, Any]:
"""Handle token refresh.
Validates the refresh token, blacklists it, and issues a new access token.
Requires refresh_token and session_id in the request body for session binding.
Args:
_request: Unused.
body: Dict with ``refresh_token`` and ``session_id``.
Returns:
Dict with new access token, refresh token, user, and permissions.
Raises:
ValueError: If refresh token is invalid.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
refresh_token = body.get("refresh_token")
if not refresh_token:
raise ValueError("refresh_token is required")
session_id = body.get("session_id")
if not session_id:
raise ValueError("session_id is required")
payload = validate_token(
refresh_token, token_type="refresh", session_id=session_id, require_session=True
)
if payload is None:
raise ValueError("Invalid or expired refresh token")
username = payload["sub"]
user = get_user(username)
if user is None:
raise ValueError("User not found")
# Persist new tokens first, then invalidate the old ones.
# This prevents data loss if generate_tokens fails mid-way:
# the old refresh token remains valid and the user is not locked out.
permissions = user["permissions"]
tokens = generate_tokens(username, permissions)
jti = payload.get("jti")
if jti:
blacklist_token(jti, token_type="refresh")
return {
"tokens": tokens,
"access_ttl": get_access_ttl(),
"user": {
"id": user["id"],
"username": user["username"],
},
"permissions": permissions,
}
@registry.register(GET_AUTH_SESSION)
def auth_session(request: Any, body: Any) -> dict[str, Any]:
"""Return current user session info.
Args:
request: The aiohttp request.
body: Dict with ``username`` from Flask user context.
Returns:
Dict with user info and permissions.
"""
username = body.get("username") if body else None
if username is None:
raise ValueError("No active session")
user = get_user(username)
if user is None:
raise ValueError("User not found")
return {
"user": {
"id": user["id"],
"username": user["username"],
},
"permissions": user["permissions"],
}
@registry.register(POST_AUTH_PASSWORD)
def auth_change_password(_request: Any, body: Any) -> dict[str, Any]:
"""Change user password.
Args:
_request: Unused.
body: Dict with ``username``, ``oldPassword``, ``newPassword``.
Returns:
Success response.
Raises:
ValueError: If password change fails.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
username = body.get("username")
old_password = body.get("oldPassword")
new_password = body.get("newPassword")
if not username or not old_password or not new_password:
raise ValueError("username, oldPassword, and newPassword are required")
if len(new_password) < 8:
raise ValueError("New password must be at least 8 characters")
update_password(username, old_password, new_password)
logger.info("Password changed for user %r", username)
return {"ok": True}
@registry.register(GET_AUTH_USERS)
def auth_list_users(_request: Any, body: Any) -> list[dict[str, Any]]:
"""List all users.
Args:
_request: Unused.
body: Unused.
Returns:
List of user summary dicts.
"""
return list_users()
@registry.register(POST_AUTH_USER_CREATE)
def auth_create_user(_request: Any, body: Any) -> dict[str, Any]:
"""Create a new user.
Args:
_request: Unused.
body: Dict with ``username``, ``password``, ``permissions``.
Returns:
Created user dict.
Raises:
ConflictError: If user already exists.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
username = body.get("username")
password = body.get("password")
permissions = body.get("permissions", {})
if not username or not password:
raise ValueError("username and password are required")
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
try:
user = create_user(username, password, permissions)
return {
"id": user["id"],
"username": user["username"],
"permissions": user["permissions"],
}
except ValueError as e:
raise ConflictError(str(e)) from e
@registry.register(POST_AUTH_USER_UPDATE)
def auth_update_user(_request: Any, body: Any) -> dict[str, Any]:
"""Update a user's permissions.
Args:
_request: Unused.
body: Dict with optional ``permissions``, ``password`` keys.
Path param ``username`` is merged into body by the daemon.
Returns:
Updated user dict.
Raises:
NotFoundError: If user not found.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
username = body.get("username")
if not username:
raise ValueError("username is required")
user = get_user(username)
if user is None:
raise NotFoundError(f"User {username!r} not found")
if "permissions" in body:
update_permissions(username, body["permissions"])
user = get_user(username)
if user is None:
raise NotFoundError(f"User {username!r} not found")
return {
"id": user["id"],
"username": user["username"],
"permissions": user["permissions"],
}
@registry.register(DELETE_AUTH_USER)
def auth_delete_user(_request: Any, body: Any) -> dict[str, Any]:
"""Delete a user.
Self-deletion is blocked by Flask middleware (auth blueprint).
Args:
_request: Unused.
body: Path param ``username`` merged by the daemon.
Returns:
Success response.
Raises:
NotFoundError: If user not found.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
username = body.get("username")
if not username:
raise ValueError("username is required")
try:
delete_user(username)
logger.info("User %r deleted", username)
return {"ok": True}
except ValueError as e:
if "not found" in str(e):
raise NotFoundError(str(e)) from e
raise
# ---------------------------------------------------------------------------
# WebAuthn handlers
# ---------------------------------------------------------------------------
def _check_webauthn_domain(body: Any) -> tuple[str, str]:
"""Validate that the request domain is eligible for WebAuthn.
Returns (origin, rp_id) if valid. Raises ValueError otherwise.
"""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
if not webauthn_is_enabled():
raise ValueError("WebAuthn is disabled")
rp_id = body.get("webauthn_rp_id")
origin = body.get("webauthn_origin")
if not rp_id or not origin:
raise ValueError("Missing WebAuthn domain configuration")
if rp_id not in get_management_domains():
raise ValueError("WebAuthn is not available on this domain")
return origin, rp_id
@registry.register(GET_AUTH_WEBAUTHN_CAPABLE)
def webauthn_capable(_request: Any, body: Any) -> dict[str, Any]:
"""Check if WebAuthn is available for the current request domain."""
rp_id = body.get("webauthn_rp_id") if body else None
origin = body.get("webauthn_origin") if body else None
if not webauthn_is_enabled():
return {"enabled": False, "reason": "WebAuthn is disabled in config"}
if not rp_id or not origin:
return {"enabled": False, "reason": "Domain information unavailable"}
mgmt_domains = get_management_domains()
if rp_id not in mgmt_domains:
return {"enabled": False, "reason": "Not a management domain"}
return {
"enabled": True,
"rp_id": rp_id,
"rp_name": get_rp_name(),
"origin": origin,
}
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_BEGIN)
def webauthn_register_begin(_request: Any, body: Any) -> dict[str, Any]:
"""Begin WebAuthn registration — return options for ``credentials.create()``."""
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
if not username:
raise ValueError("username is required")
options = create_registration_options(
username,
origin=origin,
rp_id=rp_id,
)
return options
@registry.register(POST_AUTH_WEBAUTHN_REGISTER_FINISH)
def webauthn_register_finish(_request: Any, body: Any) -> dict[str, Any]:
"""Finish WebAuthn registration — verify credential and persist."""
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
credential_response = body.get("credential_response")
registration_options = body.get("registration_options")
credential_name = body.get("name", "")
if not username or not credential_response or not registration_options:
raise ValueError(
"username, credential_response, and registration_options are required"
)
cred = verify_registration(
username,
credential_response,
registration_options,
credential_name,
origin=origin,
rp_id=rp_id,
)
return {"ok": True, "credential": cred}
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN)
def webauthn_authenticate_begin(_request: Any, body: Any) -> dict[str, Any]:
"""Begin WebAuthn authentication — return options for ``credentials.get()``.
Public endpoint — no JWT required. Returns ``{"noWebAuthn": true}`` if the
user has no registered credentials (so the frontend can fall back to password).
"""
_, rp_id = _check_webauthn_domain(body)
username = body.get("username")
if not username:
raise ValueError("username is required")
options = create_authentication_options(
username,
rp_id=rp_id,
)
if options is None:
return {"no_webauthn": True}
return options
@registry.register(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH)
def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
"""Finish WebAuthn authentication — verify assertion, issue tokens.
Public endpoint — no JWT required.
"""
origin, rp_id = _check_webauthn_domain(body)
username = body.get("username")
assertion_response = body.get("assertion_response")
auth_options = body.get("auth_options")
client_ip = body.get("client_ip")
if not username or not assertion_response or not auth_options:
raise ValueError("username, assertion_response, and auth_options are required")
if not check_webauthn_rate(username, client_ip):
raise ValueError("Too many WebAuthn attempts. Please try again later.")
try:
verify_authentication(
username,
assertion_response,
auth_options,
origin=origin,
rp_id=rp_id,
)
except ValueError:
record_webauthn_failure(username, client_ip)
raise
record_webauthn_success(username, client_ip)
user = get_user(username)
if user is None:
raise ValueError("User not found")
permissions = user["permissions"]
tokens = generate_tokens(username, permissions)
return {
"tokens": tokens,
"access_ttl": get_access_ttl(),
"user": {
"id": user["id"],
"username": user["username"],
},
"permissions": permissions,
}
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIALS)
def webauthn_credentials(request: Any, body: Any) -> list[dict[str, Any]]:
"""List WebAuthn credentials for the authenticated user."""
username = body.get("username") if body else None
if not username:
raise ValueError("No active session")
return list_credentials(username)
@registry.register(DELETE_AUTH_WEBAUTHN_CREDENTIAL)
def webauthn_remove_credential(request: Any, body: Any) -> dict[str, Any]:
"""Remove a WebAuthn credential."""
if not body or not isinstance(body, dict):
raise ValueError("Request body is required")
username = body.get("username")
if not username:
raise ValueError("No active session")
credential_id = body.get("credential_id")
if not credential_id:
raise ValueError("credential_id is required")
try:
remove_credential(username, credential_id)
logger.info("WebAuthn credential removed for %s", username)
return {"ok": True}
except ValueError as e:
if "not found" in str(e):
raise NotFoundError(str(e)) from e
raise
@registry.register(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS)
def webauthn_credential_counts(_request: Any, body: Any) -> dict[str, int]:
"""Return credential counts for all users (admin endpoint)."""
return get_all_credential_counts()
+118 -29
View File
@@ -25,7 +25,17 @@ from daemon.iface import (
POST_DNSMASQ_UPSTREAMS,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, ensure_dirs, load_json, run, run_proc, save_json
from lib.common import (
deep_merge,
ensure_dirs,
get_interface_ip,
load_json,
run,
save_json,
stamp_applied,
strip_apply_meta,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -35,7 +45,7 @@ DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
FRAGMENTS_DIR = DATA_DIR / "fragments"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
@@ -128,7 +138,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
dm = _get_dnsmasq_state()
if dm:
return dm.get("config", {})
return _get_config()
cfg = _get_config()
return strip_apply_meta(cfg)
@registry.register(POST_DNSMASQ_CONFIG)
@@ -141,7 +152,10 @@ def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str,
if not body:
raise ValueError("Request body required")
_save_config(body)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_saved"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"config_saved": True}
@@ -157,7 +171,10 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_patched"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"config_saved": True}
@@ -172,16 +189,23 @@ def apply_config(_request: Any, _body: Any) -> dict[str, Any]:
conf_text = _generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
run(["mkdir", "-p", "/etc/dnsmasq.d"], sudo=True)
run_proc(
["tee", DNSMASQ_CONF, "--"],
sudo=True,
check=True,
input=conf_text,
tmp = Path("/run/vacuum-wall/dnsmasq.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(conf_text)
run(["cp", "--", str(tmp), str(DNSMASQ_CONF)], sudo=True)
tmp.unlink(missing_ok=True)
run(["systemctl", "restart", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and restarted")
# Store the applied config snapshot + hash so the state collector can
# detect pending changes and report what specifically changed.
cfg_after = _get_config()
stamp_applied(cfg_after)
_save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "config_applied"})
)
run(["systemctl", "reload", "dnsmasq"], sudo=True)
logger.info("dnsmasq config written and reloaded")
refresh_state(["dnsmasq"])
return {"applied": True}
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"applied": True, "synced": sync_result.affected_subsystems}
@registry.register(GET_DNSMASQ_STATUS)
@@ -203,15 +227,30 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
Endpoint: POST /dnsmasq/ranges/add
Add or update a DHCP pool range by interface. Raises ValueError on invalid input.
Auto-populates ``gateway`` from the interface's IPv4 address when not provided.
"""
if not body:
raise ValueError("Request body required")
iface = body.get("interface", "").strip() or ""
iface = (body.get("interface") or "").strip()
start = body.get("start", "").strip()
end = body.get("end", "").strip()
lease_time = body.get("lease_time", "12h")
if not start or not end:
raise ValueError("'start' and 'end' are required")
# Resolve gateway: explicit value > existing range value > interface IP
gateway = body.get("gateway")
if not gateway:
# Check existing range for same interface
cfg_tmp = _get_config()
for r in cfg_tmp["dhcp"]["ranges"]:
if r.get("interface") == iface:
gateway = r.get("gateway")
break
# Fall back to interface's own IP
if not gateway and iface:
gateway = get_interface_ip(iface)
cfg = _get_config()
ranges = cfg["dhcp"]["ranges"]
found = False
@@ -223,8 +262,8 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
"end": end,
"lease_time": lease_time,
}
if body.get("gateway"):
ranges[i]["gateway"] = body["gateway"]
if gateway:
ranges[i]["gateway"] = gateway
if body.get("dns"):
ranges[i]["dns"] = body["dns"]
found = True
@@ -236,13 +275,18 @@ def set_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
"end": end,
"lease_time": lease_time,
}
if body.get("gateway"):
entry["gateway"] = body["gateway"]
if gateway:
entry["gateway"] = gateway
if body.get("dns"):
entry["dns"] = body["dns"]
ranges.append(entry)
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_added", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"interface": iface, "start": start, "end": end}
@@ -277,7 +321,12 @@ def remove_dhcp_range(_request: Any, body: dict[str, Any] | None) -> dict[str, A
f"DHCP range for interface '{iface}' ({start}-{end}) not found"
)
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "range_removed", "interface": iface}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"interface": iface, "start": start, "end": end}
@@ -316,14 +365,26 @@ def add_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str, An
if hostname is not None:
leases[i]["hostname"] = hostname
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "static_lease_added", "mac": mac},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac, "ip": ip, "hostname": hostname}
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_added", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac, "ip": ip, "hostname": hostname}
@@ -348,7 +409,12 @@ def remove_static_lease(_request: Any, body: dict[str, Any] | None) -> dict[str,
if len(cfg["dhcp"]["static_leases"]) == before:
raise NotFoundError(f"Static lease for MAC '{mac}' not found")
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "static_lease_removed", "mac": mac}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"mac": mac}
@@ -374,14 +440,26 @@ def add_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
if hostname is not None:
records[i]["hostname"] = hostname
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq",
"config_saved",
{"action": "dns_record_added", "name": name},
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name, "address": address, "hostname": hostname}
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_added", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name, "address": address, "hostname": hostname}
@@ -404,7 +482,12 @@ def remove_dns_record(_request: Any, body: dict[str, Any] | None) -> dict[str, A
if len(cfg["dns"]["custom_records"]) == before:
raise NotFoundError(f"DNS record '{name}' not found")
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent(
"dnsmasq", "config_saved", {"action": "dns_record_removed", "name": name}
)
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"name": name}
@@ -420,7 +503,10 @@ def set_upstreams(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["upstreams"] = list(body["servers"])
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "upstreams_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"upstreams": cfg["dns"]["upstreams"]}
@@ -437,5 +523,8 @@ def set_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
cfg = _get_config()
cfg["dns"]["domain"] = domain if domain else None
_save_config(cfg)
refresh_state(["dnsmasq"])
sync_result = bus.emit(
SyncEvent("dnsmasq", "config_saved", {"action": "domain_set"})
)
refresh_state(["dnsmasq", *sync_result.affected_subsystems])
return {"domain": cfg["dns"]["domain"]}
+481 -30
View File
@@ -33,16 +33,18 @@ from daemon.iface import (
POST_FIREWALL_ZONES_INTERFACES,
POST_FIREWALL_ZONES_SERVICES,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json, stamp_applied, strip_apply_meta
from lib.firewall import (
_normalize_target,
_parse_active_zones,
_parse_zone_output,
fw_change_summary,
)
from lib.firewall import (
save_backup as _save_backup,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
@@ -83,6 +85,35 @@ def _reload() -> None:
run(["firewall-cmd", "--reload"], sudo=True)
def _default_zone() -> str:
"""Return the firewalld default zone name.
The default zone is the catch-all for any interface without an explicit
zone assignment (including VPN interfaces), so it normally fronts the WAN.
"""
return run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
def _would_remove_mgmt(zone: str, services: list[str]) -> bool:
"""Return True if *services* lacks both https and ssh on the default zone.
The default zone fronts unassigned (WAN/VPN) interfaces, so removing both
management access (https via nginx) and remote recovery (ssh) from it
would leave no path back except a physical console.
"""
if "https" in services or "ssh" in services:
return False
try:
default = _default_zone()
except Exception:
logger.warning(
"Could not determine the firewalld default zone; failing closed for %s",
zone,
)
return True
return zone == default
def _fp_to_str(fp: dict[str, Any]) -> str:
"""Convert a forward-port dict to firewall-cmd CLI argument string."""
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
@@ -104,13 +135,39 @@ def _get_forward_ports(zone_name: str) -> list[str]:
return []
def _config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld."""
def _config_apply(force: bool = False) -> dict[str, Any]:
"""Apply saved declarative config to live firewalld.
For each zone in the config, reconciles interfaces, services, target,
masquerade, rich rules, and forward ports by removing old values first,
then adding desired values. Reloads firewalld at the end.
With *force* False (default), a ``ConflictError`` is raised before any
mutation if the config would strip both https and ssh from the default
zone; pass ``force=True`` to override.
"""
from lib.firewall import get_config as _get_lib_config
cfg = _get_lib_config()
cfg_zones = cfg.get("zones", {})
if not force:
default_zone = _default_zone()
lockout_zones = [
zn
for zn, zc in cfg_zones.items()
if zn == default_zone
and "https" not in zc.get("services", [])
and "ssh" not in zc.get("services", [])
]
if lockout_zones:
raise ConflictError(
f"Refusing to remove both https and ssh from default zone(s) "
f"{', '.join(repr(z) for z in lockout_zones)}: management access "
f"and remote recovery would be lost. Add at least one of them "
f'to the zone\'s services, or pass {{"force": true}}.'
)
full_state: dict[str, Any] = {
"active_zones": {},
"interfaces": [],
@@ -124,19 +181,27 @@ def _config_apply() -> dict[str, Any]:
available = run(["firewall-cmd", "--get-zones"], sudo=True).split()
applied: list[str] = []
for zone_name, zone_cfg in cfg_zones.items():
# Two-step reconciliation: remove current values, then add desired values.
# This ensures idempotency — running apply twice produces the same result.
need_create = zone_name not in available
if need_create:
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
# Create new zone first (--new-zone is required before --set-target)
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={target}",
"--permanent",
],
["firewall-cmd", f"--new-zone={zone_name}", "--permanent"],
sudo=True,
)
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if target != "default":
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={target}",
"--permanent",
],
sudo=True,
)
_reload()
else:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
@@ -153,6 +218,9 @@ def _config_apply() -> dict[str, Any]:
check=False,
)
# Step 2: Reconcile services — remove all current, add desired list.
# Firewall-cmd doesn't have a "set-services" bulk operation, so we
# remove each existing service and then add each desired service.
current_svcs: list[str] = []
with suppress(Exception):
current_svcs = _parse_zone_output(
@@ -181,6 +249,7 @@ def _config_apply() -> dict[str, Any]:
sudo=True,
)
# Step 3: Reconcile interfaces — same remove-then-add pattern.
current_ifaces: list[str] = []
with suppress(Exception):
current_ifaces = _parse_zone_output(
@@ -209,12 +278,34 @@ def _config_apply() -> dict[str, Any]:
sudo=True,
)
mq = zone_cfg.get("masquerade", False)
if mq is not None:
action = "--add-masquerade" if mq else "--remove-masquerade"
# Step 4: Toggle masquerade if explicitly set (None means "don't change").
# Skip 'public' — Step 7 handles masquerade propagation for nftables.
if zone_name != "public":
mq = zone_cfg.get("masquerade", False)
if mq is not None:
action = "--add-masquerade" if mq else "--remove-masquerade"
run(
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
sudo=True,
)
# Step 5: Reconcile rich rules — remove all current, add desired.
# Note: firewall-cmd doesn't track rule IDs for rich rules, so we
# must remove by full rule string match.
current_rules = _parse_zone_output(
zone_name,
run(["firewall-cmd", f"--zone={zone_name}", "--list-all"], sudo=True),
).get("rich-rules", [])
for rule_str in current_rules:
run(
["firewall-cmd", f"--zone={zone_name}", action, "--permanent"],
[
"firewall-cmd",
f"--zone={zone_name}",
f"--remove-rich-rule={rule_str}",
"--permanent",
],
sudo=True,
check=False,
)
for rule_entry in zone_cfg.get("rich_rules", []):
@@ -235,6 +326,7 @@ def _config_apply() -> dict[str, Any]:
check=False,
)
# Step 6: Reconcile forward ports — same pattern.
current_fps = _get_forward_ports(zone_name)
for fp_str in current_fps:
run(
@@ -262,6 +354,33 @@ def _config_apply() -> dict[str, Any]:
applied.append(zone_name)
# Step 7: Ensure masquerade propagation for nftables backend.
# With firewalld's nftables backend, POSTROUTING policy chains route traffic
# to the OUTPUT interface's zone chain. Traffic from internal zones (eth1)
# exiting through public (eth0) hits public's POSTROUTING chain, not
# internal's. If any non-public zone has masquerade enabled but the public
# zone doesn't, NAT silently fails — so propagate masquerade to public.
_any_non_public_mq = any(
z.get("masquerade", False) for zn, z in cfg_zones.items() if zn != "public"
)
_public_mq = cfg_zones.get("public", {}).get("masquerade", False)
if _any_non_public_mq and not _public_mq:
logger.info("Propagating masquerade to public zone for nftables compatibility")
run(
["firewall-cmd", "--zone=public", "--add-masquerade", "--permanent"],
sudo=True,
)
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = True
_save_config(cfg)
elif not _any_non_public_mq and _public_mq:
logger.info("No non-public zone needs masquerade, removing from public zone")
run(
["firewall-cmd", "--zone=public", "--remove-masquerade", "--permanent"],
sudo=True,
)
cfg.setdefault("zones", {}).setdefault("public", {})["masquerade"] = False
_save_config(cfg)
_reload()
full_state = {
"active_zones": {},
@@ -272,6 +391,11 @@ def _config_apply() -> dict[str, Any]:
"timestamp": "",
}
backup_path = _save_backup(full_state)
# Record the applied config snapshot + hash so pending-changes detection
# and cancel/revert work like the hash-based subsystems.
applied_cfg = _get_config()
stamp_applied(applied_cfg)
_save_config(applied_cfg)
logger.info("Firewall config applied to %d zones", len(applied))
return {
"applied_zones": applied,
@@ -301,6 +425,16 @@ def get_interfaces(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register(GET_FIREWALL_ZONES)
def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
"""Return active + available zones from state store.
Args:
_request: The incoming HTTP request (unused).
_body: The request body (unused).
Returns:
Dict with ``active`` (zone→interfaces mapping) and
``available`` (list of all zone names).
"""
fw = _get_fw_state()
active = fw.get("active_zones", {})
zones = fw.get("zones", {})
@@ -309,6 +443,19 @@ def get_zones(_request: Any, _body: Any) -> dict[str, Any]:
@registry.register(GET_FIREWALL_ZONES_INFO)
def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Return zone config by name; ``NotFoundError`` if absent.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` key (zone name).
Returns:
Zone config dict.
Raises:
ValueError: If ``zone`` key is missing from body.
NotFoundError: If the specified zone does not exist.
"""
if not body or "zone" not in body:
raise ValueError("'zone' is required")
zone = body["zone"]
@@ -321,6 +468,15 @@ def get_zone_info(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
@registry.register(GET_FIREWALL_ZONES_ALL)
def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
"""Return list of all active zone configs.
Args:
_request: The incoming HTTP request (unused).
_body: The request body (unused).
Returns:
List of zone config dicts for all active zones.
"""
fw = _get_fw_state()
active = fw.get("active_zones", {})
zones = fw.get("zones", {})
@@ -333,29 +489,80 @@ def get_all_zones_info(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register(GET_FIREWALL_SERVICES)
def get_services(_request: Any, _body: Any) -> list[str]:
"""Return list of available firewall services.
Args:
_request: The incoming HTTP request (unused).
_body: The request body (unused).
Returns:
List of service names available in firewalld.
"""
fw = _get_fw_state()
return fw.get("available_services", [])
@registry.register(GET_FIREWALL_CONFIG)
def get_config(_request: Any, _body: Any) -> dict[str, Any]:
return _get_config()
"""Return declarative config from JSON store.
Args:
_request: The incoming HTTP request (unused).
_body: The request body (unused).
Returns:
Full firewall config dict (apply bookkeeping keys stripped).
"""
return strip_apply_meta(_get_config())
@registry.register(POST_FIREWALL_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Save the full declarative firewall configuration.
Validates that the request body contains a ``zones`` dict, persists it
to the JSON store, emits a ``config_saved`` sync event to the
cross-subsystem sync bus, and refreshes the state store.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with a ``zones`` dict mapping zone names to
zone configurations.
Returns:
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty, missing ``zones`` key,
or ``zones`` is not a dict.
"""
if not body or "zones" not in body:
raise ValueError("'zones' key is required")
if not isinstance(body["zones"], dict):
raise ValueError("'zones' must be a dict")
_save_config(body)
logger.info("Firewall config saved (%d zones)", len(body["zones"]))
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_saved"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"config_saved": True}
@registry.register(PATCH_FIREWALL_CONFIG)
def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Deep-merge body into current config, save, emit sync event, refresh state.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with partial config to merge.
Returns:
Dict with ``config_saved`` flag set to ``True``.
Raises:
ValueError: If body is empty.
"""
if not body:
raise ValueError("Request body must be a JSON object")
from lib.common import deep_merge
@@ -364,26 +571,78 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
merged = deep_merge(current, body)
_save_config(merged)
logger.info("Firewall config patched: %s", sorted(body.keys()))
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_patched"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"config_saved": True}
@registry.register(GET_FIREWALL_CONFIG_PENDING)
def config_pending_handler(_request: Any, _body: Any) -> dict[str, Any]:
"""Return pending changes with human-readable summaries.
Compares saved config against live firewalld state and returns a list
of differences.
Args:
_request: The incoming HTTP request (unused).
_body: The request body (unused).
Returns:
Dict with ``pending`` (list of change dicts) and ``pending_summary``
(human-readable strings).
"""
fw = _get_fw_state()
return fw.get("pending", {})
pending = fw.get("pending", {})
changes = pending.get("pending", [])
summaries = [
fw_change_summary(c.get("zone", "unknown"), c.get("type", "unknown"), c)
for c in changes
]
return {**pending, "pending_summary": summaries}
@registry.register(POST_FIREWALL_CONFIG_APPLY)
def config_apply(_request: Any, _body: Any) -> dict[str, Any]:
result = _config_apply()
"""Apply pending config to live firewalld, emit sync event, refresh state.
Args:
_request: The incoming HTTP request (unused).
_body: Optional JSON body; ``{"force": true}`` overrides the
management-lockout guard for the default zone.
Returns:
Dict with ``applied_zones`` (list of zone names), ``backup`` (path),
and ``synced`` (affected subsystems).
"""
force = bool(_body and _body.get("force"))
result = _config_apply(force=force)
logger.info("Firewall config applied: %s", result.get("applied_zones", []))
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "config_applied"})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
result["synced"] = sync_result.affected_subsystems
return result
@registry.register(POST_FIREWALL_ZONES_CREATE)
def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Create new zone via firewall-cmd, emit sync event, refresh state.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``name`` (zone name) and optional ``target``.
Returns:
Dict with ``zone`` key set to the zone name.
Raises:
ValueError: If body is empty, missing name, or zone already exists.
"""
if not body:
raise ValueError("Request body required")
zone_name = body.get("name", "").strip()
@@ -404,12 +663,30 @@ def create_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
_reload()
logger.info("Zone '%s' created (target=%s)", zone_name, target)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "zone_created", "zone": zone_name}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone_name}
@registry.register(DELETE_FIREWALL_ZONES_DELETE)
def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Delete zone via firewall-cmd, emit sync event, refresh state.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` (zone name).
Returns:
Dict with ``zone`` key set to the zone name.
Raises:
ValueError: If body is empty or missing ``zone``.
NotFoundError: If the zone does not exist.
"""
if not body or "zone" not in body:
raise ValueError("'zone' is required")
zone = body["zone"]
@@ -419,12 +696,28 @@ def delete_zone(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Zone '%s' deleted", zone)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "zone_deleted", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone}
@registry.register(POST_FIREWALL_ZONES_INTERFACES)
def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Replace zone interfaces, reassigning interfaces from old zones.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``interfaces`` list.
Returns:
Dict with ``zone`` and ``interfaces`` keys.
Raises:
ValueError: If body is missing ``zone``.
NotFoundError: If the zone does not exist.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
@@ -489,12 +782,33 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
_save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "interfaces_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "interfaces": interfaces}
@registry.register(POST_FIREWALL_ZONES_SERVICES)
def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Replace zone services, persist to declarative config, and refresh state.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``services`` list; optional
``force`` (bool) overrides the management-lockout guard.
Returns:
Dict with ``zone`` and ``services`` keys.
Raises:
ValueError: If body is missing ``zone``.
NotFoundError: If the specified zone does not exist.
ConflictError: If the change would strip both https and ssh from the
firewalld default zone and ``force`` is not set.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
@@ -503,6 +817,12 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
raise ValueError("'zone' is required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
raise NotFoundError(f"Zone '{zone}' does not exist")
if not body.get("force") and _would_remove_mgmt(zone, list(services)):
raise ConflictError(
f"Refusing to remove both https and ssh from default zone '{zone}': "
f"management access and remote recovery would be lost. Add at least "
f'one of them back, or send "force": true to override.'
)
current = _parse_zone_output(
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
).get("services", [])
@@ -528,12 +848,36 @@ def set_zone_services(_request: Any, body: dict[str, Any] | None) -> dict[str, A
sudo=True,
)
_reload()
refresh_state(["firewall"])
# Keep the declarative config in sync so the next apply does not
# reconcile the live services back to the stale config value.
cfg = _get_config()
cfg.setdefault("zones", {}).setdefault(zone, {})["services"] = list(services)
_save_config(cfg)
logger.info("Zone '%s' services set to %s", zone, services)
sync_result = bus.emit(
SyncEvent("firewall", "config_saved", {"action": "services_set", "zone": zone})
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "services": services}
@registry.register(POST_FIREWALL_RICH_RULES_ADD)
def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Add rich rule with auto-generated ID from uuid4.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` (zone name) and ``rule``
(rich rule string).
Returns:
Dict with ``zone``, ``id`` (generated UUID), and ``rule`` keys.
Raises:
ValueError: If body is missing ``zone`` or ``rule``.
NotFoundError: If the specified zone does not exist.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
@@ -559,12 +903,30 @@ def add_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
entry = {"id": rule_id, "rule": rule}
cfg["zones"][zone]["rich_rules"].append(entry)
_save_config(cfg)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "id": rule_id, "rule": rule}
@registry.register(DELETE_FIREWALL_RICH_RULES_REMOVE)
def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Remove rich rule by ID, emit sync event, refresh state.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``id`` (rule ID).
Returns:
Dict with ``zone`` and ``id`` keys.
Raises:
ValueError: If body is missing ``zone`` or ``id``.
NotFoundError: If the zone or rule does not exist.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
@@ -597,12 +959,32 @@ def remove_rich_rule(_request: Any, body: dict[str, Any] | None) -> dict[str, An
r for r in zone_cfg.get("rich_rules", []) if r.get("id") != rule_id
]
_save_config(cfg)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "rich_rule_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "id": rule_id}
@registry.register(GET_FIREWALL_RICH_RULES)
def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Return rich rules list for a zone from state store, matched with config IDs.
Rules from the live firewall that match a config entry get their ID
included; rules without a config entry are returned without an ID.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` key.
Returns:
List of dicts with ``id`` (if available) and ``rule`` keys.
Raises:
ValueError: If ``zone`` key is missing from body.
"""
if not body or "zone" not in body:
raise ValueError("'zone' is required")
zone = body["zone"]
@@ -623,21 +1005,56 @@ def list_rich_rules(_request: Any, body: dict[str, Any] | None) -> list[dict[str
@registry.register(POST_FIREWALL_MASQUERADE)
def set_masquerade(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Enable/disable masquerade on a zone.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone`` and ``enable`` (boolean).
Returns:
Dict with ``zone`` and ``masquerade`` keys.
Raises:
ValueError: If body is missing ``zone`` or ``enable``, or if
attempting to enable masquerade on the ``public`` zone.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
enable = body.get("enable")
if not zone or enable is None:
raise ValueError("'zone' and 'enable' (bool) are required")
if zone == "public" and enable:
raise ValueError(
"Masquerade (NAT) is not supported on the public zone — enable it on 'internal' or 'vpn' instead"
)
action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "masquerade_set", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "masquerade": bool(enable)}
@registry.register(POST_FIREWALL_FORWARD_PORT_ADD)
def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Add port forwarding rule with auto-generated ID.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone``, ``port``, ``proto``, and optionally
``toaddr`` and ``toport``.
Returns:
Dict with ``zone``, ``id``, ``port``, and ``proto`` keys.
Raises:
ValueError: If body is missing required fields.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
@@ -675,12 +1092,30 @@ def add_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, An
cfg.setdefault("zones", {}).setdefault(zone, {}).setdefault("forward_ports", [])
cfg["zones"][zone]["forward_ports"].append(entry)
_save_config(cfg)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_added", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "id": fp_id, "port": int(port), "proto": proto}
@registry.register(DELETE_FIREWALL_FORWARD_PORT_REMOVE)
def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""Remove port forwarding rule.
Args:
_request: The incoming HTTP request (unused).
body: JSON body with ``zone``, ``port``, and ``proto``.
Returns:
Dict with ``zone``, ``port``, and ``proto`` keys.
Raises:
ValueError: If body is missing required fields.
NotFoundError: If the zone or forward port does not exist.
"""
if not body:
raise ValueError("Request body required")
zone = body.get("zone", "").strip()
@@ -722,12 +1157,28 @@ def remove_forward_port(_request: Any, body: dict[str, Any] | None) -> dict[str,
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == proto)
]
_save_config(cfg)
refresh_state(["firewall"])
sync_result = bus.emit(
SyncEvent(
"firewall", "config_saved", {"action": "forward_port_removed", "zone": zone}
)
)
refresh_state(["firewall", *sync_result.affected_subsystems])
return {"zone": zone, "port": int(port), "proto": proto}
@registry.register(GET_FIREWALL_STATE)
def get_state(_request: Any, _body: Any) -> dict[str, Any]:
"""Return complete firewall state snapshot.
Args:
_request: The incoming HTTP request (unused).
_body: The request body (unused).
Returns:
Dict with ``active_zones``, ``interfaces``, ``available_services``,
``zones``, ``rich_rules``, and ``timestamp`` keys. Returns empty
dict if state is not populated yet.
"""
fw = _get_state()
if fw is None:
return {}
+65 -6
View File
@@ -20,8 +20,10 @@ from daemon.iface import (
POST_NETWORK_INTERFACE_RELOAD,
POST_NETWORK_SYSCTL_SET,
)
from daemon.server import NotFoundError, registry
from lib.common import run, validate_interface_name
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import run, stamp_applied, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg
from lib.dnsmasq import set_upstreams
from lib.network import (
KNOWN_INTERFACE_KEYS,
@@ -34,22 +36,40 @@ from lib.network import (
render_network_file,
save_config,
)
from lib.sync import SyncEvent, bus
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
RUNTIME_DIR = Path("/run/vacuum-wall")
_ALLOWED_SYSCTL_KEYS: set[str] = {
"net.ipv4.ip_forward",
"net.ipv4.conf.all.forwarding",
"net.ipv4.conf.all.accept_redirects",
"net.ipv4.conf.default.accept_redirects",
"net.ipv4.conf.all.send_redirects",
"net.ipv4.conf.default.send_redirects",
"net.ipv4.conf.all.rp_filter",
"net.ipv4.icmp_echo_ignore_all",
"net.ipv4.tcp_syncookies",
}
def _copy_and_reload(iface_name: str) -> None:
"""Copy generated 99-<name>.network file to /etc/systemd/network/ and reload."""
validate_interface_name(iface_name)
src = DATA_DIR / f"99-{iface_name}.network"
runtime_src = RUNTIME_DIR / f"99-{iface_name}.network"
dst_dir = Path("/etc/systemd/network")
RUNTIME_DIR.mkdir(exist_ok=True)
runtime_src.write_text(src.read_text())
run(["mkdir", "-p", str(dst_dir)], sudo=True)
dst = dst_dir / f"99-{iface_name}.network"
run(["cp", str(src), str(dst)], sudo=True)
run(["cp", "--", str(runtime_src), str(dst)], sudo=True)
runtime_src.unlink(missing_ok=True)
# Remove lower-priority .network files that match this interface
# (they would override our config due to higher systemd priority)
@@ -107,7 +127,7 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
runtime = parse_networkctl_status(raw)
merged: dict[str, Any] = {}
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
all_names = set(ifaces_cfg.keys()) | set(runtime.keys())
for name in sorted(all_names):
merged[name] = {
"config": ifaces_cfg.get(name, {}),
@@ -194,7 +214,23 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
)
logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
return {"name": name, "applied": deployed}
# Always stamp the hash so pending-changes detection stays current
# even when deployment fails (e.g. in containerized environments).
# The hash represents the JSON config state, not the system state.
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
)
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {
"name": name,
"applied": deployed,
"synced": sync_result.affected_subsystems,
}
@registry.register(POST_NETWORK_INTERFACE_RELOAD)
@@ -236,9 +272,12 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
cleaned.append(f)
for f in generated:
tmp = RUNTIME_DIR / f.name
run(["cp", "--", str(f), str(tmp)], sudo=False)
dst = sys_dir / f.name
run(["mkdir", "-p", str(sys_dir)], sudo=True)
run(["cp", str(f), str(dst)], sudo=True)
run(["cp", "--", str(tmp), str(dst)], sudo=True)
tmp.unlink(missing_ok=True)
_full_reload()
@@ -247,10 +286,23 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
upstreams = collect_upstream_dns(cfg)
if upstreams:
set_upstreams(upstreams)
# Update dnsmasq applied snapshot + hash so pending-changes
# detection stays correct
dm_cfg = _get_dm_cfg()
stamp_applied(dm_cfg)
_save_dm_cfg(dm_cfg)
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
except Exception:
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
cfg_after = get_config()
stamp_applied(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "config_applied"})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
logger.info(
"Network config applied: %d interfaces, %d stale cleaned",
len(generated),
@@ -260,6 +312,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"applied": len(generated),
"files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned],
"synced": sync_result.affected_subsystems,
}
@@ -295,6 +348,8 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise ValueError("'name' is required")
if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name):
raise ValueError("'name' is not a valid sysctl key")
if name not in _ALLOWED_SYSCTL_KEYS:
raise ValueError("'name' is not a permitted sysctl key")
value = str(body.get("value", "")).strip()
if not value:
raise ValueError("'value' is required")
@@ -311,4 +366,8 @@ def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
)
logger.info("sysctl %s set to %s", name, value)
sync_result = bus.emit(
SyncEvent("networkd", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {"name": name, "value": value}
+415 -174
View File
@@ -2,6 +2,7 @@
import logging
import os
import subprocess
from copy import deepcopy
from pathlib import Path
from typing import Any
@@ -9,11 +10,15 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_NGINX_BACKENDS_REMOVE,
DELETE_NGINX_DOMAINS_REMOVE,
GET_NGINX_BACKENDS,
GET_NGINX_CONFIG,
GET_NGINX_DOMAINS,
PATCH_NGINX_BACKENDS,
PATCH_NGINX_CONFIG,
POST_NGINX_APPLY,
POST_NGINX_BACKENDS_ADD,
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
@@ -21,9 +26,21 @@ from daemon.iface import (
POST_NGINX_SSL_APPLY,
POST_NGINX_TEST,
)
from daemon.server import NotFoundError, refresh_state, registry
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.acme import find_cert_dir
from lib.common import ensure_dirs, load_json, run, run_proc, save_json
from lib.common import (
deep_merge,
ensure_dirs,
load_json,
run,
run_proc,
save_json,
stamp_applied,
strip_apply_meta,
)
from lib.nginx import DEFAULT_SSL, WEBUI_BACKEND
from lib.nginx import _resolve_auth as _ngx_resolve_auth
from lib.nginx import _resolve_paths as _ngx_resolve_paths
logger = logging.getLogger(__name__)
@@ -43,66 +60,76 @@ ENV = Environment(
trim_blocks=True,
)
DEFAULT_SSL: dict[str, Any] = {
"protocols": "TLSv1.2 TLSv1.3",
"ciphers": (
"ECDHE-ECDSA-AES128-GCM-SHA256:"
"ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:"
"ECDHE-RSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:"
"ECDHE-RSA-CHACHA20-POLY1305"
),
"prefer_server_ciphers": False,
}
DEFAULT_CONFIG: dict[str, Any] = {
"backends": {},
"domains": {},
"ssl": {**DEFAULT_SSL},
}
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model."""
if "management" in raw and raw["management"] is not None:
mgmt = raw["management"]
mgmt_domain = mgmt.get("domain", "")
if mgmt_domain:
domains = raw.setdefault("domains", {})
if mgmt_domain not in domains:
domains[mgmt_domain] = {
"force_ssl": True,
"paths": {},
}
dom = domains[mgmt_domain]
paths = dom.setdefault("paths", {})
if "/" not in paths:
paths["/"] = {
"backend": {
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
"port": mgmt.get("backend", {}).get("port", 9090),
"proto": "http",
},
"is_management": True,
}
if mgmt.get("auth"):
paths["/"]["auth"] = mgmt["auth"]
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
# ---------------------------------------------------------------------------
# Migration
def _migrate_config(raw: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""Migrate legacy config to backends model. Returns (config, changed)."""
c1 = _ensure_webui_backend(raw)
c2 = _migrate_mgmt_domains(raw)
return raw, c1 or c2
def _ensure_webui_backend(raw: dict[str, Any]) -> bool:
"""Create builtin webui backend if not yet migrated. Returns True if changed."""
backends = raw.setdefault("backends", {})
webui = backends.get("webui")
if webui and webui.get("_migrated"):
return False
backends["webui"] = deepcopy(WEBUI_BACKEND)
backends["webui"]["_migrated"] = True
# Harvest auth from legacy path-level auth if present
for dom in raw.get("domains", {}).values():
if "paths" not in dom and "backend" in dom:
dom["paths"] = {
"/": {
"backend": dom.pop("backend"),
"headers": dom.pop("headers", {}),
}
}
return raw
paths = dom.get("paths", {})
root = paths.get("/", {})
if root.get("auth"):
backends["webui"]["auth"] = root["auth"]
break
return True
def _migrate_mgmt_domains(raw: dict[str, Any]) -> bool:
"""Migrate legacy mgmt domains to backend refs. Returns True if anything changed."""
backends = raw.get("backends", {})
if not backends.get("webui", {}).get("_migrated"):
return False
domains = raw.setdefault("domains", {})
changed = False
for _name, dom in list(domains.items()):
if dom.get("backend") == "webui":
continue
if dom.get("application") == "webui":
del dom["application"]
changed = True
paths = dom.get("paths", {})
root = paths.get("/", {})
ws = paths.get("/ws", {})
root_backend = root.get("backend", {})
ws_backend = ws.get("backend", {})
is_mgmt_root = root.get("is_management") or (
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
)
is_mgmt_ws = ws.get("is_websocket") or (
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
)
if is_mgmt_root and is_mgmt_ws:
dom["backend"] = "webui"
dom.pop("paths", None)
dom.pop("auth", None)
changed = True
return changed
# ---------------------------------------------------------------------------
# Config helpers
def _get_state() -> dict[str, Any] | None:
@@ -120,9 +147,13 @@ def _get_config() -> dict[str, Any]:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
raw = _migrate_config(raw)
_save_config(raw)
return raw
cfg, changed = _migrate_config(raw)
if changed:
_save_config(cfg)
# The on-disk config format changed under the state store's feet;
# re-collect so cached state (e.g. the domains list) matches the file.
refresh_state(["nginx"])
return cfg
def _save_config(cfg: dict[str, Any]) -> None:
@@ -130,14 +161,122 @@ def _save_config(cfg: dict[str, Any]) -> None:
save_json(CONFIG_FILE, cfg)
def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
# ---------------------------------------------------------------------------
# Backend CRUD
def _get_backends() -> dict[str, Any]:
"""Return the backends dict from config, creating builtin webui if needed."""
cfg = _get_config()
backends = cfg.get("backends", {})
webui = backends.get("webui")
if not webui or not webui.get("_migrated"):
backends["webui"] = deepcopy(WEBUI_BACKEND)
backends["webui"]["_migrated"] = True
cfg["backends"] = backends
_save_config(cfg)
# Config file was rewritten (builtin backend materialized); re-collect
# so cached state matches the file.
refresh_state(["nginx"])
return backends
def _validate_paths(paths: dict) -> None:
"""Validate each path has backend with host, port, proto."""
for path_str, path_cfg in paths.items():
backend = path_cfg.get("backend")
if not backend:
raise ValueError(f"path {path_str!r} missing 'backend'")
if not isinstance(backend, dict):
raise ValueError(f"path {path_str!r} 'backend' must be a dict")
if not backend.get("host"):
raise ValueError(f"path {path_str!r} 'backend' missing 'host'")
if not backend.get("port"):
raise ValueError(f"path {path_str!r} 'backend' missing 'port'")
if not backend.get("proto"):
raise ValueError(f"path {path_str!r} 'backend' missing 'proto'")
def _add_backend(
name: str, label: str, paths: dict, auth: dict | None = None, builtin: bool = False
) -> None:
"""Add a backend. Validate name uniqueness and path schema."""
_validate_paths(paths)
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name in backends:
raise ValueError(f"Backend {name!r} already exists")
entry: dict[str, Any] = {"label": label, "paths": paths}
if builtin:
entry["builtin"] = True
if auth is not None:
if "htpasswd" in auth:
htpasswd_path = Path(auth["htpasswd"])
if not htpasswd_path.is_absolute():
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
entry["auth"] = auth
backends[name] = entry
_save_config(cfg)
def _update_backend(
name: str,
label: str | None = None,
paths: dict | None = None,
auth: dict | None | bool = None,
) -> None:
"""Update a backend. Cannot edit builtin backends. auth=False removes auth."""
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name not in backends:
raise KeyError(name)
if backends[name].get("builtin"):
raise ValueError("Cannot modify builtin backend")
entry = backends[name]
if label is not None:
entry["label"] = label
if paths is not None:
_validate_paths(paths)
entry["paths"] = paths
if auth is False or auth is None:
entry.pop("auth", None)
elif isinstance(auth, dict):
if "htpasswd" in auth:
htpasswd_path = Path(auth["htpasswd"])
if not htpasswd_path.is_absolute():
auth = {**auth, "htpasswd": str(PROJECT_DIR / htpasswd_path)}
entry["auth"] = auth
_save_config(cfg)
def _remove_backend(name: str) -> None:
"""Remove a non-builtin backend. Raise ConflictError if domains reference it."""
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name not in backends:
raise KeyError(name)
if backends[name].get("builtin"):
raise ValueError("Cannot remove builtin backend")
for dom_name, dom in cfg.get("domains", {}).items():
if dom.get("backend") == name:
raise ConflictError(
f"Backend {name!r} is referenced by domain {dom_name!r}"
)
del backends[name]
_save_config(cfg)
# ---------------------------------------------------------------------------
# Site generation
def _generate_server_conf(domain_cfg: dict[str, Any], backends: dict[str, Any]) -> str:
"""Render an nginx server block config from a domain entry via Jinja."""
tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
paths = domain_cfg.get("paths", {})
paths = _ngx_resolve_paths(domain_cfg, backends)
has_management = any(p.get("is_management") for p in paths.values())
# Resolve custom cert paths for cert=="file"
cert_cfg = domain_cfg.get("cert")
if isinstance(cert_cfg, dict):
cert_path = cert_cfg.get("cert_path", "")
@@ -152,7 +291,7 @@ def _generate_server_conf(domain_cfg: dict[str, Any]) -> str:
cert=domain_cfg.get("cert"),
cert_path=cert_path,
cert_key_path=cert_key_path,
domain_auth=domain_cfg.get("auth"),
domain_auth=_ngx_resolve_auth(domain_cfg, backends),
has_management=has_management,
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
@@ -176,11 +315,11 @@ def _write_include_file() -> None:
"""Write the system include file that references all per-site configs."""
tmpl = ENV.get_template("nginx/include.conf")
content = tmpl.render(sites_glob=str(SITES_DIR / "*.conf"))
tmp = Path("/tmp") / "vacuum-wall-include.tmp"
with open(tmp, "w") as f:
f.write(content)
tmp = Path("/run/vacuum-wall/include.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(content)
os.chmod(tmp, 0o644)
run(["cp", str(tmp), str(INCLUDE_FILE)], sudo=True)
run(["cp", "--", str(tmp), str(INCLUDE_FILE)], sudo=True)
run(["chown", "root:root", str(INCLUDE_FILE)], sudo=True)
tmp.unlink(missing_ok=True)
@@ -194,11 +333,11 @@ def _write_ssl_snippet() -> None:
ssl_cfg.setdefault("ciphers", DEFAULT_SSL["ciphers"])
tmpl = ENV.get_template("nginx/ssl_snippet.conf")
content = tmpl.render(ssl=ssl_cfg)
tmp = Path("/tmp") / "vacuum-wall-ssl-snippet.tmp"
with open(tmp, "w") as f:
f.write(content)
tmp = Path("/run/vacuum-wall/ssl-snippet.tmp")
tmp.parent.mkdir(exist_ok=True)
tmp.write_text(content)
os.chmod(tmp, 0o644)
run(["cp", str(tmp), str(SSL_SNIPPET)], sudo=True)
run(["cp", "--", str(tmp), str(SSL_SNIPPET)], sudo=True)
run(["chown", "root:root", str(SSL_SNIPPET)], sudo=True)
tmp.unlink(missing_ok=True)
@@ -222,15 +361,59 @@ def _reload_nginx() -> None:
logger.info("nginx configuration applied and reloaded")
def _ensure_self_signed_cert(domain: str) -> None:
"""Auto-generate a self-signed cert for *domain* if not yet present."""
certs_dir = PROJECT_DIR / "data" / "certs"
certs_dir.mkdir(parents=True, exist_ok=True)
cert_file = certs_dir / f"{domain}.crt"
key_file = certs_dir / f"{domain}.key"
if cert_file.is_file() and key_file.is_file():
return
logger.info("Auto-generating self-signed cert for %s", domain)
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(key_file),
"-out",
str(cert_file),
"-days",
"365",
"-nodes",
"-subj",
f"/CN={domain}",
],
capture_output=True,
text=True,
check=True,
timeout=30,
)
cert_file.chmod(0o644)
key_file.chmod(0o600)
def _write_all_sites() -> None:
"""Regenerate all site configs and ACME challenge site."""
ensure_dirs(SITES_DIR)
cfg = _get_config()
backends = cfg.get("backends", {})
# Auto-generate self-signed certs for management domains that need them
for name, dom in cfg.get("domains", {}).items():
cert = dom.get("cert")
paths = _ngx_resolve_paths(dom, backends)
has_management = any(p.get("is_management") for p in paths.values())
if has_management and (cert == "selfsigned" or cert is None):
_ensure_self_signed_cert(name)
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
conf = _generate_server_conf(dom_copy)
conf = _generate_server_conf(dom_copy, backends)
_write_site(name, conf)
written.add(f"{name}.conf")
@@ -252,6 +435,10 @@ def _write_all_sites() -> None:
os.replace(tmp, site)
# ---------------------------------------------------------------------------
# Auth helpers
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt via passlib."""
from passlib.hash import sha256_crypt
@@ -259,13 +446,19 @@ def _hash_password(password: str) -> str:
return sha256_crypt.hash(password)
def _write_htpasswd(user: str, password: str) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing."""
ensure_dirs(DATA_DIR)
def _write_htpasswd(
user: str, password: str, htpasswd_path: Path | None = None
) -> None:
"""Add or update a user entry in the .htpasswd file using SHA-256 hashing.
If *htpasswd_path* is not given, defaults to :data:`HTPASSWD_FILE`.
"""
target = htpasswd_path or HTPASSWD_FILE
ensure_dirs(target.parent)
hashed = _hash_password(password)
existing: dict[str, str] = {}
if HTPASSWD_FILE.exists():
with open(HTPASSWD_FILE) as f:
if target.exists():
with open(target) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
@@ -274,12 +467,12 @@ def _write_htpasswd(user: str, password: str) -> None:
if len(parts) == 2:
existing[parts[0]] = line
existing[user] = f"{user}:{hashed}"
tmp = HTPASSWD_FILE.with_suffix(".tmp")
tmp = target.with_suffix(".tmp")
with open(tmp, "w") as f:
for _uname, entry in existing.items():
f.write(entry + "\n")
os.chmod(tmp, 0o640)
os.replace(tmp, HTPASSWD_FILE)
os.replace(tmp, target)
def _get_nginx_state() -> dict[str, Any]:
@@ -300,7 +493,8 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
ng = _get_nginx_state()
if ng:
return ng.get("config", {})
return _get_config()
cfg = _get_config()
return strip_apply_meta(cfg)
@registry.register(POST_NGINX_CONFIG)
@@ -318,8 +512,6 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/config — deep-merge partial updates into current config."""
if not body:
raise ValueError("Request body required")
from lib.common import deep_merge
current = _get_config()
merged = deep_merge(current, body)
_save_config(merged)
@@ -338,10 +530,7 @@ def get_domains(_request: Any, _body: Any) -> list[dict[str, Any]]:
@registry.register(POST_NGINX_DOMAINS_ADD)
def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/domains/add — add a new reverse-proxy domain entry.
Accepts either legacy backend_* fields or a ``paths`` map.
"""
"""POST /nginx/domains/add — add a new reverse-proxy domain entry."""
if not body:
raise ValueError("Request body required")
domain = body.get("domain", "").strip()
@@ -351,70 +540,36 @@ def add_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
paths = body.get("paths")
backend_name = body.get("backend", "").strip()
if not backend_name:
raise ValueError("'backend' is required")
if backend_name not in cfg.get("backends", {}):
raise ValueError(f"Backend {backend_name!r} not found")
cert = body.get("cert")
force_ssl = body.get("force_ssl", True)
if paths is not None:
entry: dict[str, Any] = {
"paths": paths,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
else:
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
extra_headers = body.get("extra_headers")
if not backend_host:
raise ValueError("'backend_host' is required")
if backend_port is None:
raise ValueError("'backend_port' is required")
entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
entry: dict[str, Any] = {
"backend": backend_name,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
# Handle auth credentials for management domain
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "")
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth_dict = {"user": auth_user, "htpasswd": str(HTPASSWD_FILE)}
paths_entry = entry.get("paths", {})
for _ppath, pcfg in paths_entry.items():
if pcfg.get("is_management"):
pcfg["auth"] = auth_dict
break
entry["auth"] = auth_dict
# Handle auth credentials for management paths
auth_user = body.get("auth_user", "").strip()
auth_pass = body.get("auth_pass", "").strip()
if auth_user and auth_pass:
_write_htpasswd(auth_user, auth_pass)
auth_entry = {
"user": auth_user,
"htpasswd": str(HTPASSWD_FILE),
}
# Store auth on root path if it exists
root_path = entry.get("paths", {}).get("/")
if root_path:
root_path["auth"] = auth_entry
# Also store at domain level for template
entry["auth"] = auth_entry
auth = body.get("auth")
if auth is not None:
# Write htpasswd file when a password is provided, then store
# only {user, htpasswd path} — never persist the raw password.
if auth.get("user") and auth.get("pass"):
htpasswd_path = auth.get(
"htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd")
)
if isinstance(htpasswd_path, str) and not Path(htpasswd_path).is_absolute():
htpasswd_path = PROJECT_DIR / htpasswd_path
_write_htpasswd(auth["user"], auth["pass"], Path(htpasswd_path))
entry["auth"] = {"user": auth["user"], "htpasswd": str(htpasswd_path)}
else:
entry["auth"] = auth
cfg["domains"][domain] = entry
_save_config(cfg)
@@ -455,43 +610,126 @@ def update_domain(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
raise NotFoundError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
# Path removal: if body has `path` key (string) but no `paths`/`backend`/`headers`
path_to_remove = body.get("path")
if path_to_remove is not None and "paths" not in body and "backend" not in body and "headers" not in body:
paths = entry.get("paths", {})
if path_to_remove in paths:
del paths[path_to_remove]
if not paths:
entry.pop("paths", None)
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain, "path_removed": path_to_remove}
new_backend = body.get("backend")
if new_backend:
new_backend = new_backend.strip()
if new_backend not in cfg.get("backends", {}):
raise ValueError(f"Backend {new_backend!r} not found")
entry["backend"] = new_backend
updates = {k: v for k, v in body.items() if k not in ("domain", "path")}
if "paths" in updates:
entry["paths"] = updates["paths"]
else:
paths = entry.setdefault("paths", {})
if "backend" in updates:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = updates["backend"]
if "headers" in updates:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = updates["headers"]
for key, val in updates.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry:
entry[key].update(val)
if "cert" in body:
if body["cert"] is None:
entry.pop("cert", None)
else:
entry[key] = val
entry["cert"] = body["cert"]
if "force_ssl" in body:
entry["force_ssl"] = body["force_ssl"]
if "auth" in body:
if body["auth"] is None:
entry.pop("auth", None)
else:
# Normalize: if auth has `pass`, write htpasswd and store only
# `{user, htpasswd path}` — never persist the raw password.
if body["auth"].get("user") and body["auth"].get("pass"):
htpasswd_path = body["auth"].get(
"htpasswd", str(PROJECT_DIR / "data" / "nginx" / ".htpasswd")
)
if (
isinstance(htpasswd_path, str)
and not Path(htpasswd_path).is_absolute()
):
htpasswd_path = PROJECT_DIR / htpasswd_path
_write_htpasswd(
body["auth"]["user"], body["auth"]["pass"], Path(htpasswd_path)
)
entry["auth"] = {
"user": body["auth"]["user"],
"htpasswd": str(htpasswd_path),
}
else:
entry["auth"] = body["auth"]
_save_config(cfg)
refresh_state(["nginx"])
return {"domain": domain}
@registry.register(GET_NGINX_BACKENDS)
def get_backends(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /nginx/backends — return backends with secrets stripped."""
backends = _get_backends()
result: dict[str, Any] = {}
for name, be in backends.items():
entry = deepcopy(be)
entry.pop("_migrated", None)
if "auth" in entry:
entry["has_auth"] = entry["auth"] is not None
del entry["auth"]
else:
entry["has_auth"] = False
result[name] = entry
return result
@registry.register(PATCH_NGINX_BACKENDS)
def patch_backends(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /nginx/backends — deep-merge partial updates into a backend entry."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
backends = cfg.setdefault("backends", {})
if name not in backends:
raise KeyError(name)
if backends[name].get("builtin"):
raise ValueError("Cannot modify builtin backend")
update_data = {k: v for k, v in body.items() if k != "name"}
if "auth" in update_data and (
update_data["auth"] is False or update_data["auth"] is None
):
backends[name].pop("auth", None)
update_data.pop("auth")
backends[name] = deep_merge(backends[name], update_data)
_save_config(cfg)
refresh_state(["nginx"])
return {"backend": name}
@registry.register(POST_NGINX_BACKENDS_ADD)
def add_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /nginx/backends/add — add a new backend."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
label = body.get("label", "").strip()
paths = body.get("paths")
auth = body.get("auth")
if not name:
raise ValueError("'name' is required")
if not label:
raise ValueError("'label' is required")
if not paths:
raise ValueError("'paths' is required")
_add_backend(name, label, paths, auth=auth if auth else None)
refresh_state(["nginx"])
return {"backend": name}
@registry.register(DELETE_NGINX_BACKENDS_REMOVE)
def remove_backend(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /nginx/backends/remove — remove a non-builtin backend."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
_remove_backend(name)
refresh_state(["nginx"])
return {"backend": name}
@registry.register(POST_NGINX_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /nginx/apply — render all configs, test, and reload nginx."""
@@ -502,6 +740,9 @@ def apply(_request: Any, _body: Any) -> dict[str, Any]:
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_reload_nginx()
cfg_after = _get_config()
stamp_applied(cfg_after)
_save_config(cfg_after)
refresh_state(["nginx"])
return {"applied": True}
+226
View File
@@ -0,0 +1,226 @@
"""Aggregate status handler.
Exposes pending changes across all subsystems, a single apply-all
endpoint that invokes each subsystem's apply in the correct order, and a
cancel-all endpoint that reverts pending edits to the last applied config.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from daemon.handlers import dnsmasq as _dnsmasq_h
from daemon.handlers import firewall as _firewall_h
from daemon.handlers import nginx as _nginx_h
from daemon.handlers.dnsmasq import apply_config as dnsmasq_apply_config
from daemon.handlers.firewall import config_apply as firewall_config_apply
from daemon.handlers.network import apply_all as network_apply_all
from daemon.handlers.nginx import apply as nginx_apply
from daemon.handlers.wireguard import apply as wireguard_apply
from daemon.iface import (
GET_STATUS_PENDING,
POST_STATUS_APPLY_ALL,
POST_STATUS_CANCEL_ALL,
)
from daemon.server import refresh_state, registry
from lib import network as _net
from lib import wireguard as _wg
from lib.common import revert_to_applied
from lib.firewall import fw_change_summary
from lib.state import state as state_store
logger = logging.getLogger(__name__)
SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"]
SYS_LABELS = {
"networkd": "Network",
"firewall": "Firewall",
"wireguard": "WireGuard",
"dnsmasq": "DHCP/DNS",
"nginx": "Nginx",
}
SYS_APPLY = {
"networkd": network_apply_all,
"firewall": firewall_config_apply,
"wireguard": wireguard_apply,
"dnsmasq": dnsmasq_apply_config,
"nginx": nginx_apply,
}
# (module, attribute) pairs for each subsystem's on-disk config path.
# Resolved at call time so tests can monkeypatch the module constants.
SYS_CONFIG_PATHS: dict[str, tuple[Any, str]] = {
"firewall": (_firewall_h, "CONFIG_FILE"),
"dnsmasq": (_dnsmasq_h, "CONFIG_PATH"),
"nginx": (_nginx_h, "CONFIG_FILE"),
"wireguard": (_wg, "CONFIG_PATH"),
"networkd": (_net, "CONFIG_FILE"),
}
@registry.register(GET_STATUS_PENDING)
def status_pending(_request: Any, _body: Any) -> dict[str, Any]:
"""Aggregate pending changes across all subsystems.
Returns:
Dict with per-subsystem pending status and total change count.
"""
fw = state_store.get("firewall") or {}
pending_fw = fw.get("pending", {})
fw_needs_apply = pending_fw.get("needs_apply", False)
fw_pending_list = pending_fw.get("pending", [])
fw_changes = []
for c in fw_pending_list:
zone = c.get("zone", "unknown")
ctype = c.get("type", "unknown")
summary = fw_change_summary(zone, ctype, c)
fw_changes.append({"summary": summary, "detail": ""})
fw_result = {
"needs_apply": fw_needs_apply,
"change_count": len(fw_changes),
"changes": fw_changes,
}
hash_subsystems = {
"dnsmasq": _hash_subsystem("dnsmasq", state_store.get("dnsmasq")),
"nginx": _hash_subsystem("nginx", state_store.get("nginx")),
"wireguard": _hash_subsystem("wireguard", state_store.get("wireguard")),
"networkd": _hash_subsystem("networkd", state_store.get("networkd")),
}
total = len(fw_changes)
for _name, result in hash_subsystems.items():
total += len(result["changes"])
return {
"firewall": fw_result,
"dnsmasq": hash_subsystems["dnsmasq"],
"nginx": hash_subsystems["nginx"],
"wireguard": hash_subsystems["wireguard"],
"networkd": hash_subsystems["networkd"],
"total_changes": total,
}
@registry.register(POST_STATUS_APPLY_ALL)
def status_apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"""Apply pending changes for all subsystems in dependency order.
Order: network -> firewall -> wireguard -> dnsmasq -> nginx.
Returns:
Dict with applied subsystems and any errors encountered.
"""
applied = []
errors = {}
pending_data = status_pending(None, None)
fw_pending = pending_data["firewall"]["needs_apply"]
hash_pending = {
"dnsmasq": pending_data["dnsmasq"]["pending_changes"],
"nginx": pending_data["nginx"]["pending_changes"],
"wireguard": pending_data["wireguard"]["pending_changes"],
"networkd": pending_data["networkd"]["pending_changes"],
}
for name in SYS_ORDER:
if name == "firewall":
if not fw_pending:
continue
else:
if not hash_pending.get(name, False):
continue
handler = SYS_APPLY[name]
try:
handler(None, None)
applied.append(name)
except Exception as exc:
label = SYS_LABELS.get(name, name)
errors[label] = str(exc)
logger.error("Apply-all failed for %s: %s", name, exc)
refresh_state(SYS_ORDER)
return {"applied": applied, "errors": errors}
def _config_path(name: str) -> Path:
"""Return the on-disk config path for subsystem *name*.
Resolved via the owning module at call time so tests can monkeypatch
the module constants (e.g. ``lib.wireguard.CONFIG_PATH``).
"""
module, attr = SYS_CONFIG_PATHS[name]
return getattr(module, attr)
@registry.register(POST_STATUS_CANCEL_ALL)
def status_cancel_all(_request: Any, _body: Any) -> dict[str, Any]:
"""Revert pending changes for all subsystems to the last applied config.
Restores each pending subsystem's config file from its recorded
``_last_applied_config`` snapshot, discarding unapplied edits.
Subsystems without a recorded baseline (never applied) are skipped
with a reason instead of being reset. No live-system commands run —
cancel only touches the declarative config files.
Returns:
Dict with ``cancelled`` (list of reverted subsystems),
``skipped`` (label -> reason), and ``errors`` (label -> message).
"""
pending_data = status_pending(None, None)
fw_pending = pending_data["firewall"]["needs_apply"]
hash_pending = {
"dnsmasq": pending_data["dnsmasq"]["pending_changes"],
"nginx": pending_data["nginx"]["pending_changes"],
"wireguard": pending_data["wireguard"]["pending_changes"],
"networkd": pending_data["networkd"]["pending_changes"],
}
cancelled: list[str] = []
skipped: dict[str, str] = {}
errors: dict[str, str] = {}
for name in SYS_ORDER:
pending = fw_pending if name == "firewall" else hash_pending.get(name, False)
if not pending:
continue
label = SYS_LABELS.get(name, name)
try:
ok, reason = revert_to_applied(_config_path(name))
if ok:
cancelled.append(name)
logger.info("Cancelled pending changes for %s", name)
else:
skipped[label] = reason
logger.warning("Cancel-all skipped %s: %s", label, reason)
except Exception as exc:
errors[label] = str(exc)
logger.error("Cancel-all failed for %s: %s", name, exc)
if cancelled:
refresh_state(SYS_ORDER)
return {"cancelled": cancelled, "skipped": skipped, "errors": errors}
def _hash_subsystem(name: str, state: dict[str, Any] | None) -> dict[str, Any]:
"""Build pending result for a hash-based subsystem."""
if state is None:
return {"pending_changes": False, "summary": "Up to date", "changes": []}
status = state.get("status", {})
pending = status.get("pending_changes", False)
label = SYS_LABELS.get(name, name)
if pending:
summary = f"{label} configuration has unapplied changes"
return {
"pending_changes": True,
"summary": summary,
"changes": [{"summary": summary, "detail": ""}],
}
return {"pending_changes": False, "summary": "Up to date", "changes": []}
+35
View File
@@ -0,0 +1,35 @@
"""System metrics handler.
Returns CPU load, memory usage, and per-interface network traffic stats.
"""
from __future__ import annotations
import logging
from typing import Any
from daemon.iface import GET_SYSTEM_METRICS
from daemon.server import registry
from lib.state import state as state_store
logger = logging.getLogger(__name__)
@registry.register(GET_SYSTEM_METRICS)
def system_metrics(_request: Any, _body: Any) -> dict[str, Any]:
"""Return system-wide metrics.
Reads from pre-collected state (CPU load, memory, network traffic).
Returns:
Dict with load, memory, swap, and traffic data.
"""
sys_state = state_store.get("system")
if sys_state is None:
return {
"load": {"load1": 0.0, "load5": 0.0, "load15": 0.0},
"memory": {"total": 0, "available": 0, "used": 0, "used_pct": 0},
"swap": {"total": 0, "used": 0, "used_pct": 0},
"traffic": {},
}
return sys_state
+505 -149
View File
@@ -2,92 +2,68 @@
import logging
import os
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CLASS_STATUS,
GET_WIREGUARD_CLASSES,
GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CLASSES,
PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY,
POST_WIREGUARD_CLASS_INIT_KEYS,
POST_WIREGUARD_CLASSES,
POST_WIREGUARD_CLASSES_UP,
POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT,
POST_WIREGUARD_INITIALIZE,
POST_WIREGUARD_PEERS_ADD,
)
from daemon.server import NotFoundError, refresh_state, registry
from lib.common import deep_merge, load_json, run, run_proc, save_json
from daemon.server import ConflictError, NotFoundError, refresh_state, registry
from lib.common import deep_merge, run, stamp_applied, strip_apply_meta
from lib.sync import SyncEvent, bus
from lib.wireguard import (
_class_interface_name,
_class_peers,
_ensure_access_classes,
_wg_conf_path,
generate_class_conf,
generate_class_keypair,
generate_conf,
generate_keypair,
)
from lib.wireguard import (
generate_client_conf as _gen_client_conf,
)
from lib.wireguard import (
get_config as _get_wireguard_config,
)
from lib.wireguard import (
get_peers as _get_wireguard_peers,
)
from lib.wireguard import (
save_config as _save_wireguard_config,
)
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
DEFAULT_CONFIG: dict[str, Any] = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
},
"peers": {},
}
def _get_state() -> dict[str, Any] | None:
"""Retrieve cached WireGuard state from the global state store."""
from lib.state import state as state_store
return state_store.get("wireguard")
def _get_config() -> dict[str, Any]:
"""Load and merge the WireGuard config with defaults."""
return deep_merge(deepcopy(DEFAULT_CONFIG), load_json(CONFIG_PATH))
def _save_config(cfg: dict[str, Any]) -> None:
"""Persist the WireGuard config to disk."""
save_json(CONFIG_PATH, cfg)
def _generate_conf(cfg: dict[str, Any]) -> str:
"""Render the WireGuard server config file from Jinja template."""
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=cfg["interface"],
peers=cfg.get("peers", {}),
)
def _get_wg_state() -> dict[str, Any]:
"""Return cached WireGuard state, or empty dict if not yet loaded."""
wg = _get_state()
if wg is None:
return {}
return wg
from lib.state import state as state_store
wg = state_store.get("wireguard")
return {} if wg is None else wg
# ---------------------------------------------------------------------------
@@ -100,33 +76,56 @@ def get_config(_request: Any, _body: Any) -> dict[str, Any]:
wg = _get_wg_state()
if wg:
return wg.get("config", {})
cfg = _get_config()
safe = dict(cfg)
cfg = _get_wireguard_config()
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
if "access_classes" in safe:
for ck, cv in safe["access_classes"].items():
if isinstance(cv, dict):
safe["access_classes"][ck] = dict(cv)
safe["access_classes"][ck].pop("private_key", None)
return safe
@registry.register(POST_WIREGUARD_CONFIG)
def save_config_handler(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/config — replace config, preserving existing private key.
"""POST /wireguard/config — replace config, preserving existing private keys.
Raises:
ValueError: When request body is missing.
"""
if not body:
raise ValueError("Request body required")
current = _get_config()
current = _get_wireguard_config()
current_key = current.get("interface", {}).get("private_key", "")
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
if current_key:
body.setdefault("interface", {})["private_key"] = current_key
_save_config(body)
refresh_state(["wireguard"])
# Preserve class private keys
if "access_classes" in body:
current_classes = current.get("access_classes", {})
for ck, cv in body.get("access_classes", {}).items():
if isinstance(cv, dict) and ck in current_classes:
cur_class_pk = current_classes[ck].get("private_key", "")
if cur_class_pk and ck in body["access_classes"]:
body["access_classes"][ck]["private_key"] = cur_class_pk
elif "access_classes" not in body:
body["access_classes"] = current.get("access_classes", {})
_save_wireguard_config(body)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_saved"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"config_saved": True}
@@ -139,79 +138,290 @@ def patch_config(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""
if not body:
raise ValueError("Request body required")
if "interface" in body:
body = dict(body)
body["interface"] = dict(body["interface"])
body["interface"].pop("private_key", None)
current = _get_config()
# Strip class private keys from patch body
if "access_classes" in body:
for _ck, cv in body["access_classes"].items():
if isinstance(cv, dict):
cv.pop("private_key", None)
current = _get_wireguard_config()
merged = deep_merge(current, body)
_save_config(merged)
refresh_state(["wireguard"])
_save_wireguard_config(merged)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_patched"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"config_saved": True}
@registry.register(POST_WIREGUARD_APPLY)
def apply(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/apply — render config, write to disk, bring up tunnel via sudo."""
cfg = _get_config()
conf_text = _generate_conf(cfg)
_save_config(cfg)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / "wg0.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
refresh_state(["wireguard"])
return {"applied": True}
"""POST /wireguard/apply — render config, write to disk, bring up tunnels via sudo.
In multi-interface mode, applies each class with assigned peers
independently. Falls back to legacy single-interface mode.
"""
cfg = _get_wireguard_config()
classes = cfg.get("access_classes", {})
affected: list[str] = []
for class_key in classes:
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
continue
if not _class_peers(cfg, class_key):
continue
try:
conf_text = generate_class_conf(cfg, class_key)
if not conf_text:
continue
except ValueError as e:
logger.warning("Skipping class '%s' during apply: %s", class_key, e)
continue
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
affected.append(f"{ifname}.conf")
if not affected:
# Legacy single-interface mode
conf_text = generate_conf(cfg)
ifname = cfg["interface"]["name"]
conf_path = _wg_conf_path(ifname)
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard tunnel '%s' brought up", ifname)
cfg_after = _get_wireguard_config()
stamp_applied(cfg_after)
_save_wireguard_config(cfg_after)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "config_applied"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {
"applied": True,
"synced": sync_result.affected_subsystems,
"interfaces": affected,
}
@registry.register(POST_WIREGUARD_DOWN)
def down(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/down — bring down the WireGuard tunnel via sudo."""
cfg = _get_config()
name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name)
refresh_state(["wireguard"])
"""POST /wireguard/down — bring down all WireGuard tunnel interfaces."""
cfg = _get_wireguard_config()
classes = cfg.get("access_classes", {})
for class_key in classes:
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
continue
ifname = _class_interface_name(class_key)
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
except Exception:
pass
# Legacy interface (skip if name matches any class interface)
ifname = cfg["interface"].get("name", "")
if ifname:
class_names = {_class_interface_name(k) for k in classes}
if ifname not in class_names:
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
except Exception:
pass
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "tunnel_down"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"down": True}
@registry.register(POST_WIREGUARD_CLASSES_UP)
def class_up(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/classes/<key>/up — bring up a single class's tunnel.
Raises:
ValueError: When body or key is missing.
NotFoundError: When class does not exist.
"""
if not body:
raise ValueError("Request body required")
class_key = body.get("class_key", "").strip()
if not class_key:
raise ValueError("'class_key' is required")
cfg = _get_wireguard_config()
class_cfg = cfg.get("access_classes", {}).get(class_key)
if not class_cfg:
raise NotFoundError(f"Access class '{class_key}' not found")
conf_text = generate_class_conf(cfg, class_key)
if not conf_text:
raise ValueError(f"No peers assigned to class '{class_key}'")
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_tmp = Path(f"/run/vacuum-wall/{ifname}.conf.tmp")
local_tmp.parent.mkdir(exist_ok=True)
local_tmp.write_text(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True, check=False)
logger.info("WireGuard class '%s' tunnel '%s' brought up", class_key, ifname)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "class_up", "class_key": class_key}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"up": True, "interface": ifname}
@registry.register(DELETE_WIREGUARD_CLASSES_DOWN)
def class_down(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/classes/<key>/down — bring down a single class's tunnel.
Raises:
ValueError: When body or key is missing.
"""
if not body:
raise ValueError("Request body required")
class_key = body.get("class_key", "").strip()
if not class_key:
raise ValueError("'class_key' is required")
cfg = _get_wireguard_config()
if class_key not in cfg.get("access_classes", {}):
raise NotFoundError(f"Access class '{class_key}' not found")
ifname = _class_interface_name(class_key)
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard class '%s' tunnel '%s' brought down", class_key, ifname)
except Exception:
pass
sync_result = bus.emit(
SyncEvent(
"wireguard",
"config_saved",
{"action": "class_down", "class_key": class_key},
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"down": True, "interface": ifname}
@registry.register(GET_WIREGUARD_STATUS)
def status(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/status — return current WireGuard status from cache."""
wg = _get_wg_state()
if wg:
return wg.get("status", {"up": False, "interface": {}, "peers": []})
return {"up": False, "interface": {}, "peers": []}
return wg.get(
"status", {"up": False, "interface": {}, "peers": [], "classes": {}}
)
return {"up": False, "interface": {}, "peers": [], "classes": {}}
@registry.register(POST_WIREGUARD_INITIALIZE)
def initialize(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /wireguard/initialize — generate keypair and store in config (idempotent)."""
cfg = _get_config()
"""POST /wireguard/initialize — generate keypair and store in config (idempotent).
Also generates key pairs for each access class interface.
"""
cfg = _get_wireguard_config()
if cfg["interface"].get("private_key"):
_ensure_access_classes(cfg)
# Generate keys for classes that need them (idempotent, saves internally)
for class_key in cfg.get("access_classes", {}):
generate_class_keypair(class_key)
return {"initialized": False, "reason": "already initialized"}
res = run_proc([WG_BIN, "genkey"], sudo=True)
private_key = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
public_key = res2.stdout.strip()
cfg["interface"]["private_key"] = private_key
cfg["interface"]["public_key"] = public_key
_save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", public_key[:16])
refresh_state(["wireguard"])
# Fresh init
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
_ensure_access_classes(cfg)
# Generate class keys
for class_key in cfg.get("access_classes", {}):
generate_class_keypair(class_key)
_save_wireguard_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "initialized"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
safe = dict(cfg)
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
for ck, cv in safe.get("access_classes", {}).items():
if isinstance(cv, dict):
safe["access_classes"][ck] = dict(cv)
safe["access_classes"][ck].pop("private_key", None)
return {"initialized": True, "config": safe}
@registry.register(POST_WIREGUARD_CLASS_INIT_KEYS)
def init_class_keys(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/classes/keys/<key> — generate keypair for a class.
Raises:
ValueError: When body or key is missing.
NotFoundError: When class does not exist.
"""
if not body:
raise ValueError("Request body required")
class_key = body.get("class_key", "").strip()
if not class_key:
raise ValueError("'class_key' is required")
cfg = _get_wireguard_config()
if class_key not in cfg.get("access_classes", {}):
raise NotFoundError(f"Access class '{class_key}' not found")
class_cfg = cfg["access_classes"][class_key]
if class_cfg.get("private_key"):
return {
"generated": False,
"class_key": class_key,
"reason": "already has keys",
}
_, pub = generate_class_keypair(class_key)
return {"generated": True, "class_key": class_key, "public_key": pub}
@registry.register(POST_WIREGUARD_PEERS_ADD)
def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/peers/add — add new peer or update existing one.
@@ -224,33 +434,45 @@ def add_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
cfg = _get_wireguard_config()
peers = cfg.setdefault("peers", {})
allowed_ips = body.get("allowed_ips", [])
if name in peers:
peer = peers[name]
peer["endpoint"] = body.get("endpoint")
peer["allowed_ips"] = allowed_ips
peer["persistent_keepalive"] = body.get("persistent_keepalive")
if body.get("preshared_key") is not None:
if "endpoint" in body:
peer["endpoint"] = body["endpoint"]
if "allowed_ips" in body:
peer["allowed_ips"] = body["allowed_ips"]
if "persistent_keepalive" in body:
peer["persistent_keepalive"] = body["persistent_keepalive"]
if "preshared_key" in body:
peer["preshared_key"] = body["preshared_key"]
if "description" in body:
peer["description"] = body["description"]
if "access_class" in body:
peer["access_class"] = body["access_class"]
logger.info("WireGuard peer '%s' updated", name)
_peer_action = "peer_updated"
else:
res = run_proc([WG_BIN, "genkey"], sudo=True)
priv = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=priv)
pub = res2.stdout.strip()
priv, pub = generate_keypair()
peers[name] = {
"public_key": pub,
"private_key": priv,
"endpoint": body.get("endpoint"),
"allowed_ips": allowed_ips,
"allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"),
"description": body.get("description", ""),
"access_class": body.get("access_class"),
}
logger.info("WireGuard peer '%s' added", name)
_save_config(cfg)
refresh_state(["wireguard"])
_peer_action = "peer_added"
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": _peer_action, "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
peer_out = dict(peers[name])
peer_out.pop("private_key", None)
return peer_out
@@ -269,14 +491,19 @@ def remove_peer(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
cfg = _get_config()
cfg = _get_wireguard_config()
peers = cfg.setdefault("peers", {})
if name not in peers:
raise NotFoundError(f"Peer '{name}' not found")
del peers[name]
_save_config(cfg)
_save_wireguard_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
refresh_state(["wireguard"])
sync_result = bus.emit(
SyncEvent(
"wireguard", "config_saved", {"action": "peer_removed", "peer_name": name}
)
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"name": name}
@@ -286,14 +513,7 @@ def list_peers(_request: Any, _body: Any) -> list[dict[str, Any]]:
wg = _get_wg_state()
if wg:
return wg.get("peers", [])
cfg = _get_config()
result: list[dict[str, Any]] = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
entry.pop("private_key", None)
result.append(entry)
return result
return _get_wireguard_peers()
@registry.register(GET_WIREGUARD_PEER_STATUS)
@@ -305,10 +525,32 @@ def get_peer_status(_request: Any, _body: Any) -> list[dict[str, Any]]:
return []
@registry.register(GET_WIREGUARD_CLASS_STATUS)
def get_class_status(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /wireguard/classes/<key>/status — return status for a specific class interface.
Raises:
NotFoundError: When class does not exist.
"""
wg = _get_wg_state()
if wg:
classes = wg.get("status", {}).get("classes", {})
if body:
key = body.get("class_key", "")
if key and key not in classes:
raise NotFoundError(f"Access class '{key}' not found")
return classes.get(key, {"up": False, "interface": {}, "peers": []})
return classes
return {"up": False, "interface": {}, "peers": []}
@registry.register(POST_WIREGUARD_GENERATE_CLIENT)
def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/generate-client — render client-side WireGuard config for a peer.
Uses the peer's access class subnet and port to derive the correct
server address and endpoint port.
Raises:
ValueError: When body, name, or server_endpoint is missing.
NotFoundError: When peer does not exist or has no private key.
@@ -321,30 +563,144 @@ def generate_client_conf(_request: Any, body: dict[str, Any] | None) -> dict[str
server_endpoint = body.get("server_endpoint", "")
if not server_endpoint:
raise ValueError("'server_endpoint' is required")
cfg = _get_config()
cfg = _get_wireguard_config()
if name not in cfg.get("peers", {}):
raise NotFoundError(f"Peer '{name}' not found")
peer = cfg["peers"][name]
client_priv = peer.get("private_key", "")
if not client_priv:
if not peer.get("private_key"):
raise NotFoundError(f"Peer '{name}' has no private key")
iface = cfg["interface"]
sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(name) + 2
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
addr_part, prefix = srv_addr.rsplit("/", 1)
prefix_base = addr_part.rsplit(".", 1)[0]
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf")
conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
conf = _gen_client_conf(
peer_name=name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=iface.get("public_key", ""),
server_endpoint=server_endpoint,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
server_pubkey=cfg["interface"].get("public_key"),
)
return {"config": conf}
# ---------------------------------------------------------------------------
# Access Classes
@registry.register(GET_WIREGUARD_CLASSES)
def list_classes(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /wireguard/classes — return access classes (with private keys stripped)."""
cfg = _get_wireguard_config()
classes = cfg.get("access_classes", {})
safe: dict[str, Any] = {}
for k, v in classes.items():
if isinstance(v, dict):
entry = dict(v)
entry.pop("private_key", None)
safe[k] = entry
else:
safe[k] = v
return safe
@registry.register(POST_WIREGUARD_CLASSES)
def create_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /wireguard/classes — create a new access class with phase-2 fields.
Raises:
ValueError: When body is missing or key conflicts.
"""
if not body:
raise ValueError("Request body required")
key = body.get("key", "").strip()
if not key:
raise ValueError("'key' is required")
if not key.isalnum() or not key.islower():
raise ValueError("'key' must be lowercase alphanumeric")
name = body.get("name", key)
description = body.get("description", "")
cfg = _get_wireguard_config()
classes = cfg.setdefault("access_classes", {})
if key in classes:
raise ConflictError(f"Access class '{key}' already exists")
classes[key] = {
"name": name,
"description": description,
"subnet": body.get("subnet"),
"listen_port": body.get("listen_port"),
"lan_access": body.get("lan_access", False),
"private_key": "",
"public_key": "",
}
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_created"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
out = dict(classes[key])
out.pop("private_key", None)
return out
@registry.register(PATCH_WIREGUARD_CLASSES)
def update_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""PATCH /wireguard/classes/<key> — update an access class.
Raises:
ValueError: When body is missing.
NotFoundError: When class does not exist.
"""
if not body:
raise ValueError("Request body required")
key = body.get("key", "").strip()
if not key:
raise ValueError("'key' is required")
cfg = _get_wireguard_config()
classes = cfg.setdefault("access_classes", {})
if key not in classes:
raise NotFoundError(f"Access class '{key}' not found")
class_cfg = classes[key]
for field in ("name", "description", "subnet", "listen_port", "lan_access"):
if field in body:
class_cfg[field] = body[field]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_updated"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
out = dict(classes[key])
out.pop("private_key", None)
return {"key": key, **out}
@registry.register(DELETE_WIREGUARD_CLASSES)
def delete_class(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""DELETE /wireguard/classes/<key> — remove an access class.
Raises:
ValueError: When body is missing.
NotFoundError: When class does not exist.
ConflictError: When peers still reference the class.
"""
if not body:
raise ValueError("Request body required")
key = body.get("key", "").strip()
if not key:
raise ValueError("'key' is required")
cfg = _get_wireguard_config()
classes = cfg.setdefault("access_classes", {})
if key not in classes:
raise NotFoundError(f"Access class '{key}' not found")
# Check if any peers reference this class
peers_reusing = [
name
for name, info in cfg.get("peers", {}).items()
if info.get("access_class") == key
]
if peers_reusing:
raise ConflictError(
f"Cannot delete class '{key}': {len(peers_reusing)} peer(s) reference it: {', '.join(peers_reusing)}"
)
del classes[key]
_save_wireguard_config(cfg)
sync_result = bus.emit(
SyncEvent("wireguard", "config_saved", {"action": "class_deleted"})
)
refresh_state(["wireguard", *sync_result.affected_subsystems])
return {"key": key}
+66 -1
View File
@@ -29,6 +29,15 @@ PathLike = str | Endpoint
def _ep(method: str, path: str) -> Endpoint:
"""Construct a frozen (method, path) endpoint tuple.
Args:
method: HTTP method string (e.g. ``'GET'``).
path: URL path pattern.
Returns:
Endpoint tuple suitable for ``registry.register()`` and client calls.
"""
return (method, path)
@@ -45,6 +54,10 @@ POST_NGINX_TEST: Endpoint = _ep("POST", "/nginx/test")
POST_NGINX_SSL_APPLY: Endpoint = _ep("POST", "/nginx/ssl-apply")
POST_NGINX_RELOAD: Endpoint = _ep("POST", "/nginx/reload")
GET_NGINX_BACKENDS: Endpoint = _ep("GET", "/nginx/backends")
PATCH_NGINX_BACKENDS: Endpoint = _ep("PATCH", "/nginx/backends")
POST_NGINX_BACKENDS_ADD: Endpoint = _ep("POST", "/nginx/backends/add")
DELETE_NGINX_BACKENDS_REMOVE: Endpoint = _ep("DELETE", "/nginx/backends/remove")
# ---- Firewall ----
GET_FIREWALL_INTERFACES: Endpoint = _ep("GET", "/firewall/interfaces")
@@ -86,6 +99,20 @@ DELETE_WIREGUARD_PEERS_REMOVE: Endpoint = _ep("DELETE", "/wireguard/peers/remove
GET_WIREGUARD_PEERS: Endpoint = _ep("GET", "/wireguard/peers")
GET_WIREGUARD_PEER_STATUS: Endpoint = _ep("GET", "/wireguard/peer-status")
POST_WIREGUARD_GENERATE_CLIENT: Endpoint = _ep("POST", "/wireguard/generate-client")
GET_WIREGUARD_CLASSES: Endpoint = _ep("GET", "/wireguard/classes")
POST_WIREGUARD_CLASSES: Endpoint = _ep("POST", "/wireguard/classes")
PATCH_WIREGUARD_CLASSES: Endpoint = _ep("PATCH", "/wireguard/classes/<key>")
DELETE_WIREGUARD_CLASSES: Endpoint = _ep("DELETE", "/wireguard/classes/<key>")
POST_WIREGUARD_CLASSES_UP: Endpoint = _ep("POST", "/wireguard/classes/<class_key>/up")
DELETE_WIREGUARD_CLASSES_DOWN: Endpoint = _ep(
"DELETE", "/wireguard/classes/<class_key>/down"
)
GET_WIREGUARD_CLASS_STATUS: Endpoint = _ep(
"GET", "/wireguard/classes/<class_key>/status"
)
POST_WIREGUARD_CLASS_INIT_KEYS: Endpoint = _ep(
"POST", "/wireguard/classes/keys/<class_key>"
)
# ---- ACME / Certs ----
GET_ACME_LIST: Endpoint = _ep("GET", "/acme/list")
@@ -94,6 +121,7 @@ POST_ACME_VALIDATE: Endpoint = _ep("POST", "/acme/validate")
POST_ACME_ISSUE: Endpoint = _ep("POST", "/acme/issue")
GET_ACME_ISSUE_STATUS: Endpoint = _ep("GET", "/acme/issue/status")
POST_ACME_RENEW: Endpoint = _ep("POST", "/acme/renew")
GET_ACME_RENEW_STATUS: Endpoint = _ep("GET", "/acme/renew/status")
DELETE_ACME_REMOVE: Endpoint = _ep("DELETE", "/acme/remove")
POST_ACME_EMAIL: Endpoint = _ep("POST", "/acme/email")
GET_ACME_EMAIL: Endpoint = _ep("GET", "/acme/email")
@@ -140,12 +168,49 @@ GET_LOGS_NGINX_ERROR: Endpoint = _ep("GET", "/logs/nginx/error")
GET_LOGS_DNSMASQ: Endpoint = _ep("GET", "/logs/dnsmasq")
GET_LOGS_APP: Endpoint = _ep("GET", "/logs/app")
# ---- Authentication ----
POST_AUTH_LOGIN: Endpoint = _ep("POST", "/auth/login")
POST_AUTH_LOGOUT: Endpoint = _ep("POST", "/auth/logout")
POST_AUTH_REFRESH: Endpoint = _ep("POST", "/auth/refresh")
GET_AUTH_SESSION: Endpoint = _ep("GET", "/auth/session")
POST_AUTH_PASSWORD: Endpoint = _ep("POST", "/auth/password")
# User admin
GET_AUTH_USERS: Endpoint = _ep("GET", "/auth/users")
POST_AUTH_USER_CREATE: Endpoint = _ep("POST", "/auth/users")
POST_AUTH_USER_UPDATE: Endpoint = _ep("POST", "/auth/users/<username>")
DELETE_AUTH_USER: Endpoint = _ep("DELETE", "/auth/users/<username>")
# WebAuthn
POST_AUTH_WEBAUTHN_REGISTER_BEGIN: Endpoint = _ep(
"POST", "/auth/webauthn/register-begin"
)
POST_AUTH_WEBAUTHN_REGISTER_FINISH: Endpoint = _ep(
"POST", "/auth/webauthn/register-finish"
)
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN: Endpoint = _ep(
"POST", "/auth/webauthn/authenticate-begin"
)
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH: Endpoint = _ep(
"POST", "/auth/webauthn/authenticate-finish"
)
GET_AUTH_WEBAUTHN_CREDENTIALS: Endpoint = _ep("GET", "/auth/webauthn/credentials")
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS: Endpoint = _ep(
"GET", "/auth/webauthn/credential-counts"
)
DELETE_AUTH_WEBAUTHN_CREDENTIAL: Endpoint = _ep(
"DELETE", "/auth/webauthn/creds/<credential_id>"
)
GET_AUTH_WEBAUTHN_CAPABLE: Endpoint = _ep("GET", "/auth/webauthn/capable")
# ---- Server infra (not going through client) ----
GET_HEALTH: Endpoint = _ep("GET", "/health")
GET_STATUS_ALL: Endpoint = _ep("GET", "/status/all")
POST_STATUS_REFRESH: Endpoint = _ep("POST", "/status/refresh")
GET_WS: Endpoint = _ep("GET", "/ws")
POST_BATCH: Endpoint = _ep("POST", "/batch")
GET_STATUS_PENDING: Endpoint = _ep("GET", "/status/pending")
POST_STATUS_APPLY_ALL: Endpoint = _ep("POST", "/status/apply-all")
POST_STATUS_CANCEL_ALL: Endpoint = _ep("POST", "/status/cancel-all")
GET_SYSTEM_METRICS: Endpoint = _ep("GET", "/system/metrics")
# Collect all endpoint module-level constants for __all__ verification
_all_endpoints = [
+220 -55
View File
@@ -9,7 +9,9 @@ import hashlib
import json
import logging
import os
import re
import signal
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -17,6 +19,7 @@ from typing import Any
from aiohttp import web
from daemon.iface import PathLike
from lib.auth import blacklist_expired
from lib.state import _DEFAULT_POLL_INTERVALS
from lib.state import state as state_store
@@ -34,7 +37,15 @@ if _RAW_POLL:
if ":" in pair:
name, _, val = pair.partition(":")
try:
_POLL_OVERRIDE[name.strip()] = int(val.strip())
parsed = int(val.strip())
if parsed <= 0:
logger.warning(
"Invalid poll interval value %r for %r (must be > 0), skipping",
val,
name,
)
continue
_POLL_OVERRIDE[name.strip()] = parsed
except ValueError:
logger.warning(
"Invalid poll interval value %r for %r, skipping", val, name
@@ -94,6 +105,7 @@ class Registry:
method = ep_method
def decorator(fn: Callable) -> Callable:
"""Inner decorator that stores *fn* in the registry and attaches Handler metadata."""
self._routes[(method.upper(), path)] = fn # type: ignore[arg-type]
fn._handler = Handler(method, path) # type: ignore[attr-defined,reportArgumentType]
return fn
@@ -148,7 +160,14 @@ def refresh_state(subsystems: list[str] | None = None) -> None:
except RuntimeError:
pass
else:
task = asyncio.create_task(broadcast_versions())
# Broadcast each refreshed subsystem individually (gather for parallelism)
async def _broadcast_all():
await asyncio.gather(
*[broadcast_versions(name) for name in targets],
return_exceptions=True,
)
task = asyncio.create_task(_broadcast_all())
task.add_done_callback(_ws_tasks.discard)
_ws_tasks.add(task)
@@ -228,7 +247,8 @@ async def _handle_request(request: web.Request) -> web.Response:
# Build body — merge order (highest wins): path params > JSON body > query params.
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
# be overridable by body or query parameters.
# be overridable by body or query parameters. This prevents callers from
# spoofing path-scoped parameters via request body.
body: dict[str, Any] | None = pat_params if pat_params else None
if request.content_type == "application/json":
try:
@@ -340,7 +360,6 @@ def create_app() -> web.Application:
"""
app = web.Application()
app.router.add_route("GET", "/health", _health)
app.router.add_route("GET", "/status/all", get_status_all)
app.router.add_route("POST", "/status/refresh", refresh_status)
app.router.add_route("POST", "/batch", _handle_batch)
app.router.add_route("GET", "/ws", _handle_ws)
@@ -348,23 +367,102 @@ def create_app() -> web.Application:
return app
# JWTs are dot-joined base64url segments — a subset of the RFC 6455 token
# character set. Browsers therefore carry the access token as the
# Sec-WebSocket-Protocol subprotocol name itself (see websocket.js); the
# "Bearer <token>" form is not a valid subprotocol (space is not a token
# character) and is rejected by the WebSocket constructor.
_JWT_SUBPROTOCOL_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
def _extract_ws_token(subprotocols: list[str]) -> tuple[str | None, str | None]:
"""Extract the auth JWT from parsed Sec-WebSocket-Protocol names.
Accepts the raw JWT as a subprotocol name (bundled client) or the
legacy "Bearer <token>" form (non-browser clients that can send spaces).
Args:
subprotocols: Parsed subprotocol names (already stripped/split).
Returns:
``(token, matched_subprotocol)`` or ``(None, None)`` if no usable
auth subprotocol was found.
"""
for proto in subprotocols:
if _JWT_SUBPROTOCOL_RE.fullmatch(proto):
return proto, proto
for proto in subprotocols:
if proto.startswith("Bearer "):
token = proto[7:].strip()
if token:
return token, proto
return None, None
# WebSocket subscribers
_ws_subscribers: set[web.WebSocketResponse] = set()
_ws_tasks: set[asyncio.Task[None]] = set()
_poll_tasks: set[asyncio.Task[None]] = set()
_last_blacklist_cleanup: float = 0
_last_blacklist_cleanup_lock: asyncio.Lock | None = None
async def _handle_ws(request: web.Request) -> web.Response:
"""WebSocket endpoint for real-time state change notifications.
"""WebSocket endpoint for real-time state streaming.
On connect: sends current versions. On state change: broadcasts
updated subsystem versions. Clients disconnect to unsubscribe.
On connect: sends a full state snapshot of every subsystem
({type: snapshot, data: {subsystem: state, …}}). On state change:
broadcasts a data-carrying per-subsystem delta (versions or tick).
Clients disconnect to unsubscribe.
Authentication: JWT access token passed via:
1. WebSocket subprotocol header — the bundled client sends the raw JWT
as the subprotocol name (Sec-WebSocket-Protocol must carry a valid
RFC 6455 token; a JWT is one, "Bearer <token>" is not).
2. "Bearer <token>" subprotocol — legacy form for non-browser clients.
3. X-Auth-Token header — fallback for custom nginx setups that inject
it (not set by the bundled nginx config).
"""
ws = web.WebSocketResponse()
from aiohttp import hdrs
from lib.auth import validate_token
token_param = None
matched_proto = None
# Prefer the subprotocol header. Sec-WebSocket-Protocol is a
# comma-separated list; parse it the same way aiohttp's own handshake
# does (Request has no subprotocol helper).
protocol_header = request.headers.get(hdrs.SEC_WEBSOCKET_PROTOCOL, "")
subprotocols = [p.strip() for p in protocol_header.split(",") if p.strip()]
token_param, matched_proto = _extract_ws_token(subprotocols)
if token_param is None:
token_param = request.headers.get("X-Auth-Token")
if token_param is None:
return web.json_response(
{"ok": False, "error": "authentication required"}, status=401
)
# Validate token. Session binding is skipped because browsers cannot send
# custom headers on WebSocket connections (no X-Session-Id available).
payload = validate_token(
token_param,
token_type="access",
)
if payload is None:
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
# Negotiate only the matched auth subprotocol (or all if token came from header)
ws = web.WebSocketResponse(
protocols=[matched_proto] if matched_proto else subprotocols
)
await ws.prepare(request)
_ws_subscribers.add(ws)
await ws.send_json({"type": "init", "versions": state_store.get_versions()})
snapshot = state_store.get_snapshot()
await ws.send_json({"type": "snapshot", "data": snapshot})
try:
async for msg in ws:
@@ -378,39 +476,69 @@ async def _handle_ws(request: web.Request) -> web.Response:
return ws
async def broadcast_versions() -> None:
"""Broadcast updated subsystem versions to all WebSocket clients."""
updated = state_store.get_updated_versions()
if not updated or not _ws_subscribers:
async def _send_all(message: str) -> None:
"""Send a message string to all WS subscribers, removing dead ones."""
dead: set[web.WebSocketResponse] = set()
for ws in _ws_subscribers:
try:
await ws.send_str(message)
except Exception:
dead.add(ws)
_ws_subscribers.difference_update(dead)
if dead:
logger.warning("Removed %d dead WS subscribers", len(dead))
async def broadcast_versions(subsystem: str) -> None:
"""Send {type: versions} + full subsystem data to WS clients.
NOTE: Does NOT call state_store.bump(). Callers who need version bumps
(e.g. refresh_state) call bump themselves. poll_loop bumps before calling.
No legacy `updated` field is emitted (no backward compat) — version
counters still advance but are not sent over the wire.
"""
data = state_store.get(subsystem)
if data is None:
# Collector failed during the triggering populate/refresh — populate()
# cleared this subsystem to None (state.py). Skip the broadcast:
# a null payload would overwrite good client data. The next successful
# poll or mutation broadcasts the real value.
return
data = json.dumps({"type": "versions", "updated": updated})
dead: set[web.WebSocketResponse] = set()
for ws in _ws_subscribers:
try:
await ws.send_str(data)
except Exception:
dead.add(ws)
_ws_subscribers.difference_update(dead)
if dead:
logger.warning("Removed %d dead WS subscribers", len(dead))
message = json.dumps(
{
"type": "versions",
"subsystem": subsystem,
"data": data,
}
)
await _send_all(message)
async def broadcast_tick(subsystems: list[str]) -> None:
"""Broadcast a lightweight tick to WS clients without version payload."""
data = json.dumps({"type": "tick", "subsystems": subsystems})
dead: set[web.WebSocketResponse] = set()
for ws in _ws_subscribers:
try:
await ws.send_str(data)
except Exception:
dead.add(ws)
_ws_subscribers.difference_update(dead)
if dead:
logger.warning("Removed %d dead WS subscribers", len(dead))
async def broadcast_tick(subsystem: str) -> None:
"""Send {type: tick} with the changed subsystem data.
No bump — tick is volatile-only; the version counter only bumps on
structural changes.
"""
data = state_store.get(subsystem)
message = json.dumps(
{
"type": "tick",
"subsystem": subsystem,
"data": data,
}
)
await _send_all(message)
async def _poll_loop(subsystem: str, interval: int) -> None:
"""Periodically poll a subsystem for state changes and broadcast as needed."""
global _last_blacklist_cleanup, _last_blacklist_cleanup_lock
# Lazy-init lock (requires running event loop)
if _last_blacklist_cleanup_lock is None:
_last_blacklist_cleanup_lock = asyncio.Lock()
offset = int(hashlib.md5(subsystem.encode()).hexdigest(), 16) % interval
await asyncio.sleep(offset)
while True:
@@ -418,9 +546,15 @@ async def _poll_loop(subsystem: str, interval: int) -> None:
structural, volatile = state_store.poll(subsystem)
if structural:
state_store.bump(subsystem)
await broadcast_versions()
await broadcast_versions(subsystem) # now per-subsystem, carries data
elif volatile:
await broadcast_tick([subsystem])
await broadcast_tick(subsystem) # now per-subsystem, carries data
# Periodic blacklist cleanup — coordinated across all poll loops
async with _last_blacklist_cleanup_lock:
now = time.time()
if now - _last_blacklist_cleanup >= 60:
blacklist_expired()
_last_blacklist_cleanup = now
except asyncio.CancelledError:
raise
except Exception:
@@ -452,15 +586,6 @@ async def _health(_request: web.Request) -> web.Response:
return ok({"pid": os.getpid(), "socket": str(SOCKET_PATH)})
async def get_status_all(_request: web.Request) -> web.Response:
"""Return the entire state snapshot in one call.
Returns:
JSON response containing state data for all subsystems.
"""
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
async def refresh_status(_request: web.Request) -> web.Response:
"""Re-collect all state from system.
@@ -474,11 +599,24 @@ async def refresh_status(_request: web.Request) -> web.Response:
body = await _request.json()
except (json.JSONDecodeError, ValueError):
body = None
subsystems = None
if body and "subsystems" in body:
subsystems = body["subsystems"]
subsystems = body.get("subsystems") if body else None
state_store.populate(subsystems)
return ok({name: state_store.get(name) for name in state_store.SUBSYSTEMS})
targets = subsystems or state_store.SUBSYSTEMS
snapshot = {name: state_store.get(name) for name in targets}
# Broadcast to all WS clients (fire-and-forget, gather for parallelism).
# Deliberately no version bump — versions advance on structural poll
# diffs and on refresh_state() only.
async def _broadcast_all():
await asyncio.gather(
*[broadcast_versions(name) for name in targets],
return_exceptions=True,
)
task = asyncio.create_task(_broadcast_all())
task.add_done_callback(_ws_tasks.discard)
_ws_tasks.add(task)
return ok(snapshot)
async def _catch_all(request: web.Request) -> web.Response:
@@ -501,11 +639,14 @@ def _register_routes() -> None:
"""
from daemon.handlers import (
acme, # noqa: F401
auth, # noqa: F401
dnsmasq, # noqa: F401
firewall, # noqa: F401
logs, # noqa: F401
network, # noqa: F401
nginx, # noqa: F401
status, # noqa: F401
system, # noqa: F401
wireguard, # noqa: F401
)
@@ -520,6 +661,14 @@ def main() -> None:
setup_logging()
# Startup checks
try:
from lib import webauthn as lib_webauthn
lib_webauthn.check_webauthn_config()
except Exception:
pass # ignore if webauthn module import failed
_register_routes()
app = create_app()
@@ -532,13 +681,21 @@ def main() -> None:
loop = asyncio.new_event_loop()
def _on_shutdown(_sig: int) -> None:
async def _shutdown() -> None:
"""Graceful shutdown: cancel poller, close runner, teardown."""
logger.info("Shutting down daemon...")
_stop_polling()
loop.stop()
try:
await asyncio.wait_for(runner.cleanup(), timeout=5)
except TimeoutError:
logger.warning("Runner cleanup timed out, abandoning")
if Path(socket_path).exists():
os.unlink(socket_path)
logger.info("vacuum-walld stopped")
loop.call_soon(loop.stop)
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _on_shutdown, sig)
loop.add_signal_handler(sig, lambda: loop.create_task(_shutdown()))
runner = web.AppRunner(app)
loop.run_until_complete(runner.setup())
@@ -549,6 +706,13 @@ def main() -> None:
os.chmod(socket_path, 0o660)
# Import system configs → JSON (blocking — OK at startup)
from lib.system_import import import_all
reconciled = import_all()
if reconciled:
logger.info("Reconciled subsystems: %s", ", ".join(reconciled))
# Populate state from system (blocking — OK at startup)
logger.info("Populating system state...")
state_store.populate()
@@ -562,10 +726,11 @@ def main() -> None:
try:
loop.run_forever()
finally:
loop.run_until_complete(runner.cleanup())
# _shutdown() handles cleanup when invoked via signal handler;
# this block is only reached if shutdown didn't happen cleanly
# (e.g., unexpected exit), in which case we unlink the socket.
if Path(socket_path).exists():
os.unlink(socket_path)
logger.info("vacuum-walld stopped")
if __name__ == "__main__":
+717 -46
View File
@@ -1,9 +1,24 @@
# REST API Reference
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination and HTTP basic authentication. Requests target the management domain (e.g., `https://<hostname>.local/api/...`).
All endpoints are served by the management WebUI Flask application bound to `127.0.0.1:9090`, proxied through nginx with SSL termination. Authentication is handled at the Flask layer via JWT — the `Authorization: Bearer <token>` header. Public endpoints (login, WebAuthn authenticate) do not require a token.
Every request and response uses `Content-Type: application/json`.
## Authentication
Most endpoints require a valid JWT access token. The token is obtained by logging in via `POST /api/auth/login` or completing a WebAuthn authentication ceremony.
### Obtaining a Token
1. Call `POST /api/auth/login` with credentials
2. Store the returned `access_token`
3. Include `Authorization: Bearer <access_token>` on all subsequent requests
4. Refresh before expiry via `POST /api/auth/refresh`
### Permission Checks
Each request is checked against per-subsystem permissions. `GET` requires `"read"` or `"rw"` on the subsystem. `POST`/`PATCH`/`DELETE` requires `"rw"`. User management endpoints (`/api/auth/users/*`) require `auth: "rw"`.
## Conventions
### Success Responses
@@ -32,6 +47,7 @@ Error responses carry one of the following HTTP status codes:
|------|---------|
| `400` | Bad request — invalid body, missing required field, or malformed value |
| `404` | Not found — the requested resource does not exist |
| `409` | Conflict — the requested operation conflicts with an existing resource |
| `500` | Internal server error — unexpected failure in the backend |
### Route Patterns
@@ -40,6 +56,357 @@ Resource identification uses **path parameters** whenever possible. Exceptions o
---
## Auth API
Endpoints prefixed with `/api/auth/...`. Manage authentication, session, tokens, and user accounts.
### Login
#### Password Login
```
POST /api/auth/login
```
Authenticate with username and password. Returns access and refresh tokens.
**Auth:** Public — no JWT required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `password` | `string` | Yes | Plain-text password (hashed for verification) |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
| `access_ttl` | `integer` | Access token lifetime in seconds (default: `900`) |
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `401` if credentials are invalid.
---
### Session
#### Get Current Session
```
GET /api/auth/session
```
Return the current authenticated user and permissions.
**Auth:** Access token required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `401` if token is invalid, expired, or blacklisted.
#### Logout
```
POST /api/auth/logout
```
Invalidate the current session by blacklisting the access token.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
#### Refresh Tokens
```
POST /api/auth/refresh
```
Rotate token pair. Validates the refresh token, blacklists the old pair, and issues new access and refresh tokens.
**Auth:** Refresh token required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
| `access_ttl` | `integer` | Access token lifetime in seconds |
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `401` if refresh token is invalid, expired, or blacklisted.
#### Change Password
```
POST /api/auth/password
```
Change the current user's password.
**Auth:** Access token required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | No | Auto-injected from JWT context |
| `oldPassword` | `string` | Yes | Current password |
| `newPassword` | `string` | Yes | New password |
**Response:** `data` is `null` on success.
Returns HTTP `400` if old password is incorrect.
---
### User Management
#### List Users
```
GET /api/auth/users
```
List all users. Requires admin permission (`auth: "rw"`).
**Auth:** `auth: "rw"` required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `users` | `[object, ...]` | Array of user summaries (`id`, `username`, `permissions`) |
#### Create User
```
POST /api/auth/users
```
Create a new user with password and per-subsystem permissions.
**Auth:** `auth: "rw"` required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `password` | `string` | Yes | Plain-text password |
| `permissions` | `object` | No | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `id` | `int` | User ID |
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `409` if username already exists.
#### Update User
```
POST /api/auth/users/<username>
```
Update user's permissions. (To change a password, use `POST /api/auth/password`.)
**Auth:** `auth: "rw"` required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `permissions` | `object` | No | New per-subsystem permissions |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `id` | `int` | User ID |
| `username` | `string` | Username |
| `permissions` | `object` | Per-subsystem permissions (`{ subsystem: "read" \| "rw" }`) |
Returns HTTP `404` if user not found.
#### Delete User
```
DELETE /api/auth/users/<username>
```
Delete a user and all associated permissions and WebAuthn credentials (CASCADE).
**Auth:** `auth: "rw"` required. Cannot delete self.
**Response:** `data` is `{"ok": true}` on success.
Returns HTTP `404` if user not found.
---
### WebAuthn
#### Begin Registration
```
POST /api/auth/webauthn/register-begin
```
Start WebAuthn credential registration. Returns options for `navigator.credentials.create()`.
**Auth:** Access token required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username to register for |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `challenge` | `string` | Base64url challenge |
| `rp` | `object` | Relying party config (`id`, `name`) |
| `user` | `object` | User info for registration |
| `excludeCredentials` | `[object, ...]` | Credentials to exclude |
#### Finish Registration
```
POST /api/auth/webauthn/register-finish
```
Complete WebAuthn credential registration. Verifies the attestation response and stores the credential in the database.
**Auth:** Access token required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `response` | `object` | Yes | WebAuthn authenticator attestation response |
| `name` | `string` | No | Display name for this credential |
**Response:** `data` is `null` on success.
Returns HTTP `400` if verification fails.
#### Begin Authentication
```
POST /api/auth/webauthn/authenticate-begin
```
Start WebAuthn authentication. Returns options for `navigator.credentials.get()`.
**Auth:** Public — no JWT required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username to authenticate |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `challenge` | `string` | Base64url challenge |
| `allowCredentials` | `[object, ...]` | Registered credentials for this user |
Returns `{"ok": true, "data": {"no_webauthn": true}}` if user has no WebAuthn credentials (use password instead).
#### Finish Authentication
```
POST /api/auth/webauthn/authenticate-finish
```
Complete WebAuthn authentication. Verifies the assertion and issues tokens on success.
**Auth:** Public — no JWT required.
**Request Body:**
| Field | Type | Required | Description |
|---|---|---|---|
| `username` | `string` | Yes | Username |
| `assertion_response` | `object` | Yes | WebAuthn authenticator assertion response |
| `auth_options` | `object` | Yes | Original authentication options from `authenticate-begin` |
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `tokens` | `object` | Contains `access_token`, `refresh_token`, and `session_id` |
| `access_ttl` | `integer` | Access token lifetime in seconds |
| `user` | `object` | User info (`username`, `id`) |
| `permissions` | `object` | Per-subsystem permissions |
Returns HTTP `400` if verification fails.
#### List Credentials
```
GET /api/auth/webauthn/credentials
```
List WebAuthn credentials for the current user.
**Auth:** Access token required.
**Response (`data`):**
Array of credential objects (`id`, `name`, `transports`, `credentialId`, `signCount`, `createdAt`).
#### Credential Counts
```
GET /api/auth/webauthn/credential-counts
```
Return credential counts for all users. Admin endpoint.
**Auth:** `auth: "rw"` required.
**Response (`data`):**
| Field | Type | Description |
|---|---|---|
| `counts` | `object` | Dict mapping usernames to credential counts (`{"alice": 2, "bob": 1}`) |
#### Remove Credential
```
DELETE /api/auth/webauthn/creds/<credential_id>
```
Remove a WebAuthn credential.
**Auth:** Access token required.
**Response:** `data` is `null` on success.
Returns HTTP `404` if credential not found.
---
## Firewall API
Endpoints prefixed with `/api/firewall/...`. Interact with firewalld for zone management, rich rules, NAT, and masquerade.
@@ -85,7 +452,13 @@ POST /api/firewall/config/apply
Apply the declarative config to live firewalld. Applies targets, services, interfaces, masquerade, rich rules, and forward ports.
**Response:** `data` contains `applied_zones` list and backup path.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `applied_zones` | `[string, ...]` | List of zone names that were applied |
| `backup` | `string` | Path to the firewall state backup file |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
#### Check Pending Changes
@@ -369,6 +742,22 @@ Toggle masquerade (source NAT) for a zone.
| `zone` | `string` | Zone name |
| `masquerade` | `boolean` | Whether masquerade is now enabled |
### State
#### Get Firewall State
```
GET /api/firewall/state
```
Return current firewall state from the daemon state store. Provides live firewall state data including active zones, services, and interfaces as polled by the daemon.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `object` | Firewall state data from the state collector |
### Info
#### Available Services
@@ -765,20 +1154,6 @@ Returns HTTP `400` if the domain is already configured.
---
#### Get Domain Details
```
GET /api/proxy/domains/<domain>
```
Return the configuration for a single proxy domain.
**Response (`data`):** Domain name plus backend configuration fields.
Returns HTTP `404` if the domain is not configured.
---
#### Update Domain
```
@@ -890,15 +1265,35 @@ Return details for a single certificate.
Returns HTTP `404` if no certificate is found for the domain.
### Validation
#### Validate Certificate Issuance
```
POST /api/certs/validate
```
Run pre-flight checks before certificate issuance. Verifies domain format, ACME account registration, DNS resolution, and port 80 accessibility.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `domain` | `string` | Yes | Domain to validate |
**Response (`data`):** Validation results object with per-check status.
Returns HTTP `400` if the domain is missing.
### Operations
#### Issue Certificate
#### Start Certificate Issuance
```
POST /api/certs/issue
POST /api/certs/issue/start
```
Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
Create a new certificate issuance request. Issuance runs asynchronously in the background.
**Request Body:**
@@ -908,9 +1303,27 @@ Request a new certificate for a domain. Returns HTTP `500` if issuance fails.
| `email` | `string` | No | ACME contact email — **deprecated**, ignored in favor of the registered account email |
| `webroot` | `string` | No | Custom webroot path for HTTP-01 validation |
**Response:** `data` is `null` on success.
**Response (`data`):**
Returns HTTP `400` if the domain is missing. An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
| Field | Type | Description |
|-------|------|-------------|
| `request_id` | `string` | Unique identifier for polling issuance status |
Returns HTTP `400` if the domain is missing. Returns HTTP `409` if a valid certificate already exists for the domain (renew instead). An ACME account must be registered before issuance (verified by the `account_registered` blocking check in the validation pipeline).
---
#### Poll Certificate Issuance Status
```
GET /api/certs/issue/<request_id>
```
Poll the status of a certificate issuance request started by `POST /api/certs/issue/start`.
**Response (`data`):** Issuance status object containing progress, logs, and result.
Returns HTTP `404` if the request ID is not found. The frontend uses `poll()` to repeatedly fetch this endpoint until issuance completes or fails.
---
@@ -920,11 +1333,46 @@ Returns HTTP `400` if the domain is missing. An ACME account must be registered
POST /api/certs/<domain>/renew
```
Force-renew an existing certificate.
Start an async certificate renewal for an existing certificate. The renewal
runs in the background and is polled via
`GET /api/certs/renew/<request_id>`.
**Response:** `data` is `null` on success.
**Request Body:** none (domain is taken from the path).
Returns HTTP `404` if the certificate is not found. Returns HTTP `500` if renewal fails.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `request_id` | `string` | Unique identifier for polling renewal status |
| `domain` | `string` | Domain being renewed |
| `status` | `string` | Only when a renewal for this domain is already in progress (`"existing"` — the existing `request_id` is returned) |
The renewal is a **no-op** when the certificate's renewal window (default:
30 days before expiry) has not been reached — the request then completes with
`status: "skipped"`.
Returns HTTP `400` if the domain is missing. Returns HTTP `500` when the
renewal cannot be started (e.g. daemon unreachable).
---
#### Poll Certificate Renewal Status
```
GET /api/certs/renew/<request_id>
```
Poll the status of a certificate renewal started by
`POST /api/certs/<domain>/renew`.
**Response (`data`):** Renewal status object containing `request_id`,
`domain`, `status` (`"running"`, `"completed"`, `"skipped"`, or `"failed"`),
a `steps` array (each with per-step status and error message), and
timestamps.
Returns HTTP `404` if the request ID is not found. The frontend uses
`poll()` to repeatedly fetch this endpoint until the renewal completes,
is skipped, or fails.
---
@@ -1137,7 +1585,12 @@ POST /api/wireguard/down
Bring down the WireGuard tunnel interface (`wg0`).
**Response:** `data` is `null` on success.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `down` | `boolean` | Always `true` on success |
| `synced` | `[string, ...]` | Subsystems auto-synced as a result |
### Status
@@ -1268,6 +1721,86 @@ This is the only endpoint that returns a WireGuard private key. All other endpoi
Returns HTTP `404` if the peer is not found.
### Access Classes
Manage VPN access classes that categorize peers by access level (e.g., full LAN access, internet-only).
#### List Access Classes
```
GET /api/wireguard/classes
```
Return all configured access classes.
**Response (`data`):**
Object keyed by class identifier, each with `name` and `description` fields.
#### Create Access Class
```
POST /api/wireguard/classes
```
Create a new access class.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier (alphanumeric) |
| `name` | `string` | No | Display name (defaults to key) |
| `description` | `string` | No | Description text |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `key` | `string` | Class key |
| `name` | `string` | Display name |
| `description` | `string` | Description |
Returns HTTP `409` if the key already exists.
#### Update Access Class
```
PATCH /api/wireguard/classes
```
Update an existing access class.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier |
| `name` | `string` | No | New display name |
| `description` | `string` | No | New description |
**Response (`data`):** Updated class object with `key`, `name`, `description`.
Returns HTTP `404` if the class is not found.
#### Delete Access Class
```
DELETE /api/wireguard/classes
```
Remove an access class. Cannot delete a class that has peers assigned to it.
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | `string` | Yes | Class identifier |
**Response (`data`):** `{ "key": "<key>" }`
Returns HTTP `404` if the class is not found. Returns HTTP `409` if peers reference the class.
---
## Network API
@@ -1282,7 +1815,7 @@ Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface co
GET /api/network/interfaces
```
Return all configured interfaces with their network config and runtime state from `networkctl`.
Return all network interfaces (configured and live, including loopback) with their network config and runtime state from `networkctl`. `runtime.state` is the networkctl operational state (`routable`, `degraded`, `carrier`, `off`, …).
**Response:**
@@ -1328,7 +1861,8 @@ Save network config for an interface, render the `.network` file, copy it to `/e
| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Interface name |
| `applied` | `boolean` | Always `true` on success |
| `applied` | `boolean` | `true` if deploy to systemd-networkd succeeded, `false` if the system call was unavailable |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
Returns HTTP `400` if the interface name is invalid.
@@ -1368,6 +1902,7 @@ Full sync: generate all `.network` files, remove stale files, copy to `/etc/syst
| `applied` | `number` | Number of interfaces applied |
| `files` | `[string, ...]` | Paths of generated files |
| `cleaned` | `[string, ...]` | Paths of removed stale files |
| `synced` | `[string, ...]` | Subsystems that were automatically updated by the sync event bus |
### Helpers
@@ -1405,12 +1940,114 @@ Suggest firewalld zone assignments for configured interfaces based on heuristics
|-------|------|-------------|
| `data.zones` | `object` | Map of interface name to suggested zone (`"lan"`, `"wan"`, `"management"`) |
Returns HTTP `500` if the value cannot be verified after write.
---
## Status API
Endpoints prefixed with `/api/status/...`. Aggregate status across all subsystems.
### Pending Changes
#### Check All Pending Changes
```
GET /api/status/pending
```
Aggregate pending changes across all subsystems. Useful for the dashboard to show which subsystems need configuration applied.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `firewall` | `object` | `{ needs_apply, change_count, changes: [{summary, detail}] }` |
| `dnsmasq` / `nginx` / `wireguard` / `networkd` | `object` | `{ pending_changes, summary, changes: [{summary, detail}] }` |
| `total_changes` | `number` | Total count of pending changes across all subsystems |
---
#### Apply All Pending Changes
```
POST /api/status/apply-all
```
Apply pending changes for all subsystems in dependency order.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `applied` | `[string, ...]` | List of subsystems that were applied |
| `errors` | `[object, ...]` | Any errors encountered during apply |
---
#### Cancel All Pending Changes
```
POST /api/status/cancel-all
```
Revert pending changes for all subsystems to the last applied
configuration. Restores each pending subsystem's `config.json` from its
recorded `_last_applied_config` snapshot, discarding unapplied edits.
Subsystems without a recorded baseline (config never applied) are
reported as skipped and left untouched. No live-system commands run —
only the declarative config files are written.
**Request Body:** none.
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| `cancelled` | `[string, ...]` | Subsystems reverted to their last applied config |
| `skipped` | `object` | Map of subsystem label → reason (e.g. "No baseline recorded (never applied)") |
| `errors` | `object` | Map of subsystem label → error message |
---
#### Refresh State
```
POST /api/status/refresh
```
Re-collect state from the daemon, optionally filtered by subsystem. Proxies the daemon's `POST /status/refresh`, which populates the state store for the requested subsystems, replies with their current state, and broadcasts a `versions` WS delta for each so all connected viewers stay in sync.
**Request Body** (optional — `{}` or omitted refreshes all subsystems):
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `subsystems` | `[string, ...]` | No | Subsystem names to refresh (e.g., `["firewall"]`) |
**Response (`data`):**
| Field | Type | Description |
|-------|------|-------------|
| — | `object` | Map of the requested subsystem name(s) to its full state dict (`null` = collector not populated / failed) |
**Example:**
```json
// Request
{"subsystems": ["firewall"]}
// Response
{"ok": true, "data": {"firewall": {"config": {...}, "zones": {...}, "active_zones": {...}, "timestamp": "..."}}}
```
Returns HTTP `500` if the daemon is unreachable.
### Sysctl
#### Set Kernel Parameter
```
POST /api/sysctl/set
POST /api/network/sysctl/set
```
Set a sysctl kernel parameter value via `sysctl -w`, then verify by reading it back.
@@ -1435,7 +2072,7 @@ Returns HTTP `500` if the value cannot be verified after write.
## Logs API
Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the standard JSON `{"ok": true, "data": ...}` response contract — they return HTML `<div>` elements directly. Errors are rendered inline as `(error reading ...)` text rather than returning JSON error responses.
Endpoints prefixed with `/api/logs/...`. Return log lines as JSON strings, wrapped in the standard `{"ok": true, "data": ...}` response contract.
### System Journal
@@ -1445,9 +2082,13 @@ Endpoints prefixed with `/api/logs/...`. These endpoints **do not** follow the s
GET /api/logs/journal
```
Return recent system journal entries as rendered HTML log lines.
Return recent system journal entries.
**Response:** HTML fragment of `<div class="log-line">` elements.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw journal log text |
### Nginx Logs
@@ -1457,9 +2098,15 @@ Return recent system journal entries as rendered HTML log lines.
GET /api/logs/nginx/access
```
Return recent nginx access log entries as rendered HTML.
Return recent nginx access log entries.
**Response:** HTML fragment of `<div class="log-line">` elements.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw access log text |
Returns HTTP `404` if the log file does not exist.
---
@@ -1469,9 +2116,15 @@ Return recent nginx access log entries as rendered HTML.
GET /api/logs/nginx/error
```
Return recent nginx error log entries as rendered HTML.
Return recent nginx error log entries.
**Response:** HTML fragment of `<div class="log-line">` elements.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw error log text |
Returns HTTP `404` if the log file does not exist.
### Dnsmasq Log
@@ -1481,9 +2134,13 @@ Return recent nginx error log entries as rendered HTML.
GET /api/logs/dnsmasq
```
Return recent dnsmasq journal entries as rendered HTML.
Return recent dnsmasq journal entries.
**Response:** HTML fragment of `<div class="log-line">` elements.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw dnsmasq journal text |
### Application Log
@@ -1493,22 +2150,36 @@ Return recent dnsmasq journal entries as rendered HTML.
GET /api/logs/app
```
Return recent application log entries as rendered HTML.
Return recent application log entries.
**Response:** HTML fragment of `<div class="log-line">` elements.
**Response:**
| Field | Type | Description |
|-------|------|-------------|
| `data` | `string` | Raw application log text |
Returns HTTP `404` if the log file does not exist.
---
## WebSocket Protocol
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state change notifications. On connect, the server sends:
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state streaming. After authentication, the server pushes a full state snapshot on connect and then per-subsystem deltas — the client patches models in place (`modelSet`) with no HTTP round-trip.
```json
{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}}
```
### Handshake Authentication
The JWT **access** token travels as the **raw `Sec-WebSocket-Protocol` subprotocol name** (the bundled client sends the bare token, no `Bearer ` prefix — subprotocol names must be valid RFC 6455 tokens). The daemon additionally accepts a legacy `Bearer <token>` subprotocol (non-browser clients) and an `X-Auth-Token` header fallback. The token is validated without session binding (browsers cannot send custom headers on the WebSocket handshake) but with the jti revocation check. A missing or invalid token yields HTTP `401` and no socket is opened.
### Message Types
- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch.
- **`tick`** — Volatile-only change (stats, counters, DHCP IPs). `subsystems` contains affected subsystem names. Triggers lightweight per-subsystem re-fetch.
- **`notify`** — Single-topic notification. `topic` is the subsystem name.
| Type | Sent | Fields | Meaning |
|------|------|--------|---------|
| `snapshot` | On connect (after auth) | `data: {subsystem: state\|null, …}` | Full state for every subsystem. `null` = collector not populated / failed — clients skip those entries. |
| `versions` | Structural change | `subsystem`, `data` | The full state of the one changed subsystem (zone added, config changed, …). Version counter bumped; data pushed. |
| `tick` | Volatile-only change | `subsystem`, `data` | The full state of the one changed subsystem (stats/counters/DHCP IPs). No version bump. |
There is no legacy `updated` dict or `subsystems` array — each data-carrying message names a single `subsystem` and carries its full `data`.
### Manual Refresh
`POST /api/status/refresh` re-collects state (optionally filtered by a `subsystems` array) and broadcasts a `versions` delta for each requested subsystem. It is the HTTP fallback the client uses for the initial load (3s timer) and reconnect recovery. See the [Status API — Refresh State](#refresh-state) section for the full request/response contract.
+197 -24
View File
@@ -20,9 +20,9 @@ For HTTP requests (port 80), nginx returns a 301 redirect to the HTTPS equivalen
### Management WebUI Access (e.g., `<hostname>.local`)
1. A client sends an HTTPS request to the management domain.
2. nginx terminates TLS and checks for HTTP Basic Authentication credentials against the `.htpasswd` file.
3. If authentication succeeds, the request is proxied to `127.0.0.1:9090` where the Flask WebUI is listening.
4. The Flask application processes the request and communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations.
2. nginx terminates TLS and proxies the request to `127.0.0.1:9090` where the Flask WebUI is listening. No nginx-level authentication is applied.
3. Flask validates the JWT from the `Authorization: Bearer <token>` header, checks the token against the SQLite blacklist (`data/auth.db`), and verifies per-subsystem permissions before processing the request. Public endpoints (login, WebAuthn authenticate) are exempt from validation.
4. The Flask application communicates with the `vacuum-walld` daemon via a Unix socket (`data/daemon.sock`) for any privileged operations.
5. The daemon executes the privileged commands via the sudo whitelist and returns structured results.
6. Flask renders an HTML or JSON response, which nginx returns to the client over the encrypted connection.
@@ -33,12 +33,14 @@ Because Flask binds only to `127.0.0.1`, it is unreachable directly from any ext
The following diagram summarizes how the Flask WebUI communicates with each managed subsystem:
```
External Client ──→ nginx (SSL termination) ──→ Flask WebUI (127.0.0.1:9090)
External Client ──→ nginx (SSL termination, NO auth) ──→ Flask WebUI (127.0.0.1:9090, JWT + permission check)
Flask WebUI ──→ daemon/client.py (path resolution, Unix socket) ──→ vacuum-walld (aiohttp server)
Flask WebUI ──→ lib/db.py (abstract DB interface) ──→ SQLite (data/auth.db)
vacuum-walld ──→ daemon/handlers/auth.py ──→ lib/auth.py ──→ JWT operations
vacuum-walld ──→ daemon/handlers/firewall.py ──→ sudo firewall-cmd ──→ firewalld / D-Bus ──→ nftables
vacuum-walld ──→ daemon/handlers/nginx.py ──→ write local .conf files ──→ sudo cp to /etc/nginx/ ──→ sudo nginx -t && sudo nginx -s reload
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo tee /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl reload dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ZeroSSL ACME
vacuum-walld ──→ daemon/handlers/dnsmasq.py ──→ render config ──→ sudo cp /tmp/... /etc/dnsmasq.d/vacuum-wall.conf ──→ sudo systemctl restart dnsmasq
vacuum-walld ──→ daemon/handlers/acme.py ──→ acme.sh (subprocess) ──→ deploy hook (daemon API) ──→ ACME provider
vacuum-walld ──→ daemon/handlers/wireguard.py ──→ render data/wireguard/wg0.conf ──→ sudo cp to /etc/wireguard/ ──→ sudo wg-quick up wg0
vacuum-walld ──→ daemon/handlers/network.py ──→ render 50-<name>.network ──→ sudo cp to /etc/systemd/network/ ──→ sudo networkctl reload
vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ──→ systemd journal
@@ -54,13 +56,68 @@ Vacuum Wall uses two distinct system users bridged by a shared group:
This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`.
**Dev mode variant**: When `install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
**Dev mode variant**: When `scripts/install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group.
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `scripts/install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`.
## JWT Token Model
Vacuum Wall uses JWT-based authentication with access/refresh token rotation. Tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API never reads cookies — authentication is header-only.
| Token | Lifetime | Storage | Purpose |
|---|---|---|---|
| Access | 15 min | sessionStorage / memory | API auth, permission checks |
| Refresh | 7 days | sessionStorage | Token rotation, new access tokens |
JWT payload contains `sub` (username), `exp` (expiry), `iat` (issued at), `jti` (unique identifier), `type` (`"access"` or `"refresh"`), `permissions` (per-subsystem permissions), and `session_id` (session binding). Access tokens additionally contain `permissions` and `session_id`.
Each user has a unique signing secret stored in the `users.jwt_secret` database column (generated as a 32-byte base64url token via `secrets.token_urlsafe(32)`). This per-user secret model means tokens signed for one user cannot be validated as another user's tokens. Both Flask and daemon processes validate tokens by extracting `sub` from the unverified payload, looking up the user's secret, and verifying the signature with that secret. Expired and blacklisted tokens are rejected against the SQLite `token_blacklist` table (via `data/auth.db`).
Token auto-refresh occurs before expiry. On logout or password change, tokens are blacklisted in the SQLite `token_blacklist` table to prevent reuse. The blacklist is cleaned of expired entries on every refresh operation.
## Permission Model
Each user has per-subsystem permissions with two levels:
- **`"read"`** — `GET /api/<subsystem>/*` allowed; `POST`/`PATCH`/`DELETE` rejected with 403
- **`"rw"`** — all HTTP methods allowed for the subsystem
Flask `before_request` middleware enforces permissions by extracting the subsystem name from the blueprint route prefix (e.g., `/api/firewall/``"firewall"`). The middleware checks `request.user.permissions[subsystem]`. If the permission level doesn't match the required level, a 403 response is returned.
The `auth` subsystem controls user management. User CRUD endpoints (`/api/auth/users/*`) require `auth: "rw"` ("admin required").
Login-related endpoints are public (no JWT required): `POST /api/auth/login`, `POST /api/auth/webauthn/authenticate-begin`, `POST /api/auth/webauthn/authenticate-finish`.
## Database Layer
Vacuum Wall uses SQLite for authentication and user management data. Subsystem configuration remains as JSON in `config/*/`.
**Architecture:**
- `lib/db.py` — Query ID constants + abstract `Database` baseclass (no SQL strings)
- `lib/db_sqlite.py``QUERY_MAP` (query_id → SQLite SQL) + concrete implementation
- Subsystems call by **query ID only** — never write SQL
The abstract `Database` baseclass provides:
- Connection caching via `self.conn` property (lazy initialization)
- Prepared statement auto-cache (cached on first use, reused subsequently)
- `query(query_id, params)` — returns row dicts
- `run(query_id, params)` — returns rowcount
- `run_one(query_id, params)` — returns last_insert_id
- `in_transaction()` context manager — provides `BEGIN`/`COMMIT`/`ROLLBACK` with auto-commit suppressed inside
Environment variables (not config files) control database access:
| Env Var | Default | Description |
|---|---|---|
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
Both Flask (`webui/server.py`) and daemon (`daemon/server.py`) call `get_db()` at startup. Each process opens its own connection to the same DB file. SQLite WAL mode enables concurrent reads; writes are serialized by SQLite.
## Install-Time Templating
System configuration files in `system/` are Jinja2 templates rendered by `install.sh` at install time:
System configuration files in `system/` are Jinja2 templates rendered by `scripts/install.sh` at install time:
- **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root).
- **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user.
@@ -79,7 +136,7 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
| nginx | `config/nginx/config.json` | `data/nginx/.htpasswd`, `data/nginx/sites-enabled/` | `data/nginx/sites-enabled/<domain>.conf` + `/etc/nginx/conf.d/vacuum-wall.conf` | All proxy and management domain definitions are derived from the JSON config. Generated `.conf` files are overwritten on each apply. |
| WireGuard | `config/wireguard/config.json` | `data/wireguard/` | `/etc/wireguard/wg0.conf` | The JSON file defines the interface and all peers. The rendered WireGuard config is overwritten on each apply. |
| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. |
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
| ACME | `config/acme/config.json` | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. Account registration (email, CA provider) is stored in the declarative config. |
#### Background Polling
@@ -91,19 +148,117 @@ The daemon runs background polling tasks for subsystems with external runtime st
| wireguard | 10s | Peer connections/handshakes change frequently |
| dnsmasq | 10s | Lease file + service status |
| networkd | 10s | Interface up/down, DHCP address changes |
| system | 1s | Real-time metrics (load/memory/swap/traffic) |
| nginx | 60s | Config-file drift self-heal (lazy in-place migration) |
| acme | 300s | Config-file drift self-heal (lazy in-place migration) |
nginx and acme are not polled — they have no external runtime state.
Only `auth` is not polled — it has no external runtime state.
**Two-layer diff:** Each poll cycle classifies changes as:
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", ...}` → full UI re-load
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystems": [...]}` → lightweight per-subsystem re-fetch
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", "subsystem": ..., "data": ...}` → daemon pushes the full subsystem data over WS; the client patches the model in place via `modelSet`
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystem": ..., "data": ...}` → same in-place patch, without a version bump
- **No change**: silence
Volatile fields per subsystem: `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
Volatile fields per subsystem: `system` (load/memory/swap/traffic), `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`).
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`.
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`, and `broadcast_versions` additionally skips a `None` payload defensively (a null payload would clobber good client data — the next successful poll or mutation broadcasts the real value).
## System Config Import
On daemon startup, `lib/system_import.py` reconciles live system configurations
with the declarative JSON configs. This ensures that configurations created
by `scripts/install.sh` or edited manually in system files are imported into
the JSON source of truth, preventing drift.
When `vacuum-walld` starts, it calls `import_all()` which runs each subsystem
import function:
- **`import_dnsmasq`**: Parses `/etc/dnsmasq.d/vacuum-wall.conf` (managed
block between comment markers) → `config/dnsmasq/config.json`. Only writes
if config doesn't exist or differs.
- **`import_wireguard`**: Parses `/etc/wireguard/wg0.conf`
`config/wireguard/config.json`. Skips if configs match.
- **`import_networkd`**: Parses `/etc/systemd/network/99-*.network` files
(install-time files) → `config/network/config.json`. Only adds/updates
interfaces; doesn't remove interfaces without a file (they may be pending apply).
- **`import_nginx`**: Parses `data/nginx/sites-enabled/*.conf`
`config/nginx/config.json`. Only touches vacuum-wall-managed files
(identified by `# Auto-generated by Vacuum Wall` header). Skips `_acme-challenge.conf`.
- **`import_firewall`**: Runs `sudo firewall-cmd --list-all-zones`
`config/firewall/config.json`. Only writes if no config file exists
(firewalld state always takes precedence).
Import failures are silently logged as warnings — they never abort daemon startup.
The returned list of updated subsystems is logged for debugging.
## Cross-Subsystem Sync Event Bus
When a subsystem's configuration changes, related subsystems are automatically
updated to stay consistent. An in-process event bus (`lib/sync.py`) decouples
subsystems — no handler calls into another handler's logic directly.
### How It Works
1. A mutation handler saves its config (e.g., adding a DHCP range).
2. The handler emits a `SyncEvent` on the event bus.
3. Subscribers react by updating related subsystem configs:
- **DnsToFirewallSync**: Adds `dhcp`, `dns` services to the firewall zone
for each interface serving a DHCP range. Back-propagates gateway (interface
IP) into DHCP ranges so clients receive their default route.
- **WgToFirewallSync**: Creates or updates a `vpn` firewall zone with
WireGuard interface, masquerade, UDP 51820 rich rule, and inter-zone
accept rules for each peer's allowed_ips subnets. Cleans up WireGuard-created
entries when no active peers exist.
- **FirewallToDhcpSync**: Removes stale DHCP ranges for interfaces no longer
in any zone. Ensures DHCP ranges on masquerade-enabled zones carry the
gateway (interface IP). Logs warnings for zones with dhcp service but no range.
- **NetworkToAllSync**: Suggests DHCP ranges for static-IP interfaces without
ranges. Syncs firewall zone interface assignments — adding new interfaces
and removing stale ones no longer in network config.
4. The handler refreshes state for the originating subsystem plus all
transitively affected subsystems.
### Guard Rails
- **Idempotency**: Each subscriber reads current state, computes desired state,
writes the diff. Running twice is safe.
- **No loops**: The event bus tracks `(subsystem, action)` per dispatch cycle.
Re-entrant emits for the same key are silently dropped.
- **Firewall-cmd separation**: Sync subscribers only write JSON config. They
do NOT call `firewall-cmd`. The user clicks "Apply" on the firewall page to
push to firewalld.
- **Error handling**: Subscriber exceptions are caught, logged as warnings,
and do NOT abort the originating handler.
### Frontend Impact
Minimal. The sync happens transparently in the backend. The "pending changes"
indicator on the firewall page will show pending when DHCP or WireGuard saves
(since sync writes JSON but does not call firewall-cmd).
## System Config Import
On daemon startup, `vacuum-walld` runs `import_all()` from `lib/system_import.py`
to reconcile any drift between system configuration files and the declarative
JSON configs. This is invoked from `daemon/server.py` during initialization.
Each subsystem import function parses the corresponding live system config and
updates the JSON config if they differ:
| Subsystem | Source | Condition |
|---|---|---|
| dnsmasq | `/etc/dnsmasq.d/vacuum-wall.conf` | Always — parses managed block between markers |
| firewall | `firewall-cmd --list-all-zones` | Only if no JSON config exists yet |
| WireGuard | `/etc/wireguard/wg0.conf` | Always — parses INI format |
| networkd | `/etc/systemd/network/99-*.network` | Always — parses INI files |
| nginx | `data/nginx/sites-enabled/*.conf` | Always — parses generated server blocks |
All imports are **idempotent** and **non-destructive**: they only write when
configs differ, skip on failure (logged as warnings), and never abort daemon
startup. This ensures that manual edits to system files (e.g., during install
or troubleshooting) are reconciled into the declarative JSON source of truth.
## Directory Structure
@@ -113,6 +268,8 @@ Config files are persistent, user-editable JSON that defines the desired state f
```
config/
├── auth/
│ └── config.json # JWT settings, WebAuthn RP configuration
├── dnsmasq/
│ └── config.json # DHCP ranges, static leases, DNS forwarding, custom records
├── firewall/
@@ -131,8 +288,9 @@ The `data/` directory holds generated files, credentials, and subsystem artifact
```
data/
├── auth.db # SQLite database: users, permissions, token_blacklist, webauthn_creds
├── nginx/
│ ├── .htpasswd # HTTP Basic Authentication credentials for management UI
│ ├── .htpasswd # HTTP Basic credentials for basic-authed proxy domains (created on demand; the management UI itself uses JWT only)
│ └── sites-enabled/ # Generated nginx server block .conf files (one per domain)
├── dnsmasq/
│ └── fragments/ # User-defined dnsmasq config fragments (appended verbatim)
@@ -145,7 +303,11 @@ data/
├── networkd/ # Generated 50-<name>.network files
```
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the Flask process write access to both directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
Both `config/` and `data/` reside within the project directory. The systemd service unit's `ReadWritePaths` directive grants the processes write access to these directories, while keeping the rest of the filesystem read-only. The `INSTALL_DIR` value is templated into the service unit at install time.
The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop.
`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon). `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends).
## File System Layout
@@ -159,6 +321,11 @@ The following file system locations are used for integration with system service
| `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) |
| `/etc/systemd/network/50-<name>.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) |
| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) |
| `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) |
| `/run/nginx` | Runtime directory referenced by the daemon's `ReadWritePaths=`; must exist at spawn. Created by systemd `RuntimeDirectory=` before namespace setup. | Daemon (systemd unit) |
| `/run/firewalld` | Root-owned runtime dir of firewalld. Must exist at spawn because of `ProtectSystem=strict` + `ReadWritePaths=` (see volatile-/run note above). Present while firewalld runs; also pre-created at early boot by `system/tmpfiles.d/vacuum-wall.conf`. | firewalld / systemd-tmpfiles (early boot) |
| `/run/sudo` | sudo's session directory. Present only while sudo sessions exist. **Not** in the unit's `ReadWritePaths=` (NOPASSWD sudo children never need it) — see volatile-/run note above. | sudo (created/removed on demand) |
| `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) |
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
@@ -169,25 +336,31 @@ The web UI is a single-page application built on **Hoover**, a custom lightweigh
### Request Flow (Frontend)
```
Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
Client requests / ──→ nginx ──→ Flask (serves index.html)
Client loads /static/app.js ──→ Hoover initializes, checkSession() (401 with valid refresh token → one refresh) → if no valid session, render #login
Authenticated ──→ mounts #sidebar and #main render roots
apiFetch() ──→ injects Authorization: Bearer <token> header ──→ Flask REST API
Flask before_request ──→ validates JWT from header, checks blacklist, verifies permissions
Hoover connects WebSocket ──→ daemon/ws (raw JWT as Sec-WebSocket-Protocol subprotocol name; legacy `Bearer <token>` subprotocol + X-Auth-Token header fallbacks accepted)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
Token expiry ──→ refreshScheduler() ──→ POST /api/auth/refresh ──→ new tokens
WS connect ──→ snapshot (full state) / versions + tick deltas (per-subsystem data) ──→ modelSet() patches model in place ──→ render engine VDOM-diffs and patches only changed DOM nodes
```
The SPA entry point only serves `index.html` at `/`. All other paths return 404. Non-API, non-static paths are not served by Flask — the client-side router handles all navigation via hash changes. A dedicated `/vendor/<path>` route serves vendored JS libraries.
### Component Model
Each route is a `definePage()` component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using `hComp(page, key)` in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.
### No Build Step
All JavaScript is served as ES modules. The `?v=N` query string param version-pins asset imports for cache invalidation. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
All JavaScript is served as ES modules. Cache invalidation is handled via HTTP cache-control headers. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
### WebSocket Broadcast
### WebSocket Data Streaming
The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.
The daemon pushes state over the WebSocket — no HTTP round-trip for auto-refresh. On connect, after the JWT handshake, it sends a full snapshot (`{"type": "snapshot", "data": {subsystem: state|null, …}}`). On every structural change it broadcasts a per-subsystem delta (`{"type": "versions", "subsystem": …, "data": …}`); on volatile-only changes it sends `{"type": "tick", "subsystem": …, "data": …}`. The client's `handleMessage` patches the matching reactive model in place via `modelSet()`, and the VDOM diff touches only the changed nodes. HTTP remains the fallback for the initial load (3s timer) and for reconnect recovery.
## Zone Model
+157 -28
View File
@@ -153,7 +153,7 @@ The `domains` object maps domain names (keys) to proxy configurations. Each entr
| `paths` | object | Yes | Path-to-config map. Each key is a URL path (e.g., `"/"`, `"/api"`). No catch-all unless `"/"` is explicitly defined. |
| `force_ssl` | boolean | No | Enable HTTPS redirect. HTTP requests to this domain receive a 301 redirect to HTTPS. Default: `true`. |
| `cert` | string | No | Certificate provisioning method. One of: `"acme"`, `"file"`, or `"selfsigned"`. Omit for domains that don't need a dedicated cert. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htppasswd }`). Applies to all paths unless overridden at the path level. |
| `auth` | object | No | Domain-level HTTP basic auth configuration (`{ user, htpasswd }`). Applies to all paths unless overridden at the path level. |
### Path Entries
@@ -166,7 +166,7 @@ Each entry under `paths` defines a location block and its proxy backend.
| `backend.port` | integer | Yes | Port the backend service is listening on. |
| `backend.proto` | string | No | Protocol: `http` or `https`. Default: `http`. |
| `headers` | object | No | Custom proxy headers (key-value pairs). Supports nginx variable interpolation. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htppasswd }` replaces domain-level auth. `null` disables auth for this path. |
| `auth` | object \| null | No | Path-level auth override. `{ user, htpasswd }` replaces domain-level auth. `null` disables auth for this path. |
| `is_management` | boolean | No | Marks this path as the Vacuum Wall WebUI backend. Suppresses security headers (X-Frame-Options, etc.) so the SPA works correctly. |
| `is_websocket` | boolean | No | Marks this path as a WebSocket pass-through. Disables auth, sets Upgrade/Connection headers, uses extended timeouts. |
@@ -264,54 +264,99 @@ acme.sh stores its state under `data/acme/` (the ACME home directory). Key files
The application reads `.account.conf` to determine registration status. If the file is missing or lacks required keys, the account is considered unregistered.
## ACME Configuration
## Auth Configuration
**File**: `config/acme/config.json`
**File**: `config/auth/config.json`
This file stores the ACME account settings used by vacuum-wall for automatic certificate issuance via acme.sh. Account registration is performed exclusively through the WebUI — the Certificates page provides a "Register Account" modal where the user enters an email and selects a CA provider.
This file defines JWT settings and WebAuthn Relying Party configuration for the authentication system.
```json
{
"email": "",
"ca": ""
"jwt": {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256"
},
"webauthn": {
"rp_name": "Vacuum Wall",
"rp_id": "<management-domain>",
"origin": "https://<management-domain>"
}
}
```
### ACME Config Fields
### JWT Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | No (WebUI) | Contact email for the ACME account, used for certificate expiry notifications and recovery. Populated when the user registers an account via the WebUI. Default: `""`. |
| `ca` | string | No (defaults to `letsencrypt`) | ACME CA provider. One of: `"letsencrypt"`, `"zerossl"`. Populated during account registration. Default: `""` (acme.sh defaults to Let's Encrypt if omitted). |
| `access_token_ttl` | integer | No | Access token lifetime in seconds. Default: `900` (15 minutes). |
| `refresh_token_ttl` | integer | No | Refresh token lifetime in seconds. Default: `604800` (7 days). |
| `algorithm` | string | No | JWT signing algorithm. Default: `"HS256"`. |
### Account Registration Flow
**Note:** JWT signing secrets are per-user, not shared. Each user's secret is auto-generated as a 32-byte base64url token (`secrets.token_urlsafe(32)`) and stored in the `users.jwt_secret` database column. Secrets are rotated on password change to invalidate all prior sessions.
1. User navigates to the Certificates page in the WebUI.
2. Clicks "Register Account" and provides an email address, optionally selecting a CA provider.
3. The application calls `acme.sh --register-account -m <email> --server <ca>` as a privileged operation via the daemon.
4. On success, `config/acme/config.json` is updated with the email and CA. acme.sh writes its own state to `data/acme/.account.conf`.
5. The `account_registered` check in the certificate validation pipeline transitions from blocking to passing, enabling certificate issuance.
### WebAuthn Fields
The `account_registered` check is **blocking** — certificate issuance and validation will fail until an ACME account is registered. The `email_configured` check is **non-blocking** — it produces a warning if the email is empty but does not prevent issuance.
| Field | Type | Required | Description |
|---|---|---|---|
| `rp_name` | string | Yes | Display name for the WebAuthn Relying Party. Shown during credential registration. |
| `rp_id` | string | Yes | Domain for WebAuthn credential binding. Must match the management domain. |
| `origin` | string | Yes | HTTPS URL for WebAuthn origin check. Must match `https://<rp_id>`. |
### Account Management API
## Database Schema
| Endpoint | Method | Description |
The SQLite database at `data/auth.db` stores authentication data across four tables. Created automatically on first access via `get_db()`.
### users
| Column | Type | Description |
|---|---|---|
| `/api/certs/account` | `GET` | Returns account status: registered, email, CA provider. |
| `/api/certs/account/register` | `POST` | Registers a new ACME account. Body: `{ "email": "..." }`. Optional: `{ "server": "letsencrypt" }`. |
| `/api/certs/account` | `DELETE` | Deactivates the ACME account via `acme.sh --deactivate-account`. Clears email and CA from config. |
| `/api/certs/email` | `POST` | Updates the contact email on an existing account. Body: `{ "email": "..." }`. |
| `id` | INTEGER | Auto-increment primary key |
| `username` | TEXT | Unique username |
| `password_hash` | TEXT | Argon2id password hash |
| `jwt_secret` | TEXT | Per-user JWT signing secret (32-byte base64url) |
| `created_at` | INTEGER | Unix timestamp (auto-set) |
### ACME Home Directory
### permissions
acme.sh stores its operational state under `data/acme/`. The application reads `data/acme/.account.conf` to determine whether an account is registered. Required keys: `ACME_LEEMAIL` and `ACME_MCA`. Their absence or the file's absence means the account is unregistered.
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER | Auto-increment primary key |
| `username` | TEXT | Foreign key to `users.username` (CASCADE on delete) |
| `subsystem` | TEXT | Subsystem name (e.g., `"firewall"`, `"dhcp"`, `"auth"`) |
| `level` | TEXT | Permission level: `"read"` or `"rw"` |
UNIQUE constraint on `(username, subsystem)`.
### token_blacklist
| Column | Type | Description |
|---|---|---|
| `jti` | TEXT | Primary key — JWT unique identifier |
| `token_type` | TEXT | `"access"` or `"refresh"` |
| `expires` | INTEGER | Unix timestamp of token expiry |
Used to invalidate tokens on logout and password change. Expired entries are cleaned on every refresh operation.
### webauthn_creds
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER | Auto-increment primary key |
| `username` | TEXT | Foreign key to `users.username` (CASCADE on delete) |
| `credential_id` | TEXT | Base64url-encoded credential ID |
| `public_key` | TEXT | Base64url-encoded public key |
| `sign_count` | INTEGER | Signature counter (replay prevention) |
| `name` | TEXT | User-assigned display name |
| `transports` | TEXT | JSON array of transport types |
UNIQUE constraint on `(username, credential_id)`.
## WireGuard Configuration
**File**: `config/wireguard/config.json`
This file defines the WireGuard server interface and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair via `wg genkey` / `wg pubkey`.
This file defines the WireGuard server interface, access classes, and all connected peers. The application renders it into `/etc/wireguard/wg0.conf` and applies it with `wg-quick`. The file is created automatically when `initialize()` generates the server key pair and pre-seeds default access classes.
```json
{
@@ -321,9 +366,31 @@ This file defines the WireGuard server interface and all connected peers. The ap
"private_key": "<generated>",
"public_key": "<generated>",
"addresses": ["10.137.0.1/24"],
"server_endpoint": "vpn.example.com:51820",
"description": "Main WireGuard server",
"post_up": null,
"post_down": null
},
"access_classes": {
"full": {
"name": "Full LAN Access",
"description": "Peers get full access to internal networks",
"subnet": "10.137.0.0/24",
"listen_port": 51820,
"lan_access": true,
"private_key": "<generated>",
"public_key": "<generated>"
},
"internet": {
"name": "Internet Only",
"description": "Peers can only reach the internet",
"subnet": "10.137.1.0/24",
"listen_port": 51821,
"lan_access": false,
"private_key": "<generated>",
"public_key": "<generated>"
}
},
"peers": {
"alice": {
"public_key": "<auto-generated>",
@@ -331,7 +398,9 @@ This file defines the WireGuard server interface and all connected peers. The ap
"endpoint": "203.0.113.1:51820",
"allowed_ips": ["0.0.0.0/0"],
"persistent_keepalive": 25,
"preshared_key": null
"preshared_key": null,
"description": "Alice's office laptop",
"access_class": "full"
}
}
}
@@ -346,9 +415,39 @@ This file defines the WireGuard server interface and all connected peers. The ap
| `private_key` | string | Yes (after init) | Base64-encoded private key for the server interface. Generated automatically by `initialize()` via `wg genkey`. |
| `public_key` | string | Yes (after init) | Corresponding public key. Generated automatically by `initialize()` via `wg pubkey`. |
| `addresses` | array | No | IP address(es) assigned to the server interface in CIDR notation (e.g., `10.137.0.1/24`). Default: `["10.137.0.1/24"]`. |
| `server_endpoint` | string | No | External hostname:port for client connection. Used in generated client configs. Default: `""`. |
| `description` | string | No | Free-text description of the WireGuard server. Default: `""`. |
| `post_up` | string | No | Shell command to run after the interface is brought up. Common uses: adding NAT rules, enabling IP forwarding for the tunnel. Set to `null` to omit. Default: `null`. |
| `post_down` | string | No | Shell command to run after the interface is brought down. Used to clean up rules added by `post_up`. Set to `null` to omit. Default: `null`. |
### Access Classes
Access classes define categories of VPN access. Each class gets its own WireGuard interface (``wg-<key>``), firewall zone (``vpn-<key>``), subnet, and listen port. Peers are assigned to a class and their config is rendered to that class's interface. Pre-seeded with `full` and `internet` defaults on first initialization. Manageable via `GET/POST/PATCH/DELETE /api/wireguard/classes`. Per-class tunnel lifecycle: `POST /api/wireguard/classes/<key>/up`, `POST /api/wireguard/classes/<key>/down`.
| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Human-readable display name for the class. |
| `description` | string | No | Optional description of what access level this class provides. Default: `""`. |
| `subnet` | string | Yes | CIDR subnet for the class's WireGuard interface (e.g., ``10.137.0.0/24``). Server address is derived as ``<base>.1/<prefix>``. |
| `listen_port` | integer | Yes | UDP port for the class's WireGuard interface. Must be unique per class. |
| `lan_access` | boolean | No | When ``true``, the sync subscriber adds inter-zone accept rules for internal subnets, allowing peers to reach the LAN. When ``false``, peers can only reach the internet via masquerade. Default: ``false``. |
| `private_key` | string | Yes (auto) | Base64-encoded private key for the class's WireGuard interface. Auto-generated via ``POST /api/wireguard/classes/keys/<key>``. |
| `public_key` | string | Yes (auto) | Corresponding public key. Auto-generated with ``private_key``. |
### Multi-Interface Behavior
When peers are assigned to an access class, the daemon:
1. Renders a separate ``wg-<key>.conf`` for each class that has assigned peers.
2. Each class interface gets its own private/public key pair.
3. The sync subscriber creates a ``vpn-<key>`` firewall zone per class with masquerade enabled.
4. Classes with ``lan_access=true`` get additional inter-zone rules for internal subnets.
5. ``apply`` brings up all class interfaces independently. Per-class ``up``/``down`` endpoints control individual tunnels.
### Legacy Single-Interface Mode
When no peers are assigned to any access class, the system falls back to the legacy single-interface mode where all peers share ``wg0``.
### Peer Fields
Peers are stored in an object keyed by a human-readable identifier (e.g., `alice`, `office-laptop`). Each peer entry defines a WireGuard peer configuration. When `add_peer()` is called, the peer's key pair is auto-generated. The `private_key` is stored for client configuration generation but stripped from all API responses.
@@ -361,6 +460,8 @@ Peers are stored in an object keyed by a human-readable identifier (e.g., `alice
| `allowed_ips` | array | No | CIDR blocks that traffic from this peer is allowed to route. Default: `[]` (no routing restrictions from the server side). `["0.0.0.0/0"]` allows all traffic. `["10.137.0.0/16"]` restricts traffic to the VPN subnet. |
| `persistent_keepalive` | integer | No | Keepalive interval in seconds. `25` is recommended for peers behind NAT. Set to `0` or `null` to disable. Default: `null`. |
| `preshared_key` | string | No | Optional pre-shared key for post-quantum resistance. Use `wg genpsk` to generate. Default: `null`. |
| `description` | string | No | Optional description for the peer. Default: `""`. |
| `access_class` | string | No | Key of the access class this peer belongs to (e.g., `"full"`, `"internet"`). `null` means unassigned. Default: `null`. |
### Client Configuration Generation
@@ -433,6 +534,12 @@ The `zones` object maps zone names (keys) to zone configurations. Each zone corr
The `config_pending()` function compares the declarative config in `config/firewall/config.json` against the live firewalld state returned by the daemon (via `daemon.handlers.firewall.get_state()`). It returns a diff indicating which zones have pending changes for interfaces, services, target, masquerade, forward ports, and rich rules. Zones that exist live but not in config are reported as `unmanaged_zones`.
Both `/api/firewall/zones/<name>/services` and `/api/firewall/config/apply` reconcile **remove-then-add** against the live zone, so anything opened outside the declarative config (e.g. directly via `firewall-cmd`) is reverted on the next apply. Service changes made through the API are persisted to `config.json` to prevent this drift.
**Management-lockout guard.** The firewalld *default zone* is the catch-all for interfaces with no explicit assignment (typically the WAN), and it carries the management plane (nginx https) plus remote recovery (ssh). Changing the default zone's service set so that **neither `https` nor `ssh`** remains raises `409 Conflict` — from `POST /firewall/zones/<name>/services` and `POST /firewall/config/apply` — before any mutation runs. Send `"force": true` in the request body to override (the UI shows a confirm dialog with this effect on the Zones page). If the default zone cannot be determined, the guard fails closed.
**Applied baseline.** Like the other config-backed subsystems, a successful apply records `_last_applied_hash` and `_last_applied_config` (the meta-stripped config snapshot) inside `config.json`. They are internal bookkeeping — ignored by all parsing, hashing, and UI surfaces — and let the aggregate cancel action (`POST /api/status/cancel-all`) revert this file to the last applied state. Configs that have never been applied have no baseline and are skipped by cancel.
## Networkd (IP Configuration)
**File**: `config/network/config.json`
@@ -521,4 +628,26 @@ When `POST /api/network/apply` is called, the handler automatically collects pub
### Generated Files
Each interface config entry produces a `50-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
Each interface config entry produces a `50-<name>.network` file in `data/networkd/`. During apply, these are copied to `/etc/systemd/network/` and stale files (not matching any config entry) are removed. File generation uses the `systemd.syntax(7)` naming convention: first section is bare (`[Address]`, `[Route]`), subsequent sections use `#` suffix (`[Address#1]`, `[Route#2]`).
## Cross-Subsystem Dependencies
Some subsystems depend on each other. When you modify one, related subsystems
are updated automatically through the event bus.
| Trigger Subsystem | Affected Subsystem | What Happens |
|---|---|---|
| dnsmasq (DHCP range) | firewall | Zone gains `dhcp`/`dns` services. Removing the last range removes them. DHCP ranges also back-propagate gateway (interface IP) so clients receive their default route. |
| wireguard (peer add/remove) | firewall | Per-class `vpn-<key>` zones are created with `wg-<key>` interface, masquerade, UDP port rule, and inter-zone accept rules (only when ``lan_access=true``). Falls back to single `vpn` zone in legacy mode. Cleanup removes stale rules when classes have no peers. |
| firewall (zone changes) | dnsmasq | Stale DHCP ranges (for interfaces no longer in any zone) are automatically removed. Masquerade-enabled zones ensure DHCP ranges carry the gateway. Zones with dhcp service but no range are logged as warnings. |
| network (interface config) | firewall | Zone interface assignments in firewall config are updated — new interfaces are flagged, stale ones removed. |
| network (interface config) | dnsmasq | Suggested DHCP ranges are logged when an interface has a static IP but no DHCP range. |
Note: The firewall "Apply" button is still needed to push config changes to
firewalld. Sync only updates the declarative JSON.
Additionally, on daemon startup, `lib/system_import.py` reconciles live system
configs (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative
JSON. This prevents drift when configs were created by the install script or
edited manually in system files. Reconciliation only writes when the existing
JSON differs or is missing — no data is lost on re-run.
+67 -17
View File
@@ -25,13 +25,13 @@ Download the Vacuum Wall repository onto the target machine, then run the instal
# Production: all env vars
MGMT_DOMAIN=wall.example.com \
MGMT_PASS="strongpassword" \
./install.sh --user vacuum-wall
./scripts/install.sh --user vacuum-wall
# Dev mode: CLI flags, auto-detects repo owner
./install.sh --dev --mgmt-pass strongpassword
./scripts/install.sh --dev --mgmt-pass strongpassword
# mDNS (LAN-only, no DNS record needed)
./install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
./scripts/install.sh --mgmt-domain vacuum-wall.local --mgmt-pass strongpass
```
### Options
@@ -42,7 +42,7 @@ All settings that can be passed as an environment variable also have a CLI flag
|---|---|---|---|
| -- | `MGMT_DOMAIN` | No | Domain for the management WebUI. Defaults to `$hostname.local` (mDNS). Auto-detected from system hostname. **Errors if hostname is undetectable and this is not set.** |
| `--mgmt-domain` | `MGMT_DOMAIN` | No | (same as above) |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. |
| `--mgmt-pass` | `MGMT_PASS` | Yes | Password for the initial admin user (default: `admin`). Creates the admin user in the SQLite database with full `rw` permissions on all subsystems. |
| `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. |
| `--user, -u` | `USER_NAME` | Yes* | WebUI service user (created if it does not exist). Required for non-dev mode. In `--dev` mode, auto-detected from repo owner. |
| `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). |
@@ -51,7 +51,7 @@ All settings that can be passed as an environment variable also have a CLI flag
| `--lan-ifaces` | `LAN_IFACES` | No | LAN interface names, comma-separated. Auto-detected from non-loopback, non-WAN interfaces. |
| `--force-venv` | — | No | Force recreation of the Python virtual environment. |
Run `./install.sh --help` for full usage.
Run `./scripts/install.sh --help` for full usage.
---
@@ -71,7 +71,7 @@ In dev mode, the ownership model preserves the developer's ability to work with
### Running the Installer in Dev Mode
```bash
./install.sh --dev --mgmt-pass strongpassword
./scripts/install.sh --dev --mgmt-pass strongpassword
```
The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above.
@@ -88,7 +88,7 @@ You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (o
```bash
# Docker volume mount example
./install.sh --path /app/vacuum-wall --user ww-app \
./scripts/install.sh --path /app/vacuum-wall --user ww-app \
--mgmt-domain proxy.internal --mgmt-pass strongpassword
```
@@ -96,7 +96,7 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te
---
## What install.sh Does
## What scripts/install.sh Does
The installer performs the following steps automatically:
@@ -114,10 +114,10 @@ The installer performs the following steps automatically:
- **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces.
- **mDNS broadcast**: Enables and starts avahi-daemon so the appliance advertises its hostname (`<hostname>.local`) on the local network.
- **Self-signed certificate**: Generates a temporary self-signed X.509 certificate for the management domain with the correct CN and SAN, placed where acme.sh would store a real cert. Skips if a certificate already exists (preserves real ACME certs).
- **Management proxy configuration**: Configures nginx as a reverse proxy that forward-proxies to the WebUI at `127.0.0.1:9090`, with HTTP-to-HTTPS redirect, basic auth, and WebSocket upgrade support.
- **Credentials**: Generates an htpasswd file using `apache2-utils` (with a Python fallback) for the management proxy's basic auth. Updates existing file if already present.
- **Initial nginx config**: Writes `$PROJECT_DIR/config/nginx/config.json` with the management domain and auth settings pre-configured. Skips if the file already exists (preserves user-customized config).
- **Initial firewall config**: Writes `$PROJECT_DIR/config/firewall/config.json` with auto-detected WAN/LAN interfaces. Skips if the file already exists.
- **Management proxy configuration**: Calls the daemon API (`POST_NGINX_DOMAINS_ADD`) to register the management domain as a regular proxy entry with paths-based config (`/` → Flask, `/ws` → WebSocket). Then applies nginx via `POST_NGINX_APPLY`.
- **Admin user**: Creates the admin user with the password provided via `--mgmt-pass` in the SQLite database (`data/auth.db`). The user gets `rw` permissions on all subsystems. On re-run, updates the admin password if already present.
- **Initial configs**: Firewall config and nginx proxy config are written via daemon API (skips if already exists).
- **System config import**: On startup, the daemon reconciles any live system configurations (dnsmasq, wireguard, networkd, nginx, firewall) with the declarative JSON configs. This prevents drift when system files were edited manually.
- **Systemd units**: Installs four units (rendered from Jinja2 templates):
- `vacuum-walld.service` — the privileged background daemon (aiohttp, daemon socket).
- `vacuum-wall.service` — the Flask WebUI backend.
@@ -131,15 +131,15 @@ The installer performs the following steps automatically:
### Idempotent Re-Runs
`install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
`scripts/install.sh` is fully idempotent and safe to run multiple times. Re-running the script:
- Skips the Python venv (use `--force-venv` to rebuild)
- Restarts `vacuum-walld`, `vacuum-wall`, and reloads `nginx` to pick up changes
- Preserves existing SSL certificates (skips self-signed generation if a cert exists)
- Preserves existing `config.json` files (skips initial write if file exists)
- Safely updates `htpasswd` (uses update mode instead of create mode)
- Updates admin user password if changed
This makes it safe for development workflows: simply run `bash install.sh` again to update an existing installation.
This makes it safe for development workflows: simply run `bash scripts/install.sh` again to update an existing installation.
---
@@ -165,6 +165,21 @@ https://wall.example.com
Log in with the username and password you provided during installation.
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `VACUUM_WALL_DB_BACKEND` | `sqlite` | Database backend selection |
| `VACUUM_WALL_DB_PATH` | `data/auth.db` | SQLite database file path |
### Post-Deploy Verification
1. Confirm `config/auth/config.json` exists with JWT secret and WebAuthn RP configuration
2. Confirm `data/auth.db` exists with admin user present
3. Confirm the management server block has no server-level `auth_basic` directive — the management UI is authenticated by the Flask-layer JWT middleware, not nginx
4. Confirm `location /ws` has `auth_basic off` — the WebSocket is authenticated by the daemon via the raw-JWT `Sec-WebSocket-Protocol` subprotocol, never by nginx
5. Access the WebUI at `https://<management-domain>` — should show a login page
### Certificate Note
The initial certificate is **self-signed** and generated during installation. Your browser will show a security warning. This is expected. Once DNS is pointing to the appliance and port 80 is accessible from the internet, use the **Certs** tab in the WebUI to issue a real ACME certificate for the management domain. After issuance, go to the **Proxy** tab and click **Apply** to reload nginx with the new cert.
@@ -322,11 +337,46 @@ Verify that:
- A DHCP range is configured for the correct interface. Check dnsmasq config at `data/dnsmasq/`.
- The firewall allows DHCP traffic on the internal zone: `firewall-cmd --zone=internal --list-services` should include `dhcp` and `dns`.
### Locked Out of WebUI
If you lose access to the admin account, you can reset the password directly via SQLite:
```bash
# Stop the services
sudo systemctl stop vacuum-wall vacuum-walld
# Reset password (replace 'newpassword' with desired password)
sqlite3 data/auth.db "UPDATE users SET password_hash='NEW_HASH_HERE' WHERE username='admin';"
```
The password hash must be an Argon2id hash. You can generate one:
```bash
python3 -c "from lib.password import hash_password; print(hash_password('newpassword'))"
```
Alternatively, use the SQLite prompt to directly inspect and modify user data:
```bash
sqlite3 data/auth.db ".tables"
sqlite3 data/auth.db "SELECT username FROM users;"
sqlite3 data/auth.db "SELECT * FROM permissions WHERE username='admin';"
```
### Database Corruption
If the SQLite database becomes corrupted:
1. Stop the services: `sudo systemctl stop vacuum-wall vacuum-walld`
2. Inspect: `sqlite3 data/auth.db "PRAGMA integrity_check;"`
3. Restore from backup if needed: `cp data/auth.db.backup data/auth.db`
4. Start services: `sudo systemctl start vacuum-walld vacuum-wall`
### WebUI Not Accessible
1. Verify nginx is running: `systemctl status nginx`.
2. Test nginx configuration: `nginx -t`.
3. Check the management proxy configuration at `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` (initial) or via the WebUI Proxy tab (after first apply).
3. Check the management proxy domain configuration via the WebUI Proxy tab, or by inspecting `config/nginx/config.json`.
4. Ensure the WebUI service is listening on port 9090: `ss -tlnp | grep 9090`.
5. If using the self-signed cert, confirm your browser trusts it or use the WebUI to issue a real ACME certificate.
@@ -338,7 +388,7 @@ Verify that:
|---|---|---|
| Daemon (privileged) | `vacuum-walld.service` | `daemon/` |
| WebUI backend | `vacuum-wall.service` | `webui/` |
| Reverse proxy | `nginx` | `/etc/nginx/conf.d/vacuum-wall-mgmt.conf` |
| Reverse proxy | `nginx` | `config/nginx/config.json` (via daemon) |
| Firewall | `firewalld` | Managed via WebUI and `firewall-cmd` |
| DHCP/DNS | `dnsmasq` | `config/dnsmasq/` |
| VPN | wireguard-tools | `config/wireguard/` |
+285 -98
View File
@@ -11,8 +11,9 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states |
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh |
| Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states |
| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions |
| WebSocket | `websocket.js` | Auto-reconnect WS: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `disconnect()` (terminal-auth socket teardown) |
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts |
@@ -25,29 +26,31 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp
```
index.html — static shell with #sidebar, #main, #modal-root
└── app.js — SPA bootstrap
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
├── modelFetch('firewall') / modelFetch('dnsmasq') / ...
├── render(sidebarEl, Sidebar) — sidebar render root
├── render(mainEl, MainContent) — main content render root
└── connect() — WebSocket lifecycle
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches
├── render(sidebarEl, Sidebar) — sidebar render root
├── render(mainEl, MainContent) — main content render root
└── connect() — WebSocket lifecycle (snapshot → modelSet)
```
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing.
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots.
Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
### Data Flow
```
WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch()
→ reactivity proxy triggers render
→ page.render(state) reads model data
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
(snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch()
```
The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`.
Mutations (`ConfirmDelete`, `ActionButton`, `QuickModal`, `apiSubmit`) refresh models by name (`refresh: 'firewall'`), not by calling load functions. The model layer ensures in-flight dedup, loading flag management, and WS-driven auto-refresh.
State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`).
Mutations no longer trigger explicit model refreshes: after a successful write the daemon re-collects the affected subsystems and broadcasts WS deltas, which `modelSet` applies. `ConfirmDelete` / `ActionButton` / `apiSubmit` therefore skip `modelFetch` (the legacy `refresh` prop is accepted but ignored). Non-state models that still need a post-mutation fetch wire it explicitly (e.g. `backends` via `onComplete` / `onSuccess`).
## Bootstrap
@@ -55,23 +58,54 @@ The app starts from `webui/static/app.js`:
```javascript
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
modelRegister, modelFetch, reactive } from '/static/hoover/index.js';
// 1. Register subsystem models
modelRegister('firewall', {
subsystem: 'firewall',
fetch: async () => {
const r = await apiFetch('/api/firewall/config');
if (!r.ok) throw new Error(r.error);
return r.data;
},
});
// 1. Register subsystem models. All state-backed models share the same
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
// primary data path is the WS snapshot + deltas (modelSet).
const STATE_MODELS = [
{ name: 'firewall', subsystem: 'firewall' },
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
{ name: 'nginx', subsystem: 'nginx' },
{ name: 'acme', subsystem: 'acme' },
{ name: 'wireguard', subsystem: 'wireguard' },
{ name: 'network', subsystem: 'networkd' },
{ name: 'system', subsystem: 'system' },
];
for (const { name, subsystem } of STATE_MODELS) {
modelRegister(name, {
subsystem,
defaultData: SUBSYSTEMS[subsystem].defaults,
fetch: async () => {
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: [subsystem] },
});
if (!r.ok) throw new Error(r.error);
const payload = r.data?.[subsystem];
if (payload == null) throw new Error(subsystem + ': state not populated yet');
return payload;
},
});
}
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } });
```javascript
// ... more modelRegister calls ...
// 2. Initial fetch for all models
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) {
modelFetch(name);
// 2. Initial data. State-backed models receive their first data via the WS
// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
// Non-state models fetch immediately.
function fetchInitialData() {
for (const { name } of STATE_MODELS) {
setTimeout(() => {
const model = getModel(name);
if (model.loading) modelFetch(name); // snapshot not yet delivered
}, 3000);
}
modelFetch('backends');
modelFetch('logs', 'journal');
}
// 3. Create reactive router state
@@ -129,31 +163,39 @@ Manually schedule a re-render. Only one microtask is queued regardless of how ma
## Model
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles fetching, WS invalidation, loading states, and in-flight dedup.
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles WS streaming (via `modelSet`), HTTP fetching (fallback + non-state models, via `modelFetch`), loading states, and in-flight dedup.
### `modelRegister(name, definition)`
Register a subsystem model at app bootstrap.
```javascript
// State-backed model — the fetch below is the HTTP *fallback* (POST
// /api/status/refresh with a subsystem filter); the primary path is the WS
// snapshot + per-subsystem deltas applied via modelSet().
modelRegister('firewall', {
subsystem: 'firewall', // WS topic to listen for ('*' = all)
fetch: async (signal) => { // async fetch function
const r = await apiFetch('/api/firewall/config', { signal });
subsystem: 'firewall', // daemon subsystem ('*' = all)
defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data
fetch: async (signal) => { // HTTP fallback
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: ['firewall'] },
});
if (!r.ok) throw new Error(r.error);
return r.data;
return r.data?.firewall; // null → throw so stale data is kept
},
defaultData: null, // optional, initial data value
// onSuccess: (name, data, param?) => { }, // optional — after model.data is set (also for null)
// onFailure: (name, error) => { }, // optional — after model.error is set (real throws only)
});
// Parameterized example — tab-aware fetch:
// Parameterized example — tab-aware fetch (non-state model):
modelRegister('logs', {
subsystem: '*',
fetch: async (signal, tab) => {
const url = LOG_TABS[tab || 'journal'];
const r = await apiFetch(url, { signal });
if (!r.ok) throw new Error(r.error);
return (r.data || '').split('\n').filter(l => l.length > 0);
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' };
},
});
```
@@ -161,13 +203,15 @@ modelRegister('logs', {
| Parameter | Description |
|---|---|
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
| `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. |
| `definition.subsystem` | The daemon subsystem this model maps to (`'firewall'`, `'dnsmasq'`, `'networkd'`, …). Used by `refreshByTopic()` for manual / non-WS refresh; `'*'` matches all topics. (The WS stream in `websocket.js` resolves subsystem → model via its own internal map, so `networkd` correctly lands on the `network` model regardless of this field.) |
| `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). |
| `definition.defaultData` | Optional initial data value (default: `null`) |
| `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. |
| `definition.onFailure(name, error)` | Optional lifecycle hook called after `model.error` is assigned. Only reachable on a real throw from `fetch` (e.g., network error). Same fire-and-forget error isolation as `onSuccess`. |
### `getModel(name)`
Get a reactive model by name. Returns the model object with `{ data, loading, refresh, error }` properties. Call in `init()` to access model state in `render()`.
Get a reactive model by name. Throws if not registered. Returns the model object with `{ data, loading, refreshing, error }` properties. Call in `init()` to access model state in `render()`.
```javascript
// In page init
@@ -192,18 +236,24 @@ render(state) {
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
```javascript
// Initial load
// HTTP fallback for a state-backed model (WS snapshot is the primary path;
// app.js kicks in with modelFetch(name) if no snapshot arrives within 3s)
modelFetch('firewall');
// Post-mutation refresh
const r = await apiFetch('/api/firewall/zones', { method: 'POST', body });
if (r.ok) modelFetch('firewall');
// Parameterized fetch (e.g., tab-aware logs)
// Non-state models fetch directly (not backed by the daemon state store)
modelFetch('backends');
modelFetch('logs', 'journal');
modelFetch('logs', 'nginx-access');
```
> **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`,
> `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem
> deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the
> pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and
> broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is
> only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a
> subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`.
**Behavior:**
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
- Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches.
@@ -212,11 +262,34 @@ modelFetch('logs', 'nginx-access');
- On failure, stores error in `model.error`.
- Flags cleared in `finally` block.
- Does not abort in-progress fetches — other consumers may still need the data.
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name: param`.
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name` (no param) or `name: JSON.stringify(param)` (with param) — object params (e.g. `{ action: 'refresh' }` vs `{ action: 'check' }`) therefore get distinct keys, and param-less `modelFetch(name)` calls retain the bare `name` key.
### `modelSet(name, data)`
Set a model's data directly from a WebSocket payload — bypasses the fetch cycle (no
`fetch`, no `refreshing` flag). Directly assigns to the reactive proxy so it triggers a
re-render. Clears `model.loading` unconditionally on arrival of real data and resets
`model.error` to `null`.
```javascript
// Called by websocket.js for every WS snapshot / delta — usually you will not call this
modelSet('firewall', payload); // payload: the subsystem state object
```
| Parameter | Description |
|---|---|
| `name` | Model name (e.g., `'firewall'`). Unknown names are a no-op. |
| `data` | The full subsystem state payload from the WS `snapshot`/`versions`/`tick` message. Replaces `model.data` wholesale — pages render against the new reference. |
`websocket.js` maps subsystem → model name (`networkd``network`), and never applies a
`null` payload (a failed collector keeps the current data). See **WS Message Types** /
**WS Data Streaming Flow** below.
### `refreshByTopic(topic)`
Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives.
Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for
manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via
`modelSet` instead).
| Model `subsystem` | Topic | Match? |
|---|---|---|
@@ -242,6 +315,77 @@ render(state) {
Returns `{ loading, refreshing, error }` derived from the union of all passed models.
## Auth model
`auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted
to the single source of truth for the token/session lifecycle: token storage (sessionStorage via
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (TTL 60s timer),
session validation, login/logout transitions, and WS reconnection coordination.
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
`getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object).
**State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`,
`data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and
`onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object
`{ action, payload? }``check`, `refresh`, `login`, `logout` (param-less calls are treated as
`check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**:
storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an
`auth:logout` window event.
**Lifecycle:**
```
app bootstrap → modelFetch('auth', { action: 'check' })
→ 200: stores verified user/permissions + stored tokens → schedules refresh
→ 401 with a stored refresh token (stale access token after page
reload/restore): exactly one refresh attempt, then the same
success or terminal path
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
(no auth:login dispatch)
timer fires (TTL 60s) → refreshAuth() → same path
WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
login → modelFetch('auth', { action: 'login', payload: data })
→ onSuccess stores + schedules + fires auth:login (login action only)
→ app.js listener (deferred to macrotask) → fetchInitialData() + connect()
logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects
any terminal no-token result → onSuccess dispatches auth:logout
→ app.js listener → disconnect() closes the WS socket
```
**Invariants:**
- **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it
(collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd,
system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback
(exactly one refresh when the stored access token is rejected at page load while a
refresh token is still present).
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
- **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the
model's internal helpers only.
- **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in
`onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the
app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct
calls. `auth:logout` fires on every terminal (no-token) transition; its only listener
(app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js`
(would cycle) — the event inverts the dependency.
- **Session binding rotation** — the server mints a new `session_id` on every refresh; any
post-refresh request (the `apiFetch` 401 retry, the WS handshake) must re-read **both**
`Authorization` and `X-Session-Id` from `getAuthData()`.
- **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object:
`name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths
(timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
secondary guard for the timer path.
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
without the terminal `auth:logout``disconnect()` path the previous user's socket would
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
## Virtual DOM
### `h(tag, props, ...children)`
@@ -287,7 +431,7 @@ html`<div class="card">
```javascript
html`<${Badge} text=${val} variant="info" />`
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />`
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" />`
```
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
@@ -413,7 +557,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key.
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
3. **WS auto-refresh**: Topic message arrives → `refreshByTopic()` → `modelFetch()` for matching models → `model.data` update → reactivity triggers `render()`.
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
### `hComp(renderer, key)`
@@ -473,41 +617,44 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
### `connect()`
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s).
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
### `disconnect()`
Close the WS socket (terminal auth transition — logout, failed session check, failed refresh,
or the 401 session-death path). The daemon validates the WS token only at handshake, so the
socket must be closed explicitly on a terminal transition; `app.js` listens for the
`auth:logout` event and calls `disconnect()`.
### WS Message Types
| Type | Fields | Effect |
|---|---|---|
| `versions` | `updated: [topic, …]` | Refresh all models matching listed topics |
| `refresh` | `topics: [topic, …]` | Same as `versions` |
| `notify` | `topic` | Refresh all models matching the topic |
| `status` | `topic` | Refresh all models matching the topic |
The daemon streams state data directly — no HTTP round-trip for auto-refresh:
Model `subsystem: '*'` matches all topics.
| Type | Fields | When sent | Effect |
|---|---|---|---|
| `snapshot` | `data: {subsystem: state \| null, …}` | Once on connect (after JWT handshake) | `modelSet()` for every subsystem; `null` payloads (failed collectors) are skipped |
| `versions` | `subsystem`, `data` | Structural change (config mutated, bump detected) | `modelSet()` for the matching model |
| `tick` | `subsystem`, `data` | Volatile-only change (e.g., `system` metrics at 1s cadence) | `modelSet()` for the matching model |
### WS Auto-Refresh Flow
Unknown or retired shapes (legacy `versions.updated` / `tick.subsystems`, `refresh`, `notify`,
`status`) are ignored — no backward compat.
When a WS message arrives for a topic:
1. `refreshByTopic(topic)` iterates registered models.
2. Matching models call `modelFetch(name)`.
3. Model fetch updates `model.data`, triggering reactivity and page re-renders.
4. In-flight dedup prevents duplicate fetches.
System name → model name mapping is handled internally (`networkd` → `network`); unknown
subsystem names fall through to the raw name.
Pages have no awareness of WS events. The model layer handles all WS-driven refresh.
### WS Data Streaming Flow
### `onMessage(topics, handler)`
When a data-carrying WS message arrives:
1. `handleMessage()` maps the subsystem to its model name.
2. `modelSet(name, data)` replaces `model.data` in place — no fetch, no `loading`/`refreshing` churn.
3. Reactivity detects the change and re-renders the pages reading that model.
4. A `null` payload is never applied — it means the collector failed and stale good data is kept.
Direct one-off subscription for code outside `definePage`:
```javascript
const unsub = onMessage(['firewall'], (msg) => {
// handle raw message
});
// Later: unsub();
```
Handler receives the parsed WS message object.
Pages have no awareness of WS events. Initial load uses `modelFetch` over HTTP (a 3-second timer
in `app.js` kicks in if no snapshot has arrived yet); afterwards the WS stream is the sole
auto-refresh path for state-backed models.
## API
@@ -522,7 +669,8 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
- Automatically sets `Accept: application/json`.
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
- On HTTP 401, reloads the page (session expired).
- When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them.
- On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
- On non-2xx, returns `{ ok: false, error: "message", status }`.
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
- Passes `credentials: 'same-origin'` by default.
@@ -547,7 +695,7 @@ function MainContent() {
### `apiSubmit(config)`
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success. After success, refreshes the named model(s).
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts (appending an auto-synced note when the response includes a `synced` array), and closes the modal on success. Affected state-backed models update from the daemon's WS delta — no explicit `modelFetch`.
```javascript
apiSubmit({
@@ -556,7 +704,6 @@ apiSubmit({
body: () => ({ name: $val('zone-name') }),
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created',
refresh: 'firewall', // model name(s) to refresh after success
closeModal: () => closeModal(), // optional, called after success toast
}),
```
@@ -572,10 +719,13 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
| `body` | `() => body` function, or `undefined` for no body |
| `validate` | `(body) => string | null` — validation function |
| `successMsg` | Success toast message |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
| `submitText` | Submit button text (default: `'Submit'`) |
> The legacy `refresh` option is no longer supported — state-backed models are
> updated by the WS delta after the mutation. To refresh a non-state model after
> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component.
### `checkAbort(ac)`
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
@@ -639,7 +789,7 @@ poll({
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued', 'success');
modelFetch('acme');
// No modelFetch — the WS delta updates the acme model (state-backed).
},
onError: (d) => {
toast('Issuance failed', 'error');
@@ -738,7 +888,7 @@ Flex button container with 8px gap. Accepts VNode children directly.
```javascript
ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }),
ActionButton({ url: '/api/apply', label: 'Apply' }),
)
```
@@ -803,15 +953,16 @@ Card container with optional header.
#### `ConfirmDelete(props)`
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh.
Delete button with native `confirm()` dialog, then API `DELETE` call and a success toast (appending an auto-synced note when the response includes a `synced` array). Shows a spinner during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion. State-backed models update from the daemon's WS delta — no `modelFetch`.
```javascript
ConfirmDelete({
url: '/api/firewall/zones/myzone',
message: 'Delete zone myzone?',
success: 'Zone deleted',
refresh: 'firewall',
label: 'Delete',
deleteKey: 'myzone',
onComplete: () => { /* optional, runs after successful delete */ },
})
```
@@ -822,13 +973,15 @@ ConfirmDelete({
| `url` | API DELETE URL |
| `message` | Confirmation prompt text |
| `success` | Success toast message (default: `'Removed'`) |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
| `label` | Button text (default: `'Remove'`) |
| `body` | Optional JSON body to send with DELETE |
| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success; the mark is auto-purged after 2s (the WS delta normally removes the row sooner). Requires `_deleting.has(key)` class binding on the parent element. |
| `onComplete` | Callback after a successful deletion. Wire it to `modelFetch()` for non-state models. |
#### `ActionButton(props)`
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons.
Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`.
```javascript
ActionButton({
@@ -838,7 +991,7 @@ ActionButton({
label: 'Apply',
successMsg: 'Applied',
errorType: 'error', // optional, defaults to 'error'
refresh: 'dnsmasq', // model name(s) to refresh
onSuccess: () => { /* optional, runs after the success toast */ },
cls: 'btn btn-outline', // optional
disabled: false,
})
@@ -850,7 +1003,6 @@ ActionButton({
labelOn: 'Disable',
labelOff: 'Enable',
condition: z.masquerade,
refresh: 'firewall',
})
```
@@ -866,13 +1018,14 @@ ActionButton({
| `condition` | Toggle condition for `labelOn`/`labelOff` |
| `successMsg` | Success toast message |
| `errorType` | Toast type for errors (default: `'error'`) |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
| `onSuccess` | Callback after the success toast. Wire it to `modelFetch()` for non-state models (e.g., `backends`). |
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
| `disabled` | Disabled state |
#### `ActionCell(props)`
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
Standardizes "action button + ConfirmDelete" in a table cell. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action.
```javascript
ActionCell({
@@ -881,8 +1034,8 @@ ActionCell({
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeRefresh: 'proxy',
removeLabel: 'Delete',
deleteKey: d.domain,
})
```
@@ -895,10 +1048,13 @@ ActionCell({
| `removeUrl` | API DELETE URL |
| `removeMessage` | Confirmation prompt text |
| `removeSuccess` | Success toast message |
| `removeRefresh` | Model name (`string`) or array of names (`string[]`) to refresh after delete |
| `removeRefresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
| `removeLabel` | Delete button label (default: `'Remove'`) |
| `removeBody` | Optional JSON body to send with DELETE |
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
| `busy` | When `true` the action button is disabled and shows `busyLabel` (use for in-flight operations). |
| `busyLabel` | Label shown while `busy` (default: `editLabel` + `'…'`) |
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
#### `certStatusBadge(props)`
@@ -1010,13 +1166,45 @@ Table({
url: '/api/item/' + enc(i.id),
message: 'Delete ' + esc(i.name) + '?',
success: 'Item removed',
refresh: 'firewall',
})),
)),
emptyText: 'No items',
})
```
### Apply / Cancel
`components/applyconfirm.js` — cross-subsystem apply/cancel buttons with a
shared expandable-subsystems modal. Both fetch `/api/status/pending` to
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
dnsmasq, nginx, wireguard, networkd).
#### `ApplyConfirm(props)`
Button that opens the confirmation modal listing pending subsystems, then
POSTs `/api/status/apply-all`. When `props.pending` is false it renders a
disabled "synced" button that toasts on click.
**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`,
`successMsg`, `refresh` (legacy, ignored).
#### `CancelConfirm(props)`
Button that opens the confirmation modal listing the subsystems that
would be reverted ("Restores the listed subsystems to their last applied
configuration, discarding changes saved since the last apply"), then
POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map; errors from the
response are toasted separately. State-store models update from the
daemon's WS delta — no explicit `modelFetch`.
**Parameters:** `label` (default `'Cancel All Changes'`), `cls`
(default `'btn btn-danger'`).
```javascript
CancelConfirm({ cls: 'btn btn-sm btn-danger' })
```
### Modal
#### `openModal(renderFn)`
@@ -1079,7 +1267,6 @@ const addZone = QuickModal({
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created', // or (data) => string
},
refresh: 'firewall', // model name(s) to refresh after success
});
// Usage in render:
@@ -1095,9 +1282,9 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
| `submit.url` | API URL or `(data) => string` |
| `submit.method` | HTTP method (default: `'POST'`) |
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
| `submit.validate` | `(body) => string | null`, validation function |
| `submit.validate` | `(body) => string \| null`, validation function |
| `submit.successMsg` | Success toast message or `(data) => string` |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
| `submitLabel` | Submit button label (default: `'Submit'`) |
@@ -1113,7 +1300,6 @@ const editIface = MultiSelectModal({
selected: zone.interfaces,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
refresh: 'firewall',
});
// Usage:
@@ -1130,7 +1316,7 @@ h('button', { 'on:click': editIface }, 'Edit')
| `selected` | Currently selected values (`string[]`) |
| `fieldKey` | JSON key for the submitted field |
| `successMsg` | Success toast message (default: `'Updated'`) |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
### Toast
@@ -1149,19 +1335,20 @@ Render the toast notification container. Include in the main render root. See AP
| `parseZones(data)` | Parse zone data from API responses into a flat string array |
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
## Versioned Imports
## Static Asset Caching
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=7`) to invalidate browser cache when the framework changes. Page imports also include version pins. The server handles caching headers; the version query string ensures browser cache invalidation.
The server handles caching headers for static assets. Browser cache invalidation is managed
through server-side cache-control headers rather than query string version pins.
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. The model layer handles fetching, loading states, error handling, and WS-driven refresh. Pages never call `apiFetch` in `load()`.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`.
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
- **Mutation refresh**: UI components use `refresh: 'model_name'` to trigger `modelFetch()` after API mutations. Accepts single name or array.
- **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`).
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
- **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
+23 -12
View File
@@ -6,7 +6,7 @@ Vacuum Wall is a zone-based firewall appliance with a built-in SSL reverse proxy
## Architecture Overview
Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx with basic HTTP authentication.
Vacuum Wall is built around five integrated subsystems managed through a two-layer architecture: a non-privileged Flask web UI and a privileged background daemon (`vacuum-walld`). The web UI communicates with the daemon via a Unix socket. The daemon handles all privileged operations (sudo) for the subsystems: the traffic plane uses firewalld with its nftables backend (zone-based policies, source NAT, destination NAT); the DNS/DHCP plane serves private subnets via dnsmasq; the network plane uses systemd-networkd for static IP management; the proxy plane runs nginx with automatic ACME certificates through acme.sh; and the VPN plane uses WireGuard (wg-quick) for encrypted tunnel management. All subsystems are configured and monitored through the Flask web UI, which is itself proxied through nginx (TLS termination only — management authentication is a Flask-layer JWT, not nginx basic auth; individual proxy domains may optionally configure their own basic auth).
## Subsystems
@@ -43,22 +43,24 @@ WireGuard support provides server-side VPN tunnel management. Peers are added th
## Quick Start
To install Vacuum Wall on a Debian 13 system, run `install.sh` as root with required settings (CLI flags or environment variables):
To install Vacuum Wall on a Debian 13 system, run `scripts/install.sh` as root with required settings (CLI flags or environment variables):
```bash
# Production
./install.sh --mgmt-pass yourpassword
./scripts/install.sh --mgmt-pass yourpassword
# Development (auto-detects your user)
./install.sh --dev --mgmt-pass yourpassword
./scripts/install.sh --dev --mgmt-pass yourpassword
```
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./install.sh --help` for all options.
After installation, access the management interface at `https://<hostname>.local` using the credentials you configured. The `scripts/install.sh` script auto-detects the system hostname, network interfaces, and provisions nginx, authentication, an initial self-signed certificate, and all services. Run `./scripts/install.sh --help` for all options.
## Project Structure
```
├── install.sh # Deployment script (renders Jinja2 templates)
├── scripts/ # Utility scripts
│ ├── install.sh # Deployment script (renders Jinja2 templates)
│ └── update-vendor.sh # Download vendored libraries (acme.sh, htm)
├── pyproject.toml # Project metadata + dependencies
├── .venv/ # Python virtual environment
├── config/ # Declarative JSON configuration (source of truth)
@@ -66,7 +68,8 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── network/ # systemd-networkd per-interface config
│ ├── firewall/ # Firewall zone & rule config
│ ├── nginx/ # Proxy domain & SSL config
── wireguard/ # VPN interface & peer config
── wireguard/ # VPN interface & peer config
│ └── acme/ # ACME account settings (email, CA provider)
├── data/ # Runtime artifacts & generated files
│ ├── nginx/sites-enabled/ # Generated server blocks
│ ├── dnsmasq/fragments/ # User config fragments
@@ -84,21 +87,25 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── systemd/ # Service and timer unit files
│ │ ├── vacuum-wall.service # Web UI service (rendered at install)
│ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install)
│ │ ── vacuum-wall-acme.timer # Renewal schedule
│ │ ── vacuum-wall-acme.timer # Renewal schedule
│ │ └── vacuum-walld.service # Privileged daemon (rendered at install)
│ ├── sudoers.d/ # Sudo whitelist (rendered at install)
│ ├── tmpfiles.d/ # tmpfiles.d spec (installed verbatim)
│ ├── nginx/ # Nginx config templates (rendered at runtime)
│ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime)
│ └── wireguard*.conf # WireGuard templates (rendered at runtime)
├── lib/ # Subsystem abstraction layer
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs)
│ ├── common.py # Shared utilities (run, run_proc, load_json, save_json, deep_merge, ensure_dirs, get_interface_ip)
│ ├── logging.py # Logging setup
│ ├── firewall.py # firewalld bindings
│ ├── network.py # systemd-networkd rendering & parsing
│ ├── dnsmasq.py # DHCP/DNS configuration
│ ├── nginx.py # Reverse proxy configuration
│ ├── state.py # State collector (uses lib.network.parse_networkctl_status)
│ ├── acme.py # Certificate management
── wireguard.py # VPN tunnel management
│ ├── sync.py # Cross-subsystem event bus
── acme.py # Certificate management (ACME helpers)
│ ├── wireguard.py # VPN tunnel and peer management
│ └── system_import.py # Startup reconciler (imports live system configs into JSON)
├── webui/ # Flask web application
│ ├── server.py # Application entry point
│ ├── api/ # REST API route modules (blueprints)
@@ -123,6 +130,9 @@ After installation, access the management interface at `https://<hostname>.local
│ │ ├── helpers.js
│ │ └── components/ # Layout, data display, modal, toast
│ └── pages/ # Page modules (each defines a route via definePage)
├── vendor/ # Vendored scripts and JS libraries
│ ├── acme.sh # ACME certificate client
│ └── htm.js # JS tagged-template HTML adapter
├── docs/ # Documentation
│ ├── overview.md # This file
│ ├── deployment.md
@@ -132,7 +142,8 @@ After installation, access the management interface at `https://<hostname>.local
│ ├── config.md
│ └── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts
── update-vendor.sh # Vendor frontend library updates
── install.sh # Deployment script (renders Jinja2 templates)
└── update-vendor.sh # Download vendored libraries (acme.sh, htm)
```
## Documentation
+68 -16
View File
@@ -7,10 +7,12 @@ Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI
- **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`.
- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path.
ACME certificate operations via `acme.sh` run as the WebUI user — not as root, and not as the daemon user. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports.
ACME certificate operations via `acme.sh` run as the daemon user — not as root. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh runs as the daemon process invoking it, using webroot validation that does not require binding to privileged ports.
This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.
Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token, sent as the raw `Sec-WebSocket-Protocol` subprotocol name (the legacy `Bearer <token>` subprotocol and an `X-Auth-Token` header fallback are also accepted), validated before the socket upgrades.
## Communication Between WebUI and Daemon
The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:<group>` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands.
@@ -24,25 +26,32 @@ The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) p
| Firewall | `firewall-cmd *` | All firewalld operations (zone management, rules, services, ports) |
| Nginx | `nginx -s reload` | Graceful nginx configuration reload |
| Nginx | `nginx -t` | Nginx configuration syntax validation |
| Nginx file ops | `cp -- * /etc/nginx/`, `/etc/nginx/conf.d/`, `/etc/nginx/snippets/` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of SSL snippet |
| Dnsmasq | `systemctl reload dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq file ops | `cp -- * /etc/dnsmasq.d/` | Copy rendered config files |
| Dnsmasq file ops | `tee /etc/dnsmasq.d/vacuum-wall.conf` | Write dnsmasq configuration |
| Nginx status | `systemctl is-active nginx` | Check nginx service status |
| Nginx file ops | `cp * /etc/nginx/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp * /etc/nginx/conf.d/*` | Copy rendered config files to system paths |
| Nginx file ops | `cp * /etc/nginx/snippets/*` | Copy rendered config files to system paths |
| Nginx file ops | `rm /etc/nginx/conf.d/vacuum-wall.conf`, `rm /etc/nginx/snippets/vacuum-wall-ssl.conf` | Clean up generated nginx config files |
| Nginx file ops | `chown root:root /etc/nginx/conf.d/vacuum-wall.conf`, `chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf` | Ensure correct ownership of nginx config files |
| Dnsmasq | `systemctl restart dnsmasq` | Apply updated dnsmasq configuration |
| Dnsmasq status | `systemctl is-active dnsmasq` | Check dnsmasq service status |
| Dnsmasq file ops | `mkdir -p /etc/dnsmasq.d` | Ensure target directory exists |
| Dnsmasq leases | `cat /var/lib/dnsmasq/dnsmasq.leases` | Read dnsmasq lease table |
| Dnsmasq file ops | `cp * /etc/dnsmasq.d/*` | Copy rendered config files |
| Dnsmasq leases | `cat /var/lib/misc/dnsmasq.leases` | Read dnsmasq lease table |
| WireGuard | `wg-quick *` | WireGuard tunnel lifecycle (up, down, save, show) |
| WireGuard | `wg *` | WireGuard status and peer management |
| WireGuard file ops | `cp -- * /etc/wireguard/` | Copy rendered config files |
| WireGuard file ops | `cp * /etc/wireguard/*` | Copy rendered config files |
| WireGuard file ops | `chown root:root /etc/wireguard/wg0.conf` | Ensure correct ownership of WG config |
| Certificates | (none) | acme.sh runs as the non-root service user directly; no sudo escalation is needed (webroot validation is used) |
| Certificates | (none) | acme.sh runs as the non-root daemon user directly; no sudo escalation is needed (webroot validation is used) |
| Network queries | `ip -o link show` | List network interfaces |
| Network queries | `ip -o addr show` | List IP addresses on interfaces |
| Network queries | `ip -o addr show *` | Query IP address for a specific interface (DHCP gateway auto-population) |
| Networkd | `networkctl status *` | Query interface status from networkd |
| Networkd | `networkctl reload *` | Reload networkd for a specific interface |
| Networkd | `networkctl reload` | Reload networkd for all interfaces |
| Networkd | `networkctl reconfigure *` | Reconfigure a specific interface |
| Networkd file ops | `cp * /etc/systemd/network/*` | Copy rendered network unit files |
| Networkd file ops | `rm /etc/systemd/network/*.network` | Remove stale network unit files |
| Networkd file ops | `mkdir -p /etc/systemd/network` | Ensure target directory exists |
| Sysctl | `sysctl -w *` | Set kernel parameters |
| Logs | `journalctl --unit=* -n *` | Query systemd journal for managed services |
| Logs | `cat /var/log/nginx/*` | Read nginx access and error logs |
@@ -61,9 +70,11 @@ The `daemon/client.py` module resolves `<param>` placeholders in URL paths befor
### Management Interface
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination and HTTP Basic Authentication. The `.htpasswd` file is stored at `data/nginx/.htpasswd`.
The Flask WebUI binds exclusively to `127.0.0.1:9090`. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level `auth_basic` is applied to the management domain.
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS). It relies on nginx basic authentication, SSL termination, and the systemd sandbox for its security boundary.
JWT tokens are stored in browser `sessionStorage` and injected as `Authorization: Bearer <token>` headers. The API **never** reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.
The management interface does not set security hardening headers (e.g., `X-Content-Type-Options`, `X-Frame-Options`, HSTS) on proxied responses, as the SPA requires flexibility for its operation. It relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.
### Proxy Domains
@@ -77,7 +88,46 @@ Every proxied domain configured in Vacuum Wall enforces:
- `X-XSS-Protection: 1; mode=block` — Enables browser XSS filtering.
- `Referrer-Policy: strict-origin-when-cross-origin` — Limits referrer information leakage.
Additional proxy headers (`extra_headers` in the domain config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
Additional proxy headers (`headers` in the path-level config) are delivered to the upstream backend via nginx `proxy_set_header` directives — they are not sent as response headers to clients.
### JWT Authentication Lifecycle
JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is:
1. **Login**: User submits credentials via `POST /api/auth/login`. The daemon verifies the password hash (Argon2id) against `data/auth.db`. On success, an access token (15 min) and refresh token (7 days) are issued.
2. **Validation**: Every request to Flask includes `Authorization: Bearer <token>`. The `before_request` middleware validates the token signature, checks expiry, queries the SQLite `token_blacklist` table, and verifies per-subsystem permissions.
3. **Auto-refresh**: Before the access token expires, the frontend's `refreshScheduler()` calls `POST /api/auth/refresh` with the refresh token. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page.
4. **Blacklist**: On logout (`POST /api/auth/logout`), password change, or user deletion, the affected token's `jti` is inserted into `token_blacklist`. On refresh rotation the old refresh token's `jti` is blacklisted and the new token replaces the stored row in `refresh_tokens`. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (default 60s) and by a probabilistic check inside `blacklist_token()`.
Token theft protection:
- Short-lived access tokens (15 min) limit the window of exploitation
- Token blacklist prevents reuse after logout or password change
- XSS mitigations: CSP headers, `X-XSS-Protection` header on management domain
**WebSocket session binding limitation**: WebSocket connections skip `session_id` validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the `Sec-WebSocket-Protocol` subprotocol name (a JWT is a valid RFC 6455 token; the `Bearer ` prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as `X-Auth-Token`). This means a stolen access token can be used to open WebSocket connections for the full 15-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.
### WebAuthn Security
WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties:
- **Credential binding**: Each credential is cryptographically bound to the specific `rp_id` (management domain) and `origin` (HTTPS URL). Credentials cannot be phished to a different domain.
- **Private key protection**: The private key never leaves the authenticator device. The server only stores the public key and signature counter in the `webauthn_creds` table.
- **Assertion verification**: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention).
- **RP configuration**: `rp_id` and `origin` are configurable per deployment in `config/auth/config.json`.
- **Fallback**: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out.
### Header-Only Authentication and CSRF
The API exclusively reads the `Authorization` header — never cookies. This architecture eliminates CSRF risk:
- Cross-site requests cannot set custom HTTP headers due to browser CORS restrictions
- No cookie-based session to exploit
- No SameSite, double-submit, or origin checking needed
**XSS as the primary attack surface**: With header-only auth, XSS is the primary attack vector since `sessionStorage` is accessible to page scripts. Mitigations include:
- CSP headers on the management domain (configured in nginx)
- `X-XSS-Protection` header
- Short-lived access tokens (15 min) with blacklist on logout
### TLS Configuration
@@ -95,7 +145,9 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com
| Directive | Value | Effect |
|---|---|---|
| `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths |
| `ReadWritePaths` | project dir, `/tmp`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable |
| `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`); (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it |
| `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop |
| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself) |
| `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace |
| `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` |
| `PrivateDevices` | `yes` | Hides all device files under `/dev` |
+185
View File
@@ -0,0 +1,185 @@
# State Model Reference
Authoritative reference for the shapes returned by the daemon's
pre-computed state store (`lib/state.py`), collected per subsystem and
pushed over the WebSocket (snapshot on connect, per-subsystem deltas
after every change).
Python schemas live in `lib/schema.py` (TypedDicts); each collector's
return annotation references them.
## Shared notes
- Every collector return carries a top-level `timestamp` (ISO-8601).
- Subsystems with a declarative config expose pending state as a status
dict: `status: {"pending_changes": bool}`, **except firewall**, which
uses `pending: {config_pending() result}`.
- A subsystem whose collection failed holds `null`/`None` in the state
store — WS snapshots and deltas skip `null` payloads so a failed
collector never overwrites good client data.
- Config-backed subsystems record their applied baseline inside the config
file itself: `_last_applied_config` (the full merged config at last
apply) and `_last_applied_hash` (its SHA-256). A hash subsystem's
`status.pending_changes` is true when the current (merged) config hash
differs from the recorded hash; `status.pending_diff` lists the field
changes since that snapshot. All apply operations (including firewall
`config_apply`) re-stamp the baseline. These bookkeeping keys are
internal and stripped from every state/API config payload. Canceling
pending changes (`POST /api/status/cancel-all`) restores a pending
config file from its snapshot; a subsystem with no recorded baseline
(never applied) is reported as skipped, not reset.
## State shape summary
`state_store.get(<subsystem>)` returns:
| Subsystem | Poll | Volatile fields | Top-level keys |
|---|---|---|---|
| `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `interfaces`, `available_services`, `zones`, `rich_rules`, `pending`, `timestamp` |
| `dnsmasq` | 10s | *(none)* | `config`, `status`, `leases`, `timestamp` |
| `nginx` | 60s | *(none)* | `config`, `domains`, `status`, `timestamp` |
| `acme` | 300s | *(none)* | `certs`, `email`, `account`, `timestamp` |
| `wireguard` | 10s | `status.peers[].transfer_received`/`.transfer_sent`/`.latest_handshake` and the same three under `status.classes[].peers[]` | `config`, `status`, `peers`, `timestamp` |
| `networkd` | 10s | `interfaces[].addresses` | `config`, `interfaces`, `status`, `timestamp` |
| `system` | 1s | `load`, `memory`, `swap`, `traffic` | `load`, `memory`, `swap`, `traffic`, `timestamp` |
Poll intervals are overridable via `VACUUM_WALL_POLL_INTERVALS`
(`subsystem:seconds,subsystem:seconds`).
## Firewall
Top-level `FirewallState`:
```
{
config: {}, // config/firewall/config.json
active_zones: {zone: [iface]}, // zones with assigned interfaces
interfaces: [ // ip link/addr parsing
{name, mac, state, mtu, ips, ipv6, zone}
],
available_services: [str], // firewall-cmd --get-services
zones: {zone: zoneDict}, // --list-all-zones; hyphenated keys,
// may carry "sources", "ports",
// "protocols", "forward-ports", "ics",
// "icmp-blocks", "module", "rich-rules"
rich_rules: {zone: [str]}, // raw firewalld rich-rule strings,
// re-derived from zones (NO ids —
// deletion-by-id uses config.zones[].rich_rules)
pending: { // config_pending() (lib/firewall.py)
pending: [...], needs_apply: bool,
unmanaged_zones: {zone: {interfaces: [...]}}
},
timestamp: str,
}
```
Notes:
- `interfaces[].ips` / `interfaces[].ipv6` hold `"ip/prefix"` strings
(IPv6 list is separate).
- The zone dict's rich-rules key is HYPHENATED (`"rich-rules"`);
`state.rich_rules` is the snake_case top-level re-derivation.
## Dnsmasq
```
{
config: {}, // config/dnsmasq/config.json, deep-merged
status: {
service_active: bool, config_file_exists: bool,
active_leases: int, pending_changes: bool
},
leases: [
{expires, mac, ip, hostname, interface} // expires = ISO-8601 or ""
],
timestamp: str,
}
```
## Nginx
```
{
config: {}, // config/nginx/config.json
domains: [ // flattened: one entry per domain+path
{domain, path, backend, online, force_ssl, backend_name, cert,
[is_management], [is_websocket]}
],
status: {pending_changes: bool},
timestamp: str,
}
```
## ACME
```
{
certs: [ // list_certs(); extra keys possible
{domain, expiry, renewed, status, days_remaining, ...}
],
email: str,
account: {registered, email, ca, key_length},
timestamp: str,
}
```
## WireGuard
```
{
config: {}, // private_key stripped from interface
// AND every access class
status: {
up: bool, // true when ANY managed iface is up
interface: {}, peers: [], // legacy single interface (wg0)
classes: {class: {up, interface, peers}}, // per wg-<class>
pending_changes: bool
},
peers: [ // config peers, private keys stripped
{name, public_key, endpoint, allowed_ips,
persistent_keepalive, preshared_key, ...}
],
timestamp: str,
}
```
Runtime peers (`status.peers[]`, `status.classes[].peers[]`) carry:
`public_key`, `endpoint`, `allowed_ips`, `latest_handshake`,
`transfer_received`, `transfer_sent`, `persistent_keepalive`.
## Networkd
Matches `parse_networkctl_status()` output (lib/network.py) exactly:
```
{
config: {}, // config/network/config.json
interfaces: {iface: { // flat runtime entry per interface;
addresses: ["ip/prefix"], // a single combined addresses list
gateway, dns: [str], mac, // (no ipv6_addresses/routes keys)
state, link}
},
status: {pending_changes: bool},
timestamp: str,
}
```
The parser does not filter `lo`; clients that don't want it filter
client-side.
## System
Metrics only — no config, no pending state.
```
{
load: {load1, load5, load15},
memory: {total, available, used, used_pct}, // bytes; 0-100
swap: {total, used, used_pct}, // bytes; 0-100
traffic: {iface: {rx_bytes, tx_bytes,
rx_packets, tx_packets}},
timestamp: str,
}
```
All four metric fields are volatile (1s tick cadence); structural diffs
only fire on interface-set changes.
+55 -4
View File
@@ -248,7 +248,7 @@ def list_certs() -> list[dict]:
A list of dicts, one per certificate, with keys matching
the cert-info schema (domain, ca, cert_path, etc.).
"""
raw = _run_acme(["--list"])
raw = _run_acme(["--list", "--listraw"])
certs: list[dict] = []
entries = _parse_list_output(raw)
@@ -472,7 +472,7 @@ def deploy(domain: str) -> None:
def _split_line(line: str, separator: str | None) -> list[str]:
"""Split a line by *separator*, falling back to whitespace for column output."""
if separator in line:
if separator is not None and separator in line:
return line.split(separator)
return line.split()
@@ -503,8 +503,8 @@ def _parse_list_output(raw: str) -> list[dict]:
headers = _split_line(header_line, "\t")
separator = "\t"
else:
headers = _split_line(header_line, None) # whitespace
separator = None
# Column-aligned: use position-based parsing via helper
return _parse_column_aligned(header_line, lines[1:])
if "Main_Domain" not in headers:
raise ValueError(
@@ -526,6 +526,57 @@ def _parse_list_output(raw: str) -> list[dict]:
return entries
def _find_header_positions(header_line: str):
"""Find start positions of each header word in a column-aligned header."""
names: list[str] = []
starts: list[int] = []
i = 0
while i < len(header_line):
while i < len(header_line) and header_line[i] == " ":
i += 1
j = i
while j < len(header_line) and header_line[j] != " ":
j += 1
if j > i:
names.append(header_line[i:j])
starts.append(i)
i = j
return names, starts
def _parse_column_aligned(header_line: str, data_lines: list[str]) -> list[dict]:
"""Parse column-aligned output using header positions to locate fields.
Unlike simple whitespace splitting, this preserves empty fields by using
character positions rather than token counts. Empty columns (e.g. missing
Profile or SAN_Domains) are correctly handled.
"""
names, starts = _find_header_positions(header_line)
if "Main_Domain" not in names:
raise ValueError(
f"acme.sh --list output is not in expected format: {header_line!r}"
)
# Column ends: midpoint before next header starts (or end of line for last)
ends: list[int] = len(starts) * [len(header_line)]
for i in range(len(starts) - 1):
ends[i] = (starts[i] + starts[i + 1]) // 2
entries: list[dict] = []
for line in data_lines:
line = line.rstrip()
if not line.strip():
continue
entry: dict[str, str] = {}
for k, name in enumerate(names):
s, e = starts[k], ends[k]
cell = line[s:e] if len(line) > s else ""
entry[name.lower()] = cell.strip().strip('"')
entries.append(entry)
return entries
def _days_until(date_str: str) -> int | None:
"""Parse an ISO date string and return days until that date from now."""
if not date_str:
+467
View File
@@ -0,0 +1,467 @@
"""JWT authentication module for Vacuum Wall.
Handles token creation, validation, refresh, and blacklisting.
Each user has their own JWT signing secret stored in the database.
Configuration comes from config/auth/config.json.
"""
from __future__ import annotations
import base64
import json
import logging
import random
import secrets
import time
import uuid
from pathlib import Path
from typing import Any
import jwt
from lib.common import load_json
from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_REFRESH_TOKEN,
Q_INSERT_BLACKLIST,
Q_SELECT_BLACKLIST,
Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_JWT_SECRET,
Q_UPDATE_JWT_SECRET,
Q_UPSERT_REFRESH_TOKEN,
get_db,
)
logger = logging.getLogger(__name__)
AUTH_CONFIG_PATH = (
Path(__file__).resolve().parent.parent / "config" / "auth" / "config.json"
)
_DEFAULT_JWT_CONFIG = {
"access_token_ttl": 900,
"refresh_token_ttl": 604800,
"algorithm": "HS256",
}
def _get_jwt_config() -> dict[str, Any]:
"""Load JWT configuration from auth config."""
raw = load_json(AUTH_CONFIG_PATH)
return raw.get("jwt", _DEFAULT_JWT_CONFIG)
def get_user_jwt_secret(username: str) -> str | None:
"""Return the JWT signing secret for *username*, or ``None`` if not found."""
db = get_db()
rows = db.query(Q_SELECT_USER_JWT_SECRET, (username,))
if not rows:
return None
return rows[0]["jwt_secret"]
def rotate_user_secret(username: str) -> None:
"""Rotate the JWT secret for *username*, invalidating all their existing tokens.
Used when a user's password is changed to ensure all prior sessions
are immediately terminated regardless of token expiration.
"""
new_secret = secrets.token_urlsafe(32)
db = get_db()
db.run(Q_UPDATE_JWT_SECRET, (new_secret, username))
logger.warning(
"JWT secret rotated for %r — their existing tokens are now invalid", username
)
def get_access_ttl() -> int:
"""Return access token TTL in seconds."""
return _get_jwt_config().get("access_token_ttl", 900)
def get_refresh_ttl() -> int:
"""Return refresh token TTL in seconds."""
return _get_jwt_config().get("refresh_token_ttl", 604800)
def get_algorithm() -> str:
"""Return the JWT algorithm."""
return _get_jwt_config().get("algorithm", "HS256")
def generate_access_token(
username: str,
permissions: dict[str, str],
session_id: str | None = None,
) -> str:
"""Generate a new access token for *username*.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
session_id: Optional session binding ID. Included in the token payload
so the Flask middleware can tie the token to the browser session
that created it.
Returns:
JWT token string.
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_user_jwt_secret(username)
if not secret:
raise RuntimeError(f"JWT secret not configured for user {username!r}")
algorithm = get_algorithm()
now = int(time.time())
payload = {
"sub": username,
"exp": now + get_access_ttl(),
"iat": now,
"jti": str(uuid.uuid4()),
"type": "access",
"permissions": permissions,
"session_id": session_id or secrets.token_urlsafe(16),
}
return jwt.encode(payload, secret, algorithm=algorithm)
def generate_refresh_token(username: str, session_id: str | None = None) -> str:
"""Generate a new refresh token for *username*.
Args:
username: The authenticated username.
session_id: Session binding ID included in the token payload.
When present, the refresh endpoint requires a matching session_id,
preventing a stolen refresh token from being usable without the
originating browser session.
Returns:
JWT refresh token string.
Raises:
RuntimeError: If JWT secret is not configured.
"""
secret = get_user_jwt_secret(username)
if not secret:
raise RuntimeError(f"JWT secret not configured for user {username!r}")
algorithm = get_algorithm()
now = int(time.time())
payload = {
"sub": username,
"exp": now + get_refresh_ttl(),
"iat": now,
"jti": str(uuid.uuid4()),
"type": "refresh",
}
if session_id:
payload["session_id"] = session_id
return jwt.encode(payload, secret, algorithm=algorithm)
def generate_tokens(username: str, permissions: dict[str, str]) -> dict[str, str]:
"""Generate both access and refresh tokens.
Args:
username: The authenticated username.
permissions: Dict mapping subsystem names to permission levels.
Returns:
Dict with ``access_token`` and ``refresh_token`` keys.
"""
session_id = secrets.token_urlsafe(16)
access_token = generate_access_token(username, permissions, session_id)
refresh_token = generate_refresh_token(username, session_id)
_persist_refresh_token(username, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"session_id": session_id,
}
def _persist_refresh_token(username: str, refresh_token: str) -> None:
"""Persist the active refresh token JTI for *username* in the database.
One row per user replaces any existing entry on upsert.
Args:
username: The username.
refresh_token: The JWT refresh token string.
"""
payload = decode_token(refresh_token)
if payload is None:
return
jti = payload.get("jti")
if not jti:
return
issued_at = payload.get("iat", int(time.time()))
db = get_db()
db.run(Q_UPSERT_REFRESH_TOKEN, (username, jti, issued_at))
def blacklist_active_refresh_token(username: str) -> None:
"""Blacklist the user's currently active refresh token from the database.
Looks up the stored JTI for *username*, blacklists it, and removes the
database entry. Safe to call when no token is registered the query
will simply return no rows.
Args:
username: The username.
"""
db = get_db()
rows = db.query(Q_SELECT_REFRESH_TOKEN, (username,))
if rows:
blacklist_token(rows[0]["jti"], token_type="refresh")
db.run(Q_DELETE_REFRESH_TOKEN, (username,))
def _extract_unverified_sub(token_string: str) -> str | None:
"""Extract the ``sub`` claim from a JWT payload without signature verification.
The JWT payload is the second segment (dot-separated), base64url-encoded JSON.
This is safe because we are NOT trusting the claim value we use it solely
to look up the user's secret for proper verification.
Args:
token_string: The JWT token string.
Returns:
The ``sub`` claim value, or ``None`` if the token is malformed.
"""
try:
parts = token_string.split(".")
if len(parts) != 3:
return None
payload_b64 = parts[1]
# Add padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload_json = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_json)
if not isinstance(payload, dict):
return None
return payload.get("sub")
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
def decode_token(token_string: str) -> dict[str, Any] | None:
"""Decode and validate a JWT token using the user's secret.
Extracts the ``sub`` claim from the unverified payload to look up the
correct per-user signing secret, then verifies the signature.
Args:
token_string: The JWT token string (without Bearer prefix).
Returns:
Payload dict if valid, None if invalid/expired or user not found.
"""
sub = _extract_unverified_sub(token_string)
if not sub:
return None
secret = get_user_jwt_secret(sub)
if not secret:
return None
algorithm = get_algorithm()
try:
payload = jwt.decode(token_string, secret, algorithms=[algorithm])
return payload
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
def validate_token(
token_string: str,
token_type: str = "access",
session_id: str | None = None,
require_session: bool = False,
) -> dict[str, Any] | None:
"""Validate a JWT token and check it against the blacklist.
Args:
token_string: The JWT token string.
token_type: Expected token type ("access" or "refresh").
session_id: Must match the ``session_id`` claim in the token payload.
When provided, enforces session binding to prevent a stolen token
from being usable without the originating session. When ``None``,
the check is skipped unless ``require_session`` is True.
require_session: If True and the token payload contains a ``session_id``,
the request must provide a matching ``session_id``. Used by the
refresh handler to prevent session binding bypass. When False
(default), omitting ``session_id`` is acceptable even if the token
contains one.
Returns:
Payload dict including permissions, or None if invalid/blacklisted.
"""
payload = decode_token(token_string)
if payload is None:
return None
if payload.get("type") != token_type:
return None
token_session_id = payload.get("session_id")
if require_session and token_session_id is not None:
if session_id is None or session_id != token_session_id:
return None
elif session_id is not None and session_id != token_session_id:
return None
jti = payload.get("jti")
if jti and is_blacklisted(jti):
return None
return payload
def blacklist_token(jti: str, token_type: str = "access") -> None:
"""Add a JTI to the blacklist.
Args:
jti: Token UUID to blacklist.
token_type: Token type ("access" or "refresh"). Defaults to "access".
The blacklist expiry is set to current time + the token type's TTL.
"""
db = get_db()
ttl = get_refresh_ttl() if token_type == "refresh" else get_access_ttl()
db.run(Q_INSERT_BLACKLIST, (jti, token_type, int(time.time()) + ttl))
if random.random() < 0.02:
blacklist_expired()
def is_blacklisted(jti: str) -> bool:
"""Check if a JTI is blacklisted.
Args:
jti: Token UUID to check.
Returns:
True if the token has been blacklisted.
"""
db = get_db()
rows = db.query(Q_SELECT_BLACKLIST, (jti,))
return len(rows) > 0
def blacklist_expired() -> None:
"""Remove expired entries from the blacklist."""
db = get_db()
now = int(time.time())
db.run(Q_DELETE_EXPIRED_BLACKLIST, (now,))
class RateLimiter:
"""Sliding-window rate limiter that tracks successes and failures separately.
Failures are counted against the limit. A successful operation resets
the failure counter for that key.
"""
def __init__(self, max_attempts: int = 5, window_seconds: int = 300) -> None:
self.max_attempts = max_attempts
self.window = window_seconds
self.failures: dict[str, list[float]] = {}
def is_allowed(self, key: str) -> bool:
"""Check if a request from *key* is allowed (does NOT record the attempt).
Args:
key: Identifier for the rate limit bucket (e.g., username or IP).
Returns:
True if the request is allowed, False if rate limited.
"""
now = time.time()
cutoff = now - self.window
timestamps = self.failures.get(key, [])
clean = [t for t in timestamps if t > cutoff]
return len(clean) < self.max_attempts
def record_failure(self, key: str) -> None:
"""Record a failed attempt for *key*."""
self.failures.setdefault(key, []).append(time.time())
def record_success(self, key: str) -> None:
"""Reset the failure counter for *key* on a successful operation."""
self.failures.pop(key, None)
def cleanup(self) -> None:
"""Remove expired entries from all buckets."""
now = time.time()
cutoff = now - self.window
for key in list(self.failures):
self.failures[key] = [t for t in self.failures[key] if t > cutoff]
if not self.failures[key]:
del self.failures[key]
# Global rate limiters — in-memory only. Counts reset on daemon restart
# (SIGHUP reload, process restart). Acceptable for a single-user appliance
# where restarts are rare; brute-force windows briefly reset post-restart.
_login_limiter = RateLimiter(max_attempts=10, window_seconds=300)
_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
def check_login_rate(username: str, client_ip: str | None = None) -> bool:
"""Check if login is rate-limited for the given username.
Checks failure counts for both IP and username buckets without
recording anything. Callers must invoke record_login_failure() or
record_login_success() after the password verification step.
Args:
username: The login attempt username.
client_ip: The client IP address (from X-Real-IP header).
Returns:
True if the attempt is allowed, False if rate limited.
"""
if client_ip and not _login_limiter.is_allowed(client_ip):
return False
return _login_limiter.is_allowed(username)
def record_login_failure(username: str, client_ip: str | None = None) -> None:
"""Record a failed login attempt."""
if client_ip:
_login_limiter.record_failure(client_ip)
_login_limiter.record_failure(username)
def record_login_success(username: str, client_ip: str | None = None) -> None:
"""Record a successful login (resets failure counter)."""
if client_ip:
_login_limiter.record_success(client_ip)
_login_limiter.record_success(username)
def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool:
"""Check if WebAuthn authentication is rate-limited for the given username.
Args:
username: The WebAuthn attempt username.
client_ip: The client IP address (from X-Real-IP header).
Returns:
True if the attempt is allowed, False if rate limited.
"""
if client_ip and not _webauthn_limiter.is_allowed(client_ip):
return False
return _webauthn_limiter.is_allowed(username)
def record_webauthn_failure(username: str, client_ip: str | None = None) -> None:
"""Record a failed WebAuthn attempt."""
if client_ip:
_webauthn_limiter.record_failure(client_ip)
_webauthn_limiter.record_failure(username)
def record_webauthn_success(username: str, client_ip: str | None = None) -> None:
"""Record a successful WebAuthn attempt (resets failure counter)."""
if client_ip:
_webauthn_limiter.record_success(client_ip)
_webauthn_limiter.record_success(username)
+324
View File
@@ -0,0 +1,324 @@
"""User CRUD and permission management for Vacuum Wall authentication.
All operations use DB query IDs through the Database abstract layer.
Password hashing uses Argon2id via lib.password.
"""
from __future__ import annotations
import logging
import re
import secrets
from typing import Any
from lib.auth import (
blacklist_active_refresh_token,
rotate_user_secret,
)
from lib.db import (
Q_DELETE_PERMISSION_SUBSYSTEM,
Q_DELETE_USER,
Q_INSERT_USER,
Q_SELECT_PERMISSIONS,
Q_SELECT_USER_BY_NAME,
Q_SELECT_USERS_WITH_PERMS,
Q_UPDATE_PASSWORD,
Q_UPSERT_PERMISSION,
get_db,
)
from lib.password import hash_password, needs_rehash, verify_password
logger = logging.getLogger(__name__)
# Pre-computed dummy hash for constant-time verification on nonexistent users.
# Generated once at module load to avoid timing leaks from per-call hash generation.
_DUMMY_HASH = hash_password(secrets.token_hex(32))
# Builtin admin — hardcoded, full access, cannot be modified/deleted
BUILTIN_ADMIN_USERNAME = "admin"
# All subsystem names for default permission assignment
ALL_SUBSYSTEMS = [
"firewall",
"network",
"dhcp",
"proxy",
"certs",
"wireguard",
"logs",
"status",
"auth",
]
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{3,32}$")
def _get_permissions(username: str) -> dict[str, str]:
"""Load permissions for *username* from the database.
Args:
username: The username to load permissions for.
Returns:
Dict mapping subsystem names to permission levels.
"""
db = get_db()
rows = db.query(Q_SELECT_PERMISSIONS, (username,))
return {row["subsystem"]: row["level"] for row in rows}
def get_user(username: str) -> dict[str, Any] | None:
"""Get a user by username (without password hash or JWT secret).
Args:
username: The username to look up.
Returns:
User dict with id, username, permissions, or None if not found.
"""
user = find_user(username)
if user is None:
return None
return {
"id": user["id"],
"username": user["username"],
"permissions": _get_permissions(username),
}
def find_user(username: str) -> dict[str, Any] | None:
"""Find a user by username, including password hash and JWT secret.
Used for password verification and token operations. Not returned through APIs.
Args:
username: The username to look up.
Returns:
User dict from the database row, or None.
"""
db = get_db()
rows = db.query(Q_SELECT_USER_BY_NAME, (username,))
if not rows:
return None
return rows[0]
def verify_user_password(username: str, password: str) -> dict[str, Any] | None:
"""Verify a user's password using Argon2id.
Args:
username: The username to verify.
password: Plain-text password.
Returns:
User dict (without password hash) if the password is correct, None otherwise.
"""
user = find_user(username)
if user is None:
# Run a dummy Argon2id verification against a pre-computed hash to
# prevent timing-based user enumeration. Uses module-level hash
# so both paths take ~1 verify call (~200ms) instead of ~400ms.
verify_password(password, _DUMMY_HASH)
return None
if not verify_password(password, user["password_hash"]):
return None
if needs_rehash(user["password_hash"]):
new_hash = hash_password(password)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
logger.info("Password hash rehashed for %r (param upgrade)", username)
return {
"id": user["id"],
"username": user["username"],
"permissions": _get_permissions(username),
}
def create_user(
username: str,
password: str,
permissions: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create a new user with the given password and permissions.
Args:
username: The new username (3-32 alphanumeric chars, dash, underscore).
password: Plain-text password that will be hashed with Argon2id.
permissions: Dict mapping subsystem names to "read" or "rw".
Returns:
The created user dict with id, username, permissions.
Raises:
ValueError: If username is invalid or already exists.
"""
if not _USERNAME_RE.match(username):
raise ValueError(
"Username must be 3-32 characters: letters, digits, dash, underscore"
)
existing = find_user(username)
if existing is not None:
raise ValueError(f"User {username!r} already exists")
password_hash = hash_password(password)
jwt_secret = secrets.token_urlsafe(32)
db = get_db()
with db.in_transaction() as tx:
user_id = tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret))
if permissions:
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
return {
"id": user_id,
"username": username,
"permissions": _get_permissions(username),
}
def update_password(username: str, old_password: str, new_password: str) -> bool:
"""Update a user's password and invalidate all active tokens.
Rotates the user's JWT secret, immediately invalidating all existing
access and refresh tokens.
Args:
username: The username.
old_password: Current password.
new_password: New plain-text password.
Returns:
True if password was updated.
Raises:
ValueError: If old password is incorrect.
"""
if not verify_user_password(username, old_password):
raise ValueError("Current password is incorrect")
blacklist_active_refresh_token(username)
new_hash = hash_password(new_password)
rotate_user_secret(username)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
return True
def reset_password(username: str, new_password: str) -> None:
"""Force-reset a user's password without verifying the old one.
Non-interactive variant for install-time and lockout recovery: the
installer does not know the previous password by construction. Rotates
the user's JWT secret and blacklists the active refresh token,
invalidating all existing sessions.
Args:
username: The user to reset.
new_password: New plain-text password.
Raises:
ValueError: If the user does not exist.
"""
if find_user(username) is None:
raise ValueError(f"User {username!r} not found")
blacklist_active_refresh_token(username)
new_hash = hash_password(new_password)
rotate_user_secret(username)
db = get_db()
db.run(Q_UPDATE_PASSWORD, (new_hash, username))
def update_permissions(username: str, permissions: dict[str, str]) -> None:
"""Update a user's permissions and invalidate all existing tokens.
Replaces all existing permissions with the provided mapping. Rotates the
JWT secret so that permission changes take effect immediately existing
tokens with stale permissions are no longer valid.
Args:
username: The username.
permissions: Dict mapping subsystem names to permission levels.
Raises:
ValueError: If attempting to modify builtin admin.
"""
if username == BUILTIN_ADMIN_USERNAME:
raise ValueError("Cannot modify permissions for builtin admin")
user = find_user(username)
if user is None:
raise ValueError(f"User {username!r} not found")
db = get_db()
with db.in_transaction() as tx:
# Upsert all new permissions first, then remove stale ones.
# This order ensures that if an upsert fails mid-loop, the user's
# permissions remain intact (transaction rolls back) rather than
# being permanently wiped.
existing = {
row["subsystem"]: row["level"]
for row in db.query(Q_SELECT_PERMISSIONS, (username,))
}
for subsystem, level in permissions.items():
tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level))
for subsystem in existing:
if subsystem not in permissions:
tx.run(Q_DELETE_PERMISSION_SUBSYSTEM, (username, subsystem))
blacklist_active_refresh_token(username)
rotate_user_secret(username)
def list_users() -> list[dict[str, Any]]:
"""List all users with their permissions.
Returns:
List of user summary dicts.
"""
db = get_db()
rows = db.query(Q_SELECT_USERS_WITH_PERMS, ())
users: dict[int, dict[str, Any]] = {}
for row in rows:
uid = row["id"]
if uid not in users:
users[uid] = {
"id": uid,
"username": row["username"],
"permissions": {},
"created_at": row["created_at"],
}
if row["subsystem"] is not None:
users[uid]["permissions"][row["subsystem"]] = row["level"]
return list(users.values())
def delete_user(username: str) -> bool:
"""Delete a user and all their associated data.
Permissions and WebAuthn credentials are CASCADE-deleted by the schema.
Args:
username: The username to delete.
Returns:
True if the user was deleted.
Raises:
ValueError: If the user does not exist or is the builtin admin.
"""
if username == BUILTIN_ADMIN_USERNAME:
raise ValueError("Cannot delete builtin admin user")
user = find_user(username)
if user is None:
raise ValueError(f"User {username!r} not found")
blacklist_active_refresh_token(username)
db = get_db()
db.run(Q_DELETE_USER, (username,))
return True
+153
View File
@@ -4,6 +4,7 @@ Provides common helpers for JSON persistence, subprocess execution,
deep merging, and directory creation used across all subsystem modules.
"""
import hashlib
import json
import os
import re
@@ -12,6 +13,112 @@ from copy import deepcopy
from pathlib import Path
from typing import Any
from passlib.hash import sha256_crypt
_APPLY_HASH_KEY = "_last_applied_hash"
_LAST_APPLIED_CONFIG_KEY = "_last_applied_config"
_APPLY_META_KEYS = (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY)
def strip_apply_meta(cfg: dict[str, Any]) -> dict[str, Any]:
"""Return *cfg* without any apply bookkeeping keys.
Strips both the last-applied hash and the last-applied config snapshot
so the returned dict reflects only real configuration.
"""
return {k: v for k, v in cfg.items() if k not in _APPLY_META_KEYS}
def config_hash(cfg: dict[str, Any]) -> str:
"""Compute a SHA-256 hash of *cfg*, ignoring apply bookkeeping keys.
Args:
cfg: Config dict, possibly containing ``_last_applied_hash`` and
``_last_applied_config``.
Returns:
Hex digest of the stripped config JSON.
"""
clean = strip_apply_meta(cfg)
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
"""Record that *cfg* is the last applied configuration.
Writes both the content snapshot (``_last_applied_config``) and its hash
(``_last_applied_hash``) so a later pending check can detect drift and a
diff can report exactly which fields changed.
"""
cfg[_APPLY_HASH_KEY] = config_hash(cfg)
cfg[_LAST_APPLIED_CONFIG_KEY] = strip_apply_meta(cfg)
return cfg
def revert_to_applied(path: Path) -> tuple[bool, str]:
"""Restore the config file at *path* to its last-applied snapshot.
Reads the raw file, and when it records a ``_last_applied_config``
snapshot, rewrites the file from that snapshot (stamped with a fresh
hash so the pending check reports the config as up to date).
Args:
path: Path to the config JSON file to revert.
Returns:
Tuple ``(True, "")`` when the file was restored, or
``(False, reason)`` when it could not be (missing file or no
recorded baseline i.e. the config was never applied).
"""
cfg = load_json(path)
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if not isinstance(snap, dict):
return False, "No baseline recorded (never applied)"
save_json(path, stamp_applied(deepcopy(snap)))
return True, ""
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
"""Return a list of field-level changes between two configurations.
Each entry is ``{"path", "action", "old", "new"}`` where *action* is one
of ``"added"``, ``"removed"`` or ``"changed"``. Dicts are recursed with
dotted paths; lists of equal length are compared element-by-element,
while any other value that differs is reported as a single change.
Apply bookkeeping keys are ignored.
"""
if isinstance(old, dict):
old = strip_apply_meta(old)
if isinstance(new, dict):
new = strip_apply_meta(new)
out: list[dict[str, Any]] = []
_diff_nodes(old, new, prefix, out)
return out
def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> None:
"""Recursively collect field-level changes from *old* into *new*."""
if isinstance(old, dict) and isinstance(new, dict):
for key in sorted(set(old) | set(new)):
child = f"{path}.{key}" if path else str(key)
if key in old and key in new:
_diff_nodes(old[key], new[key], child, out)
elif key in old:
out.append(
{"path": child, "action": "removed", "old": old[key], "new": None}
)
else:
out.append(
{"path": child, "action": "added", "old": None, "new": new[key]}
)
return
if isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
for i, (o, n) in enumerate(zip(old, new, strict=True)):
_diff_nodes(o, n, f"{path}[{i}]", out)
return
if old != new:
out.append({"path": path, "action": "changed", "old": old, "new": new})
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
@@ -64,6 +171,7 @@ def run(
full_cmd,
capture_output=True,
text=True,
shell=False,
check=check,
timeout=timeout,
)
@@ -99,6 +207,7 @@ def run_proc(
full_cmd,
capture_output=True,
text=True,
shell=False,
check=check,
timeout=timeout,
input=input,
@@ -153,12 +262,56 @@ def ensure_dirs(*dirs: Path) -> None:
d.mkdir(parents=True, exist_ok=True)
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
Used for nginx htpasswd files. NOT used for auth user passwords
those use Argon2id via ``lib.password``.
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd``
(e.g. ``$5$rounds=$``).
"""
return sha256_crypt.hash(password)
def get_interface_ip(iface: str) -> str | None:
"""Return the primary IPv4 address of *iface* (without CIDR), or ``None``.
Uses ``ip -o addr show`` which is in the daemon sudo whitelist.
"""
if not iface:
return None
try:
raw = run(["ip", "-o", "addr", "show", iface], sudo=True)
for line in raw.splitlines():
parts = line.split()
# -o format: "NUM: IFACE inet/6 ADDR/MASK ..."
if len(parts) >= 4 and parts[2] == "inet":
return parts[3].split("/", 1)[0]
except Exception:
pass
return None
__all__ = [
"_APPLY_HASH_KEY",
"_LAST_APPLIED_CONFIG_KEY",
"_hash_password",
"config_hash",
"deep_diff",
"deep_merge",
"ensure_dirs",
"get_interface_ip",
"load_json",
"revert_to_applied",
"run",
"run_proc",
"save_json",
"stamp_applied",
"strip_apply_meta",
"validate_interface_name",
]
+366
View File
@@ -0,0 +1,366 @@
"""Abstract database layer for Vacuum Wall.
Provides query ID constants and an abstract Database baseclass so subsystems
interact with the database through opaque query identifiers, never raw SQL.
Backend implementations (SQLite, PostgreSQL) provide the actual SQL.
Usage:
from lib.db import Q_INSERT_USER, Database, get_db
class SQLiteBackend(Database):
QUERY_MAP = {
Q_INSERT_USER: "INSERT INTO users (username, password_hash) VALUES (?, ?)",
...
}
"""
from __future__ import annotations
import logging
import os
import secrets
import threading
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, ClassVar
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Query ID constants — single source of truth for all database operations
# ---------------------------------------------------------------------------
Q_INIT_TABLES = "init_tables"
Q_INSERT_USER = "insert_user"
Q_SELECT_USER_BY_NAME = "select_user_by_name"
Q_SELECT_USER_BY_ID = "select_user_by_id"
Q_UPDATE_PASSWORD = "update_password"
Q_DELETE_USER = "delete_user"
Q_UPSERT_PERMISSION = "upsert_permission"
Q_SELECT_PERMISSIONS = "select_permissions"
Q_DELETE_PERMISSIONS = "delete_permissions"
Q_DELETE_PERMISSION_SUBSYSTEM = "delete_permission_subsystem"
Q_INSERT_BLACKLIST = "insert_blacklist"
Q_SELECT_BLACKLIST = "select_blacklist_jti"
Q_DELETE_EXPIRED_BLACKLIST = "delete_expired_blacklist"
Q_UPSERT_REFRESH_TOKEN = "upsert_refresh_token"
Q_SELECT_REFRESH_TOKEN = "select_refresh_token"
Q_DELETE_REFRESH_TOKEN = "delete_refresh_token"
Q_INSERT_WEBAUTHN = "insert_webauthn"
Q_SELECT_WEBAUTHN_USER = "select_webauthn_user"
Q_SELECT_WEBAUTHN_ID = "select_webauthn_id"
Q_SELECT_WEBAUTHN_COUNTS = "select_webauthn_counts"
Q_DELETE_WEBAUTHN = "delete_webauthn"
Q_UPDATE_WEBAUTHN_SIGN_COUNT = "update_webauthn_sign_count"
Q_SELECT_ALL_USERS = "select_all_users"
Q_SELECT_USERS_WITH_PERMS = "select_users_with_perms"
Q_SELECT_USER_JWT_SECRET = "select_user_jwt_secret"
Q_UPDATE_JWT_SECRET = "update_jwt_secret"
# ---------------------------------------------------------------------------
# Schema DDL
# ---------------------------------------------------------------------------
INIT_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
subsystem TEXT NOT NULL,
level TEXT NOT NULL CHECK (level IN ('read', 'rw')),
UNIQUE(username, subsystem)
);
CREATE TABLE IF NOT EXISTS token_blacklist (
jti TEXT PRIMARY KEY,
token_type TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
username TEXT UNIQUE NOT NULL,
jti TEXT NOT NULL,
issued_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webauthn_creds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL REFERENCES users(username) ON DELETE CASCADE,
credential_id TEXT NOT NULL,
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
transports TEXT NOT NULL DEFAULT '[]',
UNIQUE(username, credential_id)
);
CREATE TABLE IF NOT EXISTS init_sequence (
seq INTEGER PRIMARY KEY
);
"""
class Transaction:
"""Context manager for database transactions.
Provides BEGIN/COMMIT/ROLLBACK semantics. Auto-commit is suppressed
inside the transaction block.
Usage:
with db.in_transaction() as tx:
tx.run(Q_INSERT_USER, ("user1", "hash"))
tx.run(Q_UPSERT_PERMISSION, ("user1", "firewall", "rw"))
"""
def __init__(self, parent: Database) -> None:
self._parent = parent
def __enter__(self) -> Transaction:
self._parent._begin()
self._parent._suppress_auto_commit()
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
try:
if exc_type is None:
self._parent._commit()
else:
self._parent._rollback()
finally:
self._parent._restore_auto_commit()
return False
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
return self._parent.query(query_id, params)
def run(self, query_id: str, params: tuple = ()) -> int:
return self._parent.run(query_id, params)
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
return self._parent.run_one(query_id, params)
class Database(ABC):
"""Abstract database interface.
All subsystems interact with the database through this interface.
Queries are identified by string IDs (e.g. Q_INSERT_USER) never
raw SQL strings.
Connection is cached via the ``conn`` property. Prepared statements
are auto-cached on first use.
"""
QUERY_MAP: ClassVar[dict[str, str]] = {}
def __init__(self, connection_string: str) -> None:
self._connection_string = connection_string
self._prepared: dict[str, Any] = {}
self._in_transaction = False
# Per-thread connections: backend connection objects (e.g. sqlite3)
# are bound to the thread that created them. The Flask WebUI runs
# requests in worker threads while the daemon uses a single event-loop
# thread, so each thread lazily gets its own connection.
self._local = threading.local()
@property
def conn(self) -> Any:
"""Return this thread's cached database connection, creating it lazily."""
conn = getattr(self._local, "conn", None)
if conn is None:
conn = self._connect(self._connection_string)
self._local.conn = conn
return conn
@abstractmethod
def _connect(self, cs: str) -> Any: ...
@abstractmethod
def _prepare(self, sql: str) -> Any: ...
@abstractmethod
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
"""Execute a prepared statement. Returns (rows, rowcount)."""
...
@abstractmethod
def _last_insert_id(self, stmt: Any) -> int | dict: ...
@abstractmethod
def _execute_direct(self, sql: str) -> None:
"""Execute raw SQL without prepared statements (for DDL, not implemented by base)."""
def _begin(self) -> None: # noqa: B027
"""Begin a transaction (not implemented by base)."""
def _commit(self) -> None: # noqa: B027
"""Commit a transaction (not implemented by base)."""
def _rollback(self) -> None: # noqa: B027
"""Rollback a transaction (not implemented by base)."""
def _suppress_auto_commit(self) -> None: # noqa: B027
"""Suppress auto-commit (not implemented by base)."""
def _restore_auto_commit(self) -> None: # noqa: B027
"""Restore auto-commit (not implemented by base)."""
def query(self, query_id: str, params: tuple = ()) -> list[dict]:
"""Execute a SELECT query. Returns list of row dicts."""
stmt = self._get_prepared(query_id)
rows, _ = self._execute(stmt, params)
return rows
def run(self, query_id: str, params: tuple = ()) -> int:
"""Execute an INSERT/UPDATE/DELETE. Returns affected row count."""
stmt = self._get_prepared(query_id)
_, count = self._execute(stmt, params)
return count
def run_one(self, query_id: str, params: tuple = ()) -> int | dict:
"""Execute and return the last insert ID or row dict."""
stmt = self._get_prepared(query_id)
_, _ = self._execute(stmt, params)
return self._last_insert_id(stmt)
def in_transaction(self) -> Transaction:
"""Return a transaction context manager."""
return Transaction(self)
def init_tables(self) -> None:
"""Create schema tables if they don't exist."""
self._execute_direct(INIT_SQL)
def _get_prepared(self, query_id: str) -> Any:
if query_id not in self._prepared:
if query_id not in self.QUERY_MAP:
raise KeyError(f"Unknown query ID: {query_id!r}")
self._prepared[query_id] = self._prepare(self.QUERY_MAP[query_id])
return self._prepared[query_id]
# ---------------------------------------------------------------------------
# Singleton accessor
# ---------------------------------------------------------------------------
_db_instance: Database | None = None
def _get_backend_name() -> str:
return os.environ.get("VACUUM_WALL_DB_BACKEND", "sqlite")
def _get_db_path() -> str:
return os.environ.get("VACUUM_WALL_DB_PATH", str(PROJECT_DIR / "data" / "auth.db"))
def get_db() -> Database:
"""Return the singleton Database instance.
Creates the instance on first call using the backend specified by
``VACUUM_WALL_DB_BACKEND`` env var (default: sqlite).
Call this at application startup to ensure the DB is initialized.
"""
global _db_instance
if _db_instance is None:
backend = _get_backend_name()
if backend == "sqlite":
from lib.db_sqlite import SQLiteBackend
path = _get_db_path()
_db_instance = SQLiteBackend(path)
Path(path).parent.mkdir(parents=True, exist_ok=True)
_db_instance.init_tables()
_seed_builtin_admin(_db_instance)
else:
raise ValueError(f"Unknown database backend: {backend!r}")
return _db_instance
def _seed_builtin_admin(db: Database) -> None:
"""Create the builtin admin user as a last-resort fallback.
Seeding is skipped when:
- ``VACUUM_WALL_SEED_BUILTIN_ADMIN`` is set to ``0`` bootstrap_auth.py
sets this, since bootstrap creates the operator user itself and must
not leave a hardcoded ``admin`` with an unrecoverable random password, or
- the users table already contains users accounts exist, so bootstrap
(or a prior start) has run.
The seed therefore only fires on a completely empty database, i.e.
bootstrap was genuinely skipped and a service starts first.
The builtin admin has full (rw) access to all subsystems and cannot
be deleted or have permissions modified through the normal API.
"""
if os.environ.get("VACUUM_WALL_SEED_BUILTIN_ADMIN", "1") == "0":
return
from lib.auth_users import ALL_SUBSYSTEMS, BUILTIN_ADMIN_USERNAME
from lib.password import hash_password
# Last-resort guard: only seed when no users exist at all.
if db.query(Q_SELECT_ALL_USERS):
return
# Generate a random password — this fallback should only fire if
# bootstrap_auth.py was skipped. Log the password prominently.
random_password = secrets.token_urlsafe(24)
placeholder_hash = hash_password(random_password)
jwt_secret = secrets.token_urlsafe(32)
try:
with db.in_transaction() as tx:
tx.run_one(
Q_INSERT_USER, (BUILTIN_ADMIN_USERNAME, placeholder_hash, jwt_secret)
)
for subsystem in ALL_SUBSYSTEMS:
tx.run(Q_UPSERT_PERMISSION, (BUILTIN_ADMIN_USERNAME, subsystem, "rw"))
except Exception as exc:
rows = db.query(Q_SELECT_USER_BY_NAME, (BUILTIN_ADMIN_USERNAME,))
if not rows:
raise
logger.warning(
"Concurrent builtin admin seed detected (%s); proceeding with existing user",
exc,
)
return
auth_log = Path("/var/log/vacuum-wall/auth.log")
auth_log_written = False
try:
auth_log.write_text(
f"Builtin admin password: {random_password}\n", encoding="utf-8"
)
import os as _os
_os.chmod(str(auth_log), 0o600)
auth_log_written = True
except OSError:
logger.error("Could not write admin password to %s", auth_log)
logger.warning(
"Builtin admin user created. THIS IS A FALLBACK — bootstrap_auth.py "
"should have run during install. Admin password: %s... (%s)",
random_password[:6],
"see /var/log/vacuum-wall/auth.log for the full password"
if auth_log_written
else "full password NOT written — check the error above",
)
def reset_db_for_test() -> None:
"""Reset the singleton — only for tests."""
global _db_instance
if _db_instance is not None:
_db_instance = None
+185
View File
@@ -0,0 +1,185 @@
"""SQLite backend for Vacuum Wall database.
Concrete implementation of the Database abstract class using SQLite3.
Uses Python 3.13+ sqlite3.Statement for prepared statements.
"""
from __future__ import annotations
import contextlib
import logging
import sqlite3
from typing import Any, ClassVar
from lib.db import (
Q_DELETE_EXPIRED_BLACKLIST,
Q_DELETE_PERMISSION_SUBSYSTEM,
Q_DELETE_PERMISSIONS,
Q_DELETE_REFRESH_TOKEN,
Q_DELETE_USER,
Q_DELETE_WEBAUTHN,
Q_INSERT_BLACKLIST,
Q_INSERT_USER,
Q_INSERT_WEBAUTHN,
Q_SELECT_ALL_USERS,
Q_SELECT_BLACKLIST,
Q_SELECT_PERMISSIONS,
Q_SELECT_REFRESH_TOKEN,
Q_SELECT_USER_BY_ID,
Q_SELECT_USER_BY_NAME,
Q_SELECT_USER_JWT_SECRET,
Q_SELECT_USERS_WITH_PERMS,
Q_SELECT_WEBAUTHN_COUNTS,
Q_SELECT_WEBAUTHN_ID,
Q_SELECT_WEBAUTHN_USER,
Q_UPDATE_JWT_SECRET,
Q_UPDATE_PASSWORD,
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
Q_UPSERT_PERMISSION,
Q_UPSERT_REFRESH_TOKEN,
Database,
)
logger = logging.getLogger(__name__)
class SQLiteBackend(Database):
"""SQLite implementation of the Database interface.
Uses ``sqlite3.Connection.execute()`` for statement execution and
``sqlite3.Row`` for row-factory dict access.
"""
def __init__(self, connection_string: str) -> None:
super().__init__(connection_string)
self._last_rowid: int = 0
QUERY_MAP: ClassVar[dict[str, str]] = {
# Schema init is handled by direct execution, not prepared statements
# init_tables is called as _execute_direct(INIT_SQL)
# Users
Q_INSERT_USER: (
"INSERT INTO users (username, password_hash, jwt_secret) VALUES (?, ?, ?)"
),
Q_SELECT_USER_BY_NAME: (
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE username = ?"
),
Q_SELECT_USER_BY_ID: (
"SELECT id, username, password_hash, jwt_secret, created_at FROM users WHERE id = ?"
),
Q_UPDATE_PASSWORD: "UPDATE users SET password_hash = ? WHERE username = ?",
Q_DELETE_USER: "DELETE FROM users WHERE username = ?",
Q_SELECT_ALL_USERS: (
"SELECT id, username, created_at FROM users ORDER BY username"
),
Q_SELECT_USERS_WITH_PERMS: (
"SELECT u.id, u.username, u.created_at, p.subsystem, p.level "
"FROM users u LEFT JOIN permissions p ON u.username = p.username "
"ORDER BY u.username"
),
Q_SELECT_USER_JWT_SECRET: ("SELECT jwt_secret FROM users WHERE username = ?"),
Q_UPDATE_JWT_SECRET: ("UPDATE users SET jwt_secret = ? WHERE username = ?"),
# Permissions
Q_UPSERT_PERMISSION: (
"INSERT INTO permissions (username, subsystem, level) "
"VALUES (?, ?, ?) "
"ON CONFLICT(username, subsystem) DO UPDATE SET level = excluded.level"
),
Q_SELECT_PERMISSIONS: (
"SELECT subsystem, level FROM permissions WHERE username = ?"
),
Q_DELETE_PERMISSIONS: ("DELETE FROM permissions WHERE username = ?"),
Q_DELETE_PERMISSION_SUBSYSTEM: (
"DELETE FROM permissions WHERE username = ? AND subsystem = ?"
),
# Token blacklist
Q_INSERT_BLACKLIST: (
"INSERT OR IGNORE INTO token_blacklist (jti, token_type, expires) VALUES (?, ?, ?)"
),
Q_SELECT_BLACKLIST: "SELECT jti FROM token_blacklist WHERE jti = ?",
Q_DELETE_EXPIRED_BLACKLIST: "DELETE FROM token_blacklist WHERE expires < ?",
# Refresh tokens
Q_UPSERT_REFRESH_TOKEN: (
"INSERT INTO refresh_tokens (username, jti, issued_at) "
"VALUES (?, ?, ?) "
"ON CONFLICT(username) DO UPDATE SET jti = excluded.jti, issued_at = excluded.issued_at"
),
Q_SELECT_REFRESH_TOKEN: "SELECT username, jti, issued_at FROM refresh_tokens WHERE username = ?",
Q_DELETE_REFRESH_TOKEN: "DELETE FROM refresh_tokens WHERE username = ?",
# WebAuthn
Q_INSERT_WEBAUTHN: (
"INSERT INTO webauthn_creds "
"(username, credential_id, public_key, sign_count, name, transports) "
"VALUES (?, ?, ?, ?, ?, ?)"
),
Q_SELECT_WEBAUTHN_USER: (
"SELECT id, credential_id, public_key, sign_count, name, transports "
"FROM webauthn_creds WHERE username = ?"
),
Q_SELECT_WEBAUTHN_ID: (
"SELECT id, username, credential_id, public_key, sign_count, name, transports "
"FROM webauthn_creds WHERE credential_id = ?"
),
Q_SELECT_WEBAUTHN_COUNTS: (
"SELECT username, COUNT(*) as cred_count FROM webauthn_creds "
"GROUP BY username"
),
Q_UPDATE_WEBAUTHN_SIGN_COUNT: (
"UPDATE webauthn_creds SET sign_count = ? WHERE credential_id = ?"
),
Q_DELETE_WEBAUTHN: "DELETE FROM webauthn_creds WHERE credential_id = ?",
}
def _connect(self, cs: str) -> Any:
"""Create a SQLite connection with WAL mode and row factory."""
conn = sqlite3.connect(cs, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
# Multiple threads/processes hold distinct connections (see
# Database.conn); wait up to 5s for writers instead of failing
# immediately with SQLITE_BUSY.
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn
def _prepare(self, sql: str) -> Any:
"""Store the SQL string for later execution.
SQLite in-memory DB doesn't support the Python 3.13 conn.prepare()
API, so we store the raw SQL and execute via conn.execute().
"""
return sql
def _execute(self, stmt: Any, params: tuple) -> tuple[list[dict], int]:
"""Execute a prepared statement, returning (rows, rowcount)."""
cursor = self.conn.execute(stmt, params)
self._last_rowid = cursor.lastrowid
result = cursor.fetchall()
rows: list[dict] = []
for row in result:
rows.append(dict(row))
return rows, cursor.rowcount
def _last_insert_id(self, stmt: Any) -> int | dict:
"""Return the last insert row ID from the most recent execute."""
return self._last_rowid
def _begin(self) -> None:
self.conn.execute("BEGIN")
def _commit(self) -> None:
self.conn.execute("COMMIT")
def _rollback(self) -> None:
with contextlib.suppress(sqlite3.Error):
self.conn.execute("ROLLBACK")
def _suppress_auto_commit(self) -> None:
self._in_transaction = True
def _restore_auto_commit(self) -> None:
self._in_transaction = False
def _execute_direct(self, sql: str) -> None:
"""Execute raw SQL without prepared statements (for DDL)."""
self.conn.executescript(sql)
+5 -310
View File
@@ -1,18 +1,15 @@
"""Dnsmasq configuration manager for Vacuum Wall SSL proxy firewall.
"""Dnsmasq config persistence for Vacuum Wall.
Generates /etc/dnsmasq.d/vacuum-wall.conf and manages DHCP range,
static leases, and custom DNS records through sudo.
Provides load/save for the declarative JSON config and upstream-management
helpers used by the sync bus and network handler. All mutation and
apply logic lives in daemon/handlers/dnsmasq.py.
"""
import logging
import subprocess
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.common import deep_merge, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
@@ -22,17 +19,7 @@ CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
FRAGMENTS_DIR = DATA_DIR / "fragments"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
ENV = Environment(
loader=FileSystemLoader(str(PROJECT_DIR / "system")),
autoescape=False,
lstrip_blocks=True,
trim_blocks=True,
)
# --- defaults ---
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {
"ranges": [],
@@ -66,244 +53,7 @@ def save_config(cfg: dict[str, Any]) -> None:
logger.info("dnsmasq config saved")
def apply_config() -> None:
"""Write generated config to disk via sudo tee, then reload dnsmasq."""
cfg = get_config()
conf_text = generate_conf(cfg)
ensure_dirs(CONFIG_DIR, DATA_DIR, FRAGMENTS_DIR)
subprocess.run(["sudo", "mkdir", "-p", "/etc/dnsmasq.d"], check=True)
subprocess.run(
["sudo", "tee", DNSMASQ_CONF, "--"],
input=conf_text,
capture_output=True,
text=True,
check=True,
)
subprocess.run(
["sudo", "systemctl", "reload", "dnsmasq"],
capture_output=True,
text=True,
check=True,
)
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a complete dnsmasq.conf text block from the config dict."""
dhcp_cfg = cfg.get("dhcp", {})
dns_cfg = cfg.get("dns", {})
interfaces = [
r["interface"] for r in dhcp_cfg.get("ranges", []) if "interface" in r
]
# Fallback: use network-managed interface addresses for listen-address
listen_addresses = []
try:
from lib.network import get_config as _get_net_config
net_cfg = _get_net_config()
for _iface, info in net_cfg.get("interfaces", {}).items():
for addr_str in info.get("addresses", []):
if "/" in addr_str:
addr_str = addr_str.split("/")[0]
listen_addresses.append(addr_str)
except Exception:
pass
tmpl = ENV.get_template("dnsmasq.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interfaces=interfaces or None,
listen_addresses=listen_addresses if listen_addresses else None,
dhcp=dhcp_cfg,
dns=dns_cfg,
fragments_dir=str(FRAGMENTS_DIR) if FRAGMENTS_DIR.exists() else None,
)
# ───────── dhcp management ───────────────────────────────────────────
def set_dhcp_range(
iface: str,
start: str,
end: str,
lease_time: str = "12h",
gateway: str | None = None,
dns: str | None = None,
) -> None:
"""Add or replace the DHCP range for a given interface."""
cfg = get_config()
ranges = cfg["dhcp"]["ranges"]
found = False
for i, r in enumerate(ranges):
if r.get("interface") == iface:
ranges[i] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
ranges[i]["gateway"] = gateway
if dns:
ranges[i]["dns"] = dns
found = True
break
if not found:
entry: dict[str, Any] = {
"interface": iface,
"start": start,
"end": end,
"lease_time": lease_time,
}
if gateway:
entry["gateway"] = gateway
if dns:
entry["dns"] = dns
ranges.append(entry)
save_config(cfg)
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
"""Remove a DHCP range by interface + IP range."""
cfg = get_config()
cfg["dhcp"]["ranges"] = [
r
for r in cfg["dhcp"]["ranges"]
if not (
r.get("interface") == iface
and r.get("start") == start
and r.get("end") == end
)
]
save_config(cfg)
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
"""Add (or update) a static DHCP lease by MAC address."""
cfg = get_config()
leases = cfg["dhcp"]["static_leases"]
for i, lease in enumerate(leases):
if lease["mac"].lower() == mac.lower():
leases[i].update({"mac": mac, "ip": ip})
if hostname is not None:
leases[i]["hostname"] = hostname
save_config(cfg)
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
return
entry: dict[str, Any] = {"mac": mac, "ip": ip}
if hostname:
entry["hostname"] = hostname
leases.append(entry)
save_config(cfg)
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
def remove_static_lease(mac: str) -> None:
"""Remove a static DHCP lease by MAC address."""
cfg = get_config()
cfg["dhcp"]["static_leases"] = [
lease
for lease in cfg["dhcp"]["static_leases"]
if lease["mac"].lower() != mac.lower()
]
save_config(cfg)
logger.info("Static DHCP lease removed for MAC %s", mac)
# ───────── dns record management ─────────────────────────────────────
def add_dns_record(name: str, address: str, hostname: str | None = None) -> None:
"""Add or update a custom DNS A record."""
cfg = get_config()
records = cfg["dns"]["custom_records"]
for i, r in enumerate(records):
if r["name"] == name:
records[i].update({"name": name, "address": address})
if hostname is not None:
records[i]["hostname"] = hostname
save_config(cfg)
logger.info("DNS record updated: %s -> %s", name, address)
return
entry: dict[str, Any] = {"name": name, "address": address}
if hostname:
entry["hostname"] = hostname
records.append(entry)
save_config(cfg)
logger.info("DNS record added: %s -> %s", name, address)
def remove_dns_record(name: str) -> None:
"""Remove a custom DNS record by name."""
cfg = get_config()
cfg["dns"]["custom_records"] = [
r for r in cfg["dns"]["custom_records"] if r["name"] != name
]
save_config(cfg)
logger.info("DNS record removed: %s", name)
# ───────── lease table ───────────────────────────────────────────────
def _parse_lease_line(line: str) -> dict[str, Any] | None:
"""Parse one line from dnsmasq.leases into a dict."""
line = line.strip()
if not line or line.startswith("#"):
return None
parts = line.split()
if len(parts) < 3:
return None
try:
ts = datetime.fromtimestamp(int(parts[0]), tz=UTC)
except (ValueError, OSError):
ts = None
return {
"expires_at": ts,
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
def get_lease_table() -> list[dict[str, Any]]:
"""Read and parse the current dnsmasq lease file."""
leases: list[dict[str, Any]] = []
try:
result = subprocess.run(
["sudo", "cat", LEASE_FILE],
capture_output=True,
text=True,
check=True,
)
for entry in map(_parse_lease_line, result.stdout.splitlines()):
if entry is not None:
leases.append(entry)
except subprocess.CalledProcessError:
pass
return leases
# ───────── upstream / domain helpers ─────────────────────────────────
# ───────── upstream helpers ──────────────────────────────────────────
def set_upstreams(servers: list[str]) -> None:
@@ -322,64 +72,9 @@ def set_domain(domain: str | None) -> None:
logger.info("DNS domain set to '%s'", domain)
# ───────── status / info ─────────────────────────────────────────────
def get_status() -> dict[str, Any]:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
try:
proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"],
capture_output=True,
text=True,
)
active = proc.stdout.strip() == "active"
except Exception:
active = False
conf_exists = Path(DNSMASQ_CONF).is_file()
if conf_exists:
try:
with open(DNSMASQ_CONF) as f:
conf_on_disk = f.read()
except PermissionError:
conf_on_disk = ""
else:
conf_on_disk = ""
expected = generate_conf(cfg)
leases = get_lease_table()
return {
"service_active": active,
"config_file_exists": conf_exists,
"config_in_sync": conf_on_disk == expected,
"dhcp_ranges": len(cfg["dhcp"]["ranges"]),
"static_leases": len(cfg["dhcp"]["static_leases"]),
"custom_dns_records": len(cfg["dns"]["custom_records"]),
"upstreams": cfg["dns"]["upstreams"],
"domain": cfg["dns"].get("domain"),
"active_leases": len(leases),
"leases": leases,
}
__all__ = [
"add_dns_record",
"add_static_lease",
"apply_config",
"generate_conf",
"get_config",
"get_lease_table",
"get_status",
"remove_dhcp_range",
"remove_dns_record",
"remove_static_lease",
"save_config",
"set_dhcp_range",
"set_domain",
"set_upstreams",
]
+112 -13
View File
@@ -22,6 +22,22 @@ CONFIG_FILE: Path = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# Zones firewalld ships by default. They are always present live and are
# never meaningful to flag as "unmanaged (not in config)".
FIREWALLD_BUILTIN_ZONES: frozenset[str] = frozenset(
{
"block",
"dmz",
"drop",
"external",
"home",
"host",
"internal",
"public",
"trusted",
}
)
# ---------------------------------------------------------------------------
# Internal helpers
@@ -66,7 +82,9 @@ def _parse_interfaces(output: str) -> list[str]:
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
"""Parse ``firewall-cmd --zone=Z --list-all`` output."""
"""Parse ``firewall-cmd --zone=Z --list-all`` or a zone block
from ``--list-all-zones`` output.
"""
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
@@ -76,6 +94,11 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
key = key.strip()
value = value.strip()
# --list-all-zones uses "rich rules" (space) while
# --zone=Z --list-all uses "rich-rules" (hyphen); normalize.
if key == "rich rules":
key = "rich-rules"
if not value:
if key in ("masquerade", "ics"):
info[key] = False
@@ -116,6 +139,43 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
return info
def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
"""Parse the combined ``firewall-cmd --list-all-zones`` output.
Returns a dict mapping each zone name to its parsed info dict
(same structure as ``_parse_zone_output``).
"""
zones: dict[str, dict[str, Any]] = {}
current_name: str | None = None
current_lines: list[str] = []
for raw_line in output.splitlines():
if not raw_line.strip():
continue
# Non-indented line starts a new zone block
if raw_line[0].isspace():
if current_name is not None:
current_lines.append(raw_line.strip())
else:
# Finalize previous zone
if current_name is not None and current_lines:
zones[current_name] = _parse_zone_output(
current_name, "\n".join(current_lines)
)
# Extract zone name (discard trailing parenthetical metadata)
name = raw_line.strip().split()[0]
if "(" in name:
name = name[: name.index("(")]
current_name = name
current_lines = []
# Finalize last zone
if current_name is not None and current_lines:
zones[current_name] = _parse_zone_output(current_name, "\n".join(current_lines))
return zones
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
@@ -269,17 +329,22 @@ def _compute_pending_changes(
}
)
cfg_mq = zone_cfg.get("masquerade", False)
live_mq = live_zone.get("masquerade", False)
if cfg_mq != live_mq:
changes.append(
{
"zone": zone_name,
"type": "masquerade",
"config": cfg_mq,
"live": live_mq,
}
)
# public zone masquerade is not reconciled by apply (it is driven by
# the nftables propagation step in daemon/handlers/firewall.py), so
# reporting it as pending here would advertise a change that never
# happens. Skip it to keep the diff consistent with apply.
if zone_name != "public":
cfg_mq = zone_cfg.get("masquerade", False)
live_mq = live_zone.get("masquerade", False)
if cfg_mq != live_mq:
changes.append(
{
"zone": zone_name,
"type": "masquerade",
"config": cfg_mq,
"live": live_mq,
}
)
cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
live_rules = set(live_zone.get("rich-rules", []))
@@ -312,7 +377,7 @@ def _compute_pending_changes(
)
for zone_name in live_zones:
if zone_name not in cfg_zones:
if zone_name not in cfg_zones and zone_name not in FIREWALLD_BUILTIN_ZONES:
unknown_live[zone_name] = {
"interfaces": live_zones[zone_name].get("interfaces", []),
}
@@ -335,11 +400,43 @@ def config_pending(state: dict[str, Any]) -> dict[str, Any]:
return _compute_pending_changes(cfg, live_zones)
def fw_change_summary(zone: str, ctype: str, change: dict[str, Any]) -> str:
"""Build a human-readable summary string for a firewall change."""
if ctype == "interfaces":
config_if = change.get("config", [])
live_if = change.get("live", [])
return f"Zone {zone}: interfaces changed (config: {config_if}, live: {live_if})"
if ctype == "services":
config_sv = change.get("config", [])
live_sv = change.get("live", [])
return f"Zone {zone}: services changed (config: {config_sv}, live: {live_sv})"
if ctype == "rich_rules":
cfg_count = change.get("config_count", 0)
live_count = change.get("live_count", 0)
return (
f"Zone {zone}: rich rules differ (config: {cfg_count}, live: {live_count})"
)
if ctype == "forward_ports":
cfg_count = change.get("config_count", 0)
live_count = change.get("live_count", 0)
return f"Zone {zone}: port forwards differ (config: {cfg_count}, live: {live_count})"
if ctype == "masquerade":
cfg_val = change.get("config", False)
live_val = change.get("live", False)
return f"Zone {zone}: masquerade changed (config: {cfg_val}, live: {live_val})"
if ctype == "target":
cfg_val = change.get("config", "default")
live_val = change.get("live", "default")
return f"Zone {zone}: target changed (config: {cfg_val}, live: {live_val})"
return f"Zone {zone}: {ctype} changed"
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
"DATA_DIR",
"DEFAULT_CONFIG",
"FIREWALLD_BUILTIN_ZONES",
"RULES_FILE",
"_compute_pending_changes",
"_ensure_config_file",
@@ -347,10 +444,12 @@ __all__ = [
"_normalize_target",
"_now_iso",
"_parse_active_zones",
"_parse_all_zones_output",
"_parse_forward_ports",
"_parse_interfaces",
"_parse_zone_output",
"config_pending",
"fw_change_summary",
"get_config",
"load_backup",
"save_backup",
+9
View File
@@ -76,6 +76,15 @@ def setup_logging(level: str | None = None) -> None:
"""
def _open(self):
"""Override to open log file with group-write permissions (0664).
Temporarily clears umask to ensure the file is writable by both the
WebUI process (vacuum-wall user) and daemon process (vacuum-walld user)
when they share a group. On existing stale files, chmod's to 0664.
Returns:
Open file object in append mode.
"""
# Ensure group-write on an existing stale file (e.g. left by the
# other process with a stricter umask at creation time).
with contextlib.suppress(OSError):
+55
View File
@@ -103,30 +103,73 @@ def save_config(cfg: dict[str, Any]) -> None:
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=Value`` line to *lines* if *py_key* has a non-None value in *d*.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=Value`` line to *lines* for integer values.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=yes/no`` line to *lines* if *py_key* has a non-None value in *d*.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Identical to :func:`_emit_bool` — kept for API compatibility.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=Value`` line handling both bool and non-bool types.
Boolean values are rendered as ``yes``/``no``; all other types are
stringified directly.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
if isinstance(v, bool):
@@ -570,6 +613,18 @@ _IS_LOCAL = [
def _is_local_dns(addr: str) -> bool:
"""Return ``True`` if *addr* falls within a local/private IP range.
Checks loopback, RFC 1918 (10/8, 172.16/12, 192.168/16), link-local
(169.254/16), and their IPv6 equivalents (fc00::/7, fe80::/10).
Args:
addr: IP address string to test.
Returns:
``True`` if the address is local/private, ``False`` otherwise.
Invalid addresses are treated as non-local.
"""
try:
ip = ipaddress.ip_address(addr)
for net in _IS_LOCAL:
+209 -152
View File
@@ -1,7 +1,7 @@
"""Nginx server-block generator for Vacuum Wall SSL proxy firewall.
Manages per-domain SSL reverse proxy configurations, certificate
bootstrap, basic-auth htpasswd files, and nginx reload cycles.
Manages per-domain SSL reverse proxy configurations backed by named backends,
certificate bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import logging
@@ -14,7 +14,7 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
from lib.acme import find_cert_dir
from lib.common import ensure_dirs, load_json, save_json
from lib.common import _hash_password, ensure_dirs, load_json, save_json
logger = logging.getLogger(__name__)
@@ -47,74 +47,127 @@ DEFAULT_SSL: dict[str, Any] = {
"prefer_server_ciphers": False,
}
WEBUI_BACKEND: dict[str, Any] = {
"label": "Vacuum Wall WebUI",
"builtin": True,
"paths": {
"/": {
"backend": {"host": "127.0.0.1", "port": 9090, "proto": "http"},
"is_management": True,
# The WebUI is protected by JWT at the Flask layer; nginx must
# not gate it with auth_basic (the SPA sends Bearer tokens, which
# suppress the browser's automatic Basic credentials). auth=None
# renders `auth_basic off` even if legacy auth was harvested.
"auth": None,
},
"/ws": {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
}
DEFAULT_CONFIG: dict[str, Any] = {
"backends": {},
"domains": {},
"ssl": {**DEFAULT_SSL},
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Resolution
def _resolve_paths(
domain_cfg: dict[str, Any], backends: dict[str, Any]
) -> dict[str, Any]:
"""Resolve effective paths from backends[domain_cfg['backend']].paths."""
backend_name = domain_cfg.get("backend", "")
if backend_name and backend_name in backends:
return backends[backend_name].get("paths", {})
return {}
def _resolve_auth(
domain_cfg: dict[str, Any], backends: dict[str, Any]
) -> dict[str, Any] | None:
"""Resolve effective auth: domain auth -> backend auth -> None."""
if "auth" in domain_cfg:
return domain_cfg.get("auth")
backend_name = domain_cfg.get("backend", "")
if backend_name and backend_name in backends:
backend_auth = backends[backend_name].get("auth")
if backend_auth is not None:
return backend_auth
return None
# ---------------------------------------------------------------------------
# Migration
def _migrate_config(raw: dict[str, Any]) -> dict[str, Any]:
"""Migrate legacy config formats to the new paths-based model.
Handles two migrations:
1. Legacy ``management`` top-level key -> path entry under its domain.
2. Domain entries without ``paths`` -> wrap ``backend`` inside ``paths["/"]``.
Args:
raw: Config dict as loaded from disk.
Returns:
The migrated config dict.
"""
# Migrate management key
if "management" in raw and raw["management"] is not None:
mgmt = raw["management"]
mgmt_domain = mgmt.get("domain", "")
if mgmt_domain:
domains = raw.setdefault("domains", {})
if mgmt_domain not in domains:
domains[mgmt_domain] = {
"force_ssl": True,
"paths": {},
}
dom = domains[mgmt_domain]
paths = dom.setdefault("paths", {})
if "/" not in paths:
paths["/"] = {
"backend": {
"host": mgmt.get("backend", {}).get("host", "127.0.0.1"),
"port": mgmt.get("backend", {}).get("port", 9090),
"proto": "http",
},
"is_management": True,
}
if mgmt.get("auth"):
paths["/"]["auth"] = mgmt["auth"]
# Add WebSocket path if not present
if "/ws" not in paths:
paths["/ws"] = {
"backend": {"host": "127.0.0.1", "port": 9091, "proto": "http"},
"is_websocket": True,
}
del raw["management"]
# Migrate domain entries without paths
for dom in raw.get("domains", {}).values():
if "paths" not in dom and "backend" in dom:
dom["paths"] = {
"/": {
"backend": dom.pop("backend"),
"headers": dom.pop("headers", {}),
}
}
"""Migrate legacy config to backends model."""
_ensure_webui_backend(raw)
_migrate_mgmt_domains(raw)
return raw
def _ensure_webui_backend(raw: dict[str, Any]) -> None:
"""Create the builtin webui backend if not yet migrated."""
backends = raw.setdefault("backends", {})
webui = backends.get("webui")
if webui and webui.get("_migrated"):
return
backends["webui"] = deepcopy(WEBUI_BACKEND)
backends["webui"]["_migrated"] = True
def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
"""Migrate legacy management domains to backend references.
Legacy format: management domains had inline paths pointing to
127.0.0.1:9090 (Flask) and 127.0.0.1:9091 (WebSocket).
New format: domains reference the "webui" backend by name.
Detection heuristic: if both "/" path points to 127.0.0.1:9090
(is_management) and "/ws" path points to 127.0.0.1:9091
(is_websocket), the domain is a management domain and gets migrated.
"""
backends = raw.get("backends", {})
if not backends.get("webui", {}).get("_migrated"):
return
domains = raw.setdefault("domains", {})
for _name, dom in list(domains.items()):
if dom.get("backend") == "webui":
continue # Already migrated
if dom.get("application") == "webui":
del dom["application"]
paths = dom.get("paths", {})
root = paths.get("/", {})
ws = paths.get("/ws", {})
root_backend = root.get("backend", {})
ws_backend = ws.get("backend", {})
# Check if root path points to Flask management backend
is_mgmt_root = root.get("is_management") or (
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
)
# Check if WS path points to WebSocket management backend
is_mgmt_ws = ws.get("is_websocket") or (
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
)
# If both match, migrate: set backend reference, remove inline paths/auth
if is_mgmt_root and is_mgmt_ws:
dom["backend"] = "webui"
dom.pop("paths", None)
dom.pop("auth", None)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_config() -> dict[str, Any]:
"""Load the current nginx config, initializing with defaults if needed.
@@ -122,7 +175,7 @@ def get_config() -> dict[str, Any]:
legacy formats, then returns the config dict.
Returns:
The complete config dict with ``domains`` and ``ssl`` keys.
The complete config dict with ``backends``, ``domains``, and ``ssl`` keys.
"""
ensure_dirs(CONFIG_DIR, SITES_DIR)
raw = load_json(CONFIG_FILE)
@@ -130,6 +183,8 @@ def get_config() -> dict[str, Any]:
raw = deepcopy(DEFAULT_CONFIG)
if "ssl" not in raw:
raw["ssl"] = deepcopy(DEFAULT_SSL)
if "backends" not in raw:
raw["backends"] = {}
raw = _migrate_config(raw)
save_config(raw)
return raw
@@ -143,20 +198,21 @@ def save_config(cfg: dict[str, Any]) -> None:
def get_domains() -> list[dict[str, Any]]:
"""Return a list of all configured proxy domains flattened by path.
Each path within a domain becomes a separate entry with domain-level
settings repeated.
Each path within a domain becomes a separate entry. Paths are resolved
from the domain's referenced backend.
Returns:
List of dicts with ``domain``, ``path``, ``backend``, ``online``,
``force_ssl``, and path-level flags.
``force_ssl``, ``backend_name``, and path-level flags.
"""
cfg = get_config()
backends = cfg.get("backends", {})
result: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
site = SITES_DIR / f"{name}.conf"
paths = dom.get("paths", {})
if not paths:
if "backend" not in dom:
continue
site = SITES_DIR / f"{name}.conf"
paths = _resolve_paths(dom, backends)
for ppath, pcfg in paths.items():
entry: dict[str, Any] = {
"domain": name,
@@ -164,6 +220,7 @@ def get_domains() -> list[dict[str, Any]]:
"backend": pcfg.get("backend", {}),
"online": site.exists(),
"force_ssl": dom.get("force_ssl", True),
"backend_name": dom["backend"],
}
if pcfg.get("is_management"):
entry["is_management"] = True
@@ -173,6 +230,33 @@ def get_domains() -> list[dict[str, Any]]:
return result
def get_management_domains() -> list[str]:
"""Return domain names that serve the management UI.
Checks both backend-referenced paths (for migrated configs) and
inline paths (for legacy configs pending migration).
Returns:
List of domain name strings.
"""
cfg = get_config()
backends = cfg.get("backends", {})
domains: list[str] = []
for name, dom in cfg.get("domains", {}).items():
# Check inline paths (pre-migration format)
inline_paths = dom.get("paths", {})
if any(p.get("is_management") for p in inline_paths.values()):
domains.append(name)
continue
# Check backend-referenced paths
backend_name = dom.get("backend", "")
if backend_name and backend_name in backends:
paths = backends[backend_name].get("paths", {})
if any(p.get("is_management") for p in paths.values()):
domains.append(name)
return domains
# ------------------------------------------------------------------
# Domain CRUD
# ------------------------------------------------------------------
@@ -180,57 +264,37 @@ def get_domains() -> list[dict[str, Any]]:
def add_domain(
domain: str,
backend_host: str | None = None,
backend_port: int | None = None,
backend_proto: str = "http",
backend_name: str,
cert: str | None = None,
extra_headers: dict[str, str] | None = None,
paths: dict[str, dict[str, Any]] | None = None,
force_ssl: bool = True,
auth: dict[str, Any] | None = None,
) -> None:
"""Add a new proxy domain with the given backend and optional settings.
"""Add a new proxy domain that references an existing backend.
Args:
domain: Domain name to add.
backend_host: Upstream host to proxy to (legacy mode).
backend_port: Upstream port (legacy mode).
backend_proto: Protocol (``http`` or ``https``; legacy mode).
backend_name: Name of the backend to proxy through.
cert: Optional certificate type identifier.
extra_headers: Optional dict of extra headers to forward (legacy mode).
paths: Optional path-to-config map (new mode). Each path entry must
have a ``backend`` key with ``host``, ``port``, and ``proto``.
force_ssl: Whether to enforce HTTPS redirect.
auth: Optional domain-level auth override.
Raises:
ValueError: If the domain is already configured.
ValueError: If the domain is already configured or backend not found.
"""
cfg = get_config()
if domain in cfg["domains"]:
raise ValueError(f"Domain {domain!r} already configured")
if backend_name not in cfg.get("backends", {}):
raise ValueError(f"Backend {backend_name!r} not found")
if paths is not None:
entry: dict[str, Any] = {
"paths": paths,
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
else:
if not backend_host or backend_port is None:
raise ValueError("'backend_host' and 'backend_port' are required")
entry = {
"paths": {
"/": {
"backend": {
"host": backend_host,
"port": int(backend_port),
"proto": backend_proto,
},
"headers": extra_headers or {},
}
},
"force_ssl": True,
}
if cert is not None:
entry["cert"] = cert
entry: dict[str, Any] = {
"backend": backend_name,
"force_ssl": force_ssl,
}
if cert is not None:
entry["cert"] = cert
if auth is not None:
entry["auth"] = auth
cfg["domains"][domain] = entry
save_config(cfg)
@@ -249,12 +313,10 @@ def remove_domain(domain: str) -> None:
def update_domain(domain: str, **kwargs: Any) -> None:
"""Update fields of an existing domain entry in-place.
"""Update domain-level fields of an existing domain entry.
Supports both domain-level keys (``force_ssl``, ``cert``, ``auth``,
``paths``) and paths-level shorthand (``backend``, ``headers`` for
the root path). When ``paths`` is provided, it is fully replaced.
When ``backend`` is provided, it updates ``paths["/"]["backend"]``.
Only domain-level keys are accepted: ``backend``, ``cert``, ``force_ssl``,
``auth``. Path changes must be made on the backend.
Args:
domain: Domain name to update.
@@ -262,36 +324,27 @@ def update_domain(domain: str, **kwargs: Any) -> None:
Raises:
KeyError: If the domain is not configured.
ValueError: If a new backend is specified but doesn't exist.
"""
cfg = get_config()
if domain not in cfg["domains"]:
raise KeyError(f"Domain {domain!r} not configured")
entry = cfg["domains"][domain]
# If paths is given, replace entirely
if "paths" in kwargs:
entry["paths"] = kwargs["paths"]
else:
# Legacy: top-level backend/headers -> paths["/"]
paths = entry.setdefault("paths", {})
if "backend" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["backend"] = kwargs["backend"]
if "headers" in kwargs:
root = paths.setdefault("/", {"backend": {}, "headers": {}})
root["headers"] = kwargs["headers"]
new_backend = kwargs.get("backend")
if new_backend:
if new_backend not in cfg.get("backends", {}):
raise ValueError(f"Backend {new_backend!r} not found")
entry["backend"] = new_backend
# Remove legacy top-level keys from domain entry
entry.pop("backend", None)
entry.pop("headers", None)
allowed = ("backend", "cert", "force_ssl", "auth")
for key in allowed:
if key in kwargs and key != "backend":
if key == "auth" and kwargs[key] is None:
entry.pop("auth", None)
else:
entry[key] = kwargs[key]
for key, val in kwargs.items():
if key in ("backend", "headers", "paths"):
continue
if isinstance(val, dict) and key in entry:
entry[key].update(val)
else:
entry[key] = val
save_config(cfg)
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
@@ -301,11 +354,15 @@ def update_domain(domain: str, **kwargs: Any) -> None:
# ------------------------------------------------------------------
def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
def generate_server_conf(
domain_cfg: dict[str, Any], backends: dict[str, Any] | None = None
) -> str:
"""Render the Jinja template for a domain server block.
Args:
domain_cfg: Domain entry dict including the ``domain`` key.
backends: Optional backends dict for path/auth resolution.
When omitted, falls back to reading from domain inline paths.
Returns:
The complete nginx server-block configuration as a string.
@@ -313,7 +370,14 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
tmpl = ENV.get_template("nginx/server_block.conf")
acme_home_path = PROJECT_DIR / "data" / "acme"
acme_cert_dir = str(find_cert_dir(domain_cfg["domain"], acme_home_path))
paths = domain_cfg.get("paths", {})
if backends is not None:
paths = _resolve_paths(domain_cfg, backends)
domain_auth = _resolve_auth(domain_cfg, backends)
else:
paths = domain_cfg.get("paths", {})
domain_auth = domain_cfg.get("auth")
has_management = any(p.get("is_management") for p in paths.values())
# Resolve custom cert paths for cert=="file"
cert_cfg = domain_cfg.get("cert")
@@ -330,7 +394,7 @@ def generate_server_conf(domain_cfg: dict[str, Any]) -> str:
cert=domain_cfg.get("cert"),
cert_path=cert_path,
cert_key_path=cert_key_path,
domain_auth=domain_cfg.get("auth"),
domain_auth=domain_auth,
has_management=has_management,
acme_cert_dir=acme_cert_dir,
certs_dir=str(PROJECT_DIR / "data" / "certs"),
@@ -382,19 +446,20 @@ def write_acme_challenge() -> None:
def write_all_sites() -> None:
"""Regenerate all site configs from the current config state.
Writes server blocks for every configured domain (now unified, including
any management paths), removes orphaned site files, and ensures the ACME
challenge config is present.
Writes server blocks for every configured domain using backend-resolved
paths, removes orphaned site files, and ensures the ACME challenge
config is present.
"""
ensure_dirs(SITES_DIR)
cfg = get_config()
backends = cfg.get("backends", {})
existing = set(SITES_DIR.iterdir()) if SITES_DIR.exists() else set()
written: set[str] = set()
for name, dom in cfg.get("domains", {}).items():
dom_copy = dict(dom, domain=name)
conf = generate_server_conf(dom_copy)
conf = generate_server_conf(dom_copy, backends)
write_site(name, conf)
written.add(f"{name}.conf")
@@ -533,26 +598,18 @@ def write_htpasswd(user: str, password: str) -> None:
os.replace(tmp, HTPASSWD_FILE)
def _hash_password(password: str) -> str:
"""Hash *password* using SHA-256 crypt (``$5$`` format) via passlib.
Args:
password: Plain-text password to hash.
Returns:
The hashed password string suitable for ``.htpasswd`` (e.g. ``$5$rounds=$``).
"""
from passlib.hash import sha256_crypt
return sha256_crypt.hash(password)
__all__ = [
"WEBUI_BACKEND",
"_ensure_webui_backend",
"_migrate_mgmt_domains",
"_resolve_auth",
"_resolve_paths",
"add_domain",
"apply",
"generate_server_conf",
"get_config",
"get_domains",
"get_management_domains",
"remove_domain",
"save_config",
"test_config",
+83
View File
@@ -0,0 +1,83 @@
"""Password hashing with Argon2id.
Handles user password storage for the auth DB. Uses argon2-cffi (C
implementation of the Argon2id memory-hard KDF). Random 16-byte salt is
generated per hash by the library.
Argon2id is used for auth user passwords ONLY. Nginx basic-auth htpasswd
files continue to use sha256_crypt (passlib) that's a separate concern
with different constraints (htpasswd format is standardized).
"""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from argon2.low_level import Type
# Argon2id: OWASP recommended parameters
# 64 MiB memory, 3 iterations, 4 parallel threads
_PH = PasswordHasher(
time_cost=3,
memory_cost=65536, # 64 MiB
parallelism=4,
hash_len=32,
salt_len=16,
type=Type.ID,
)
def hash_password(password: str) -> str:
"""Generate an Argon2id hash of *password*.
A random 16-byte salt is generated automatically by argon2.
The resulting hash string starts with ``$argon2id$`` and encodes
the algorithm version, parameters, salt, and hash output.
Args:
password: Plain-text password string.
Returns:
Full Argon2id hash string (e.g. ``$argon2id$v=19$m=65536,t=3,p=4$...``).
Raises:
TypeError: If *password* is not a string or contains NUL bytes.
"""
return _PH.hash(password)
def verify_password(password: str, hash_string: str) -> bool:
"""Verify *password* against an Argon2id *hash_string*.
The hash string must have been produced by :func:`hash_password`.
Args:
password: Plain-text password to verify.
hash_string: Argon2id hash string to compare against.
Returns:
``True`` if the password matches, ``False`` otherwise.
Raises:
TypeError: If *hash_string* is not a valid Argon2id hash.
"""
try:
_PH.verify(hash_string, password)
return True
except (InvalidHashError, VerifyMismatchError, ValueError):
return False
def needs_rehash(hash_string: str) -> bool:
"""Check if *hash_string* needs to be rehashed with updated parameters.
Returns True if the hash was not produced with the current parameters
of the hasher, indicating it should be rehashed on next login.
Args:
hash_string: Argon2id hash string to check.
Returns:
``True`` if the hash parameters should be upgraded.
"""
return _PH.check_needs_rehash(hash_string)
+508
View File
@@ -0,0 +1,508 @@
"""TypedDict schemas for every state collector's return value.
Single source of truth for the state-store data shapes. The Markdown
reference is ``docs/state-model.md``.
"""
from typing import Any, TypedDict
__all__ = [
"AcmeAccount",
"AcmeCert",
"AcmeState",
"CpuLoad",
"DnsmasqDhcpLease",
"DnsmasqState",
"DnsmasqStatus",
"FirewallInterface",
"FirewallState",
"FirewallZone",
"MemoryStats",
"NetworkdInterface",
"NetworkdState",
"NginxDomain",
"NginxState",
"SwapStats",
"SystemState",
"TrafficStats",
"WgClassStatus",
"WgPeer",
"WgState",
"WgStatus",
"WgStatusPeer",
]
# ── Shared notes ───────────────────────────────────────────────
# Every collector return carries a top-level `timestamp` (ISO-8601).
# Subsystems with a declarative config expose pending state as a
# status dict: `status: {"pending_changes": bool}`, except firewall,
# which uses `pending: {config_pending() result}`.
# ── Firewall ──────────────────────────────────────────────────
class FirewallInterface(TypedDict):
"""A network interface as parsed from `ip link` / `ip addr`.
Attributes:
name: Interface name (e.g. "eth0").
mac: MAC address, or ``None`` if unavailable.
state: Link state from `ip link` ("UP", "DOWN", "UNKNOWN", ...).
mtu: MTU value, or ``None`` if unavailable.
ips: IPv4 addresses as "ip/prefix" strings.
ipv6: IPv6 addresses as "ip/prefix" strings.
zone: Assigned firewalld zone name, or ``None``.
"""
name: str
mac: str | None
state: str
mtu: int | None
ips: list[str]
ipv6: list[str]
zone: str | None
class FirewallZone(TypedDict, total=False):
"""A firewalld zone as parsed from `--list-all-zones`.
The zone dict carries the HYPHENATED key "rich-rules" (see
``lib.firewall._parse_all_zones_output``), which TypedDict fields
cannot express. Additional firewalld keys may also appear:
"sources", "ports", "protocols", "forward-ports", "ics",
"icmp-blocks", "module", "rich-rules".
"""
target: str
interfaces: list[str]
services: list[str]
masquerade: bool
# snake_case `rich_rules` exists only at the top-level FirewallState
# (collector re-derivation, lib/state.py); the zone dict itself uses
# the hyphenated "rich-rules" key.
rich_rules: list[str]
class FirewallState(TypedDict):
"""Complete firewalld state (collector: `_collect_firewall`).
Attributes:
config: Contents of config/firewall/config.json.
active_zones: Zone name assigned interfaces.
default_zone: firewalld default zone name ("--get-default-zone");
catch-all zone for interfaces with no explicit assignment.
interfaces: All system interfaces (see FirewallInterface).
available_services: firewalld service catalog ("--get-services").
zones: All zones as runtime dicts (see FirewallZone).
rich_rules: Zone name raw firewalld rich-rule strings.
pending: config_pending() result:
``{pending: [...], needs_apply: bool,
unmanaged_zones: {zone: {interfaces: [...]}}}``.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
active_zones: dict[str, list[str]]
default_zone: str
interfaces: list[FirewallInterface]
available_services: list[str]
zones: dict[str, FirewallZone]
rich_rules: dict[str, list[str]]
pending: dict[str, Any]
timestamp: str
# ── Dnsmasq ───────────────────────────────────────────────────
class DnsmasqDhcpLease(TypedDict):
"""A single dnsmasq DHCP lease from the lease file.
Attributes:
expires: ISO-8601 expiry timestamp, or "" if unparseable.
mac: Client MAC address.
ip: Leased IP address.
hostname: Client hostname (may be "").
interface: Interface the lease was granted on (may be "").
"""
expires: str
mac: str
ip: str
hostname: str
interface: str
class PendingChange(TypedDict):
"""One field-level difference between the applied config and the current
saved config (see `lib.common.deep_diff`).
Attributes:
path: Dotted (or indexed) path to the changed field.
action: "added", "removed", or "changed".
old: Value in the last applied config (None when added).
new: Value in the current config (None when removed).
"""
path: str
action: str
old: Any
new: Any
class DnsmasqStatus(TypedDict):
"""Dnsmasq service status snapshot.
Attributes:
service_active: Whether the dnsmasq systemd unit is active.
config_file_exists: Whether the rendered .conf is on disk.
active_leases: Count of currently active leases.
pending_changes: Whether the config is dirty vs the applied state.
pending_diff: Field-level changes since the last apply (empty when
up to date or when no applied snapshot is recorded).
"""
service_active: bool
config_file_exists: bool
active_leases: int
pending_changes: bool
pending_diff: list[PendingChange]
class DnsmasqState(TypedDict):
"""Dnsmasq state (collector: `_collect_dnsmasq`).
Attributes:
config: config/dnsmasq/config.json, deep-merged with defaults.
status: Service/config status (see DnsmasqStatus).
leases: Active DHCP leases.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
status: DnsmasqStatus
leases: list[DnsmasqDhcpLease]
timestamp: str
# ── Nginx ─────────────────────────────────────────────────────
class NginxDomain(TypedDict, total=False):
"""One flattened domain+path entry (see `_resolve_paths`).
Attributes:
domain: Domain name (config key).
path: Path prefix for this entry.
backend: Resolved backend config dict.
online: Whether a site .conf exists on disk.
force_ssl: Redirect-to-HTTPS flag.
backend_name: Backend config key this domain points at.
cert: Certificate reference (e.g. "acme", "selfsigned", ...).
is_management: Management UI path marker.
is_websocket: WebSocket-capable path marker.
"""
domain: str
path: str
backend: dict[str, Any]
online: bool
force_ssl: bool
backend_name: str
cert: str | None
is_management: bool
is_websocket: bool
class NginxState(TypedDict):
"""Nginx state (collector: `_collect_nginx`).
Attributes:
config: config/nginx/config.json.
domains: Flattened domain entries (one per domain+path).
status: ``{"pending_changes": bool}``.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
domains: list[NginxDomain]
status: dict[str, Any]
timestamp: str
# ── ACME ──────────────────────────────────────────────────────
class AcmeAccount(TypedDict):
"""ACME account status (see `_parse_account_conf`).
Attributes:
registered: Whether an account exists.
email: Registered email ("" when not registered).
ca: Human-readable CA name ("" when not registered).
key_length: Account key size, or ``None``.
"""
registered: bool
email: str
ca: str
key_length: int | None
class AcmeCert(TypedDict, total=False):
"""One certificate entry from `list_certs()` output.
Attributes:
domain: Certificate domain name.
expiry: Expiry date string.
renewed: Last renewal date string.
status: "valid" | "expired" | "active" | ...
days_remaining: Days until expiry.
Additional keys from `lib.acme.list_certs()` output may appear.
"""
domain: str
expiry: str
renewed: str
status: str
days_remaining: int
class AcmeState(TypedDict):
"""ACME state (collector: `_collect_acme`).
Attributes:
certs: Certificate list.
email: Registered ACME email.
account: Account status (see AcmeAccount).
timestamp: ISO-8601 collection time.
"""
certs: list[AcmeCert]
email: str
account: AcmeAccount
timestamp: str
# ── WireGuard ─────────────────────────────────────────────────
# The collector tracks BOTH a legacy single interface (wg0) and one
# wg-<class> interface per access class.
class WgStatusPeer(TypedDict):
"""A runtime peer parsed from `wg show` output.
Attributes:
public_key: Peer public key.
endpoint: Last-seen endpoint, or ``None``.
allowed_ips: Allowed IP/CIDR list.
latest_handshake: Human-readable last handshake time, or ``None``.
transfer_received: Received-bytes string from the transfer line.
transfer_sent: Sent-bytes string from the transfer line.
persistent_keepalive: Keepalive seconds, or ``None``.
"""
public_key: str
endpoint: str | None
allowed_ips: list[str]
latest_handshake: str | None
transfer_received: str
transfer_sent: str
persistent_keepalive: int | None
class WgClassStatus(TypedDict):
"""Runtime status for one wg-<class> interface.
Attributes:
up: Whether the class interface is up.
interface: ``{public_key, listen_port}`` (empty when down).
peers: Runtime peers of this class interface.
"""
up: bool
interface: dict[str, Any]
peers: list[WgStatusPeer]
class WgStatus(TypedDict):
"""Aggregated WireGuard runtime status.
Attributes:
up: True when any managed interface is up.
interface: Legacy single-interface info (public_key, listen_port).
peers: Legacy single-interface runtime peers.
classes: Per-access-class runtime status (keyed by class name).
pending_changes: Whether the config is dirty vs the applied state.
pending_diff: Field-level changes since the last apply (empty when
up to date or when no applied snapshot is recorded).
"""
up: bool
interface: dict[str, Any]
peers: list[WgStatusPeer]
classes: dict[str, WgClassStatus]
pending_changes: bool
pending_diff: list[PendingChange]
class WgPeer(TypedDict, total=False):
"""A config-file peer (``private_key`` stripped).
Attributes:
name: Peer config key.
public_key: Peer public key.
endpoint: Configured endpoint, or ``None``.
allowed_ips: Allowed IP/CIDR list.
persistent_keepalive: Keepalive seconds, or ``None``.
preshared_key: Preshared key, or ``None``.
"""
name: str
public_key: str
endpoint: str | None
allowed_ips: list[str]
persistent_keepalive: int | None
preshared_key: str | None
class WgState(TypedDict):
"""WireGuard state (collector: `_collect_wireguard`).
Attributes:
config: config/wireguard/config.json; ``private_key`` stripped
from the interface AND from every access class.
status: Runtime status (see WgStatus).
peers: Config peers, private keys stripped.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
status: WgStatus
peers: list[WgPeer]
timestamp: str
# ── Networkd ──────────────────────────────────────────────────
# Matches parse_networkctl_status() output exactly (lib/network.py).
# There is a single combined `addresses` list — no separate
# `ipv6_addresses` or `routes` keys.
class NetworkdInterface(TypedDict):
"""One networkd interface as parsed by `parse_networkctl_status`.
Attributes:
addresses: "ip/prefix" entries, IPv4+IPv6 combined.
gateway: Default-route gateway, or ``None``.
dns: Configured DNS server list.
mac: MAC address, or ``None``.
state: OperationalState (e.g. "routable", "degraded", "off").
link: Link type (e.g. "ether", "loopback", ...).
"""
addresses: list[str]
gateway: str | None
dns: list[str]
mac: str | None
state: str
link: str
class NetworkdState(TypedDict):
"""Networkd state (collector: `_collect_networkd`).
Attributes:
config: config/network/config.json.
interfaces: Runtime state keyed by interface name.
status: ``{"pending_changes": bool}``.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
interfaces: dict[str, NetworkdInterface]
status: dict[str, Any]
timestamp: str
# ── System ────────────────────────────────────────────────────
class CpuLoad(TypedDict):
"""CPU load averages (see /proc/loadavg).
Attributes:
load1: 1-minute load average.
load5: 5-minute load average.
load15: 15-minute load average.
"""
load1: float
load5: float
load15: float
class MemoryStats(TypedDict):
"""Memory usage (see /proc/meminfo).
Attributes:
total: Total memory in bytes.
available: Available memory in bytes.
used: Used memory in bytes.
used_pct: Used percentage (0-100), rounded to 0.1.
"""
total: int
available: int
used: int
used_pct: float
class SwapStats(TypedDict):
"""Swap usage (see /proc/meminfo).
Attributes:
total: Total swap in bytes.
used: Used swap in bytes.
used_pct: Used percentage (0-100), rounded to 0.1.
"""
total: int
used: int
used_pct: float
class TrafficStats(TypedDict):
"""Per-interface traffic counters (see /sys/class/net/<iface>/statistics).
Attributes:
rx_bytes: Total bytes received.
tx_bytes: Total bytes transmitted.
rx_packets: Total packets received.
tx_packets: Total packets transmitted.
"""
rx_bytes: int
tx_bytes: int
rx_packets: int
tx_packets: int
class SystemState(TypedDict):
"""System-wide metrics (collector: `_collect_system`).
Attributes:
load: CPU load averages (see CpuLoad).
memory: Memory usage (see MemoryStats).
swap: Swap usage (see SwapStats).
traffic: Per-interface counters keyed by interface name.
timestamp: ISO-8601 collection time.
"""
load: CpuLoad
memory: MemoryStats
swap: SwapStats
traffic: dict[str, TrafficStats]
timestamp: str
+463 -146
View File
@@ -12,10 +12,20 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar
from lib.common import load_json, run, run_proc
from lib import schema
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
deep_diff,
load_json,
run,
run_proc,
strip_apply_meta,
)
from lib.firewall import (
_parse_active_zones,
_parse_zone_output,
_parse_all_zones_output,
)
from lib.firewall import (
config_pending as _config_pending,
@@ -36,6 +46,11 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = {
"wireguard": 10,
"dnsmasq": 10,
"networkd": 10,
"system": 1,
# nginx/acme state derives from config files (and lazy in-place migration
# can rewrite them without a mutation); poll so drift self-heals.
"nginx": 60,
"acme": 300,
}
@@ -63,6 +78,7 @@ class State:
"acme",
"wireguard",
"networkd",
"system",
]
def __init__(self) -> None:
@@ -119,6 +135,17 @@ class State:
"""
return self._data.get(subsystem)
def get_snapshot(self) -> dict[str, dict[str, Any] | None]:
"""Return all subsystem state dicts.
Used for the initial WS snapshot on connect.
Returns:
Dict mapping every subsystem name to its state data
(``None`` when not populated or the last collection failed).
"""
return {name: self._data.get(name) for name in self.SUBSYSTEMS}
def set(self, subsystem: str, data: dict[str, Any] | None) -> None:
"""Set state data for *subsystem*.
@@ -277,58 +304,72 @@ def _strip_volatile(
for k in pop_keys:
stripped.pop(k, None)
for vpath in volatile:
# Determine if this is a list-of-dicts pattern
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker != -1:
# Split into prefix (before []), item keys (after [])
prefix = vpath[:list_marker].split(".")
item_keys = (
vpath[list_marker + 3 :].split(".")
if list_marker + 3 < len(vpath)
else []
)
parent = stripped
for seg in prefix:
_strip_volatile_path(stripped, vpath)
return stripped
def _strip_volatile_item(item: dict[str, Any], keys: list[str]) -> None:
"""Recursively strip volatile keys from *item*, handling nested ``[]`` markers."""
for i, k in enumerate(keys):
if "[]" in k:
base_key = k.replace("[]", "")
rest = keys[i + 1 :]
target = item.get(base_key, [])
if isinstance(target, list):
for t in target:
if isinstance(t, dict):
_strip_volatile_item(t, rest)
elif isinstance(target, dict):
for v in target.values():
if isinstance(v, dict):
_strip_volatile_item(v, rest)
return
elif i == len(keys) - 1:
item[k] = None
return
else:
if isinstance(item, dict) and k in item:
item = item[k]
else:
return
def _strip_volatile_path(stripped: dict[str, Any], vpath: str) -> None:
"""Strip a single volatile path from *stripped*, supporting nested ``[]`` markers."""
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker == -1:
segments = vpath.split(".")
parent = stripped
for i, seg in enumerate(segments):
if i == len(segments) - 1:
if isinstance(parent, dict) and seg in parent:
parent[seg] = None
else:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
break
if isinstance(parent, list):
items = parent
elif isinstance(parent, dict):
logger.debug(
"_strip_volatile: %s resolved to dict, falling back to .values()",
vpath,
)
items = parent.values()
else:
continue
for item in items:
if isinstance(item, dict):
curr = item
for i, ik in enumerate(item_keys):
if i == len(item_keys) - 1:
curr[ik] = None
else:
if isinstance(curr, dict) and ik in curr:
curr = curr[ik]
else:
break
return
return
# Split into prefix and item keys.
prefix = vpath[:list_marker].split(".")
item_keys = (
vpath[list_marker + 3 :].split(".") if list_marker + 3 < len(vpath) else []
)
parent = stripped
for seg in prefix:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
# Scalar/dict path
segments = vpath.split(".")
parent = stripped
for i, seg in enumerate(segments):
if i == len(segments) - 1:
if isinstance(parent, dict) and seg in parent:
parent[seg] = None
else:
if isinstance(parent, dict) and seg in parent:
parent = parent[seg]
else:
break
return stripped
return
if isinstance(parent, list):
items = parent
elif isinstance(parent, dict):
items = list(parent.values())
else:
return
for item in items:
if isinstance(item, dict) and item_keys:
_strip_volatile_item(item, item_keys)
def _diff_layers(
@@ -338,26 +379,36 @@ def _diff_layers(
) -> tuple[bool, bool]:
"""Compare *old* and *new* state using two-layer diff.
Strips ``timestamp`` from both before comparing.
The two-layer strategy distinguishes between:
1. Structural changes (config, topology) triggers full client re-fetch
2. Volatile changes (byte counters, timestamps) triggers lightweight tick
If structural data changed, volatile is suppressed (False) because the
structural change already triggers a full re-fetch, making the volatile
signal redundant.
Args:
old: Previous state data, or ``None`` if not yet populated.
new: New state data from collector.
volatile: Frozenset of volatile field paths.
Returns:
``(structural_changed, volatile_changed)``
``True`` means that layer differs between old and new.
If structural data changed, volatile is always ``False``
(the structural change already triggers a full re-fetch, so
the volatile signal is suppressed).
``(structural_changed, volatile_changed)``.
"""
if old is None:
return (True, True)
# Structural diff: compare with volatile/timestamp fields zeroed
# Structural diff: compare with volatile fields zeroed out, plus timestamp
# removed. If these differ, the configuration or topology has changed.
pop_keys = frozenset(("timestamp",))
old_struct = _strip_volatile(old, volatile, pop_keys)
new_struct = _strip_volatile(new, volatile, pop_keys)
structural = old_struct != new_struct
# Volatile diff: compare without timestamp
# Volatile diff: only relevant if structural is unchanged. Compare full
# data (minus timestamp). If this differs, only volatile fields changed
# (e.g. WireGuard transfer counters), and a lightweight tick suffices.
volatile_changed = False
if not structural:
old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
@@ -394,16 +445,16 @@ def _fp_to_str(fp: dict[str, Any]) -> str:
return "/".join(parts)
def _collect_firewall() -> dict[str, Any]:
def _collect_firewall() -> schema.FirewallState:
"""Return the complete current state of firewalld.
Returns:
Dict containing firewall zones, interfaces, rules, config, and
pending changes.
"""
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
default_zone = run(["firewall-cmd", "--get-default-zone"], sudo=True).strip()
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
link_out = run(["ip", "-o", "link", "show"], sudo=True)
addr_out = run(["ip", "-o", "addr", "show"], sudo=True)
@@ -458,25 +509,25 @@ def _collect_firewall() -> dict[str, Any]:
ifaces = list(iface_map.values())
# Collect all zones in a single call (replaces per-zone loop)
zones: dict[str, dict[str, Any]] = {}
for zn in zone_names:
try:
zones[zn] = _parse_zone_output(
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
)
except Exception:
continue
try:
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
zones = _parse_all_zones_output(all_zones_raw)
except Exception:
pass
# Load config
# Load config (strip apply bookkeeping keys, as the other collectors do)
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
config_data = {}
if fw_config_path.exists():
with contextlib.suppress(Exception):
config_data = load_json(fw_config_path)
config_data = strip_apply_meta(load_json(fw_config_path))
# Pending changes
full_state = {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
@@ -489,6 +540,7 @@ def _collect_firewall() -> dict[str, Any]:
return {
"active_zones": active,
"default_zone": default_zone,
"interfaces": ifaces,
"available_services": services,
"zones": zones,
@@ -516,7 +568,7 @@ register_volatile(
# ---------------------------------------------------------------------------
def _collect_dnsmasq() -> dict[str, Any]:
def _collect_dnsmasq() -> schema.DnsmasqState:
"""Collect dnsmasq status, config, and leases.
Returns:
@@ -525,7 +577,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
CONFIG_PATH = CONFIG_DIR / "config.json"
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
LEASE_FILE = "/var/lib/misc/dnsmasq.leases"
DEFAULT_CFG: dict[str, Any] = {
"dhcp": {"ranges": [], "static_leases": []},
@@ -577,25 +629,38 @@ def _collect_dnsmasq() -> dict[str, Any]:
ts = None
leases.append(
{
"expires_at": ts,
"expires": ts.isoformat() if ts else "",
"mac": parts[1],
"ip": parts[2],
"hostname": parts[3] if len(parts) > 3 else "",
"interface": parts[4] if len(parts) > 4 else "",
}
)
except RuntimeError:
except Exception:
pass
# Check config file on disk
conf_exists = Path(DNSMASQ_CONF).is_file()
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
cfg
)
safe_cfg = strip_apply_meta(cfg)
pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
pending_diff = deep_diff(snap, safe_cfg)
return {
"config": cfg,
"config": safe_cfg,
"status": {
"service_active": service_active,
"config_file_exists": conf_exists,
"active_leases": len(leases),
"pending_changes": pending_changes,
"pending_diff": pending_diff,
},
"leases": leases,
"timestamp": _now_iso(),
@@ -611,7 +676,7 @@ register_collector("dnsmasq", _collect_dnsmasq)
# ---------------------------------------------------------------------------
def _collect_nginx() -> dict[str, Any]:
def _collect_nginx() -> schema.NginxState:
"""Collect nginx config and domains list.
Returns:
@@ -652,10 +717,15 @@ def _collect_nginx() -> dict[str, Any]:
pass
# Build flattened domains list (one entry per path)
from lib.nginx import _resolve_paths as _ngx_resolve_paths
backends = cfg.get("backends", {})
domains: list[dict[str, Any]] = []
for name, dom in cfg.get("domains", {}).items():
if "backend" not in dom:
continue
site = SITES_DIR / f"{name}.conf"
paths = dom.get("paths", {})
paths = _ngx_resolve_paths(dom, backends)
if not paths:
continue
for ppath, pcfg in paths.items():
@@ -665,6 +735,7 @@ def _collect_nginx() -> dict[str, Any]:
"backend": pcfg.get("backend", {}),
"online": site.exists() if SITES_DIR.exists() else False,
"force_ssl": dom.get("force_ssl", True),
"backend_name": dom["backend"],
"cert": dom.get("cert"),
}
if pcfg.get("is_management"):
@@ -673,9 +744,24 @@ def _collect_nginx() -> dict[str, Any]:
entry["is_websocket"] = True
domains.append(entry)
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
cfg
)
safe_cfg = strip_apply_meta(cfg)
nginx_pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
nginx_pending_diff = deep_diff(snap, safe_cfg)
return {
"config": cfg,
"config": safe_cfg,
"domains": domains,
"status": {
"pending_changes": pending_changes,
"pending_diff": nginx_pending_diff,
},
"timestamp": _now_iso(),
}
@@ -800,7 +886,7 @@ def _get_acme_email() -> str:
return _read_acme_email()
def _collect_acme() -> dict[str, Any]:
def _collect_acme() -> schema.AcmeState:
"""Collect ACME certificate list and email.
Returns:
@@ -808,48 +894,10 @@ def _collect_acme() -> dict[str, Any]:
"""
email = _get_acme_email()
certs: list[dict[str, Any]] = []
try:
from lib.acme import (
_days_until,
_has_auto_renew,
_parse_list_output,
_run_acme,
)
from lib.acme import list_certs
raw = _run_acme(["--list"])
entries = _parse_list_output(raw)
acme_home_env = os.environ.get("ACME_HOME", str(PROJECT_DIR / "data" / "acme"))
acme_home = Path(acme_home_env)
for entry in entries:
main = entry.get("main_domain", "")
if not main:
continue
san_domains = [
d.strip()
for d in entry.get("san_domains", "").split(",")
if d.strip() and d.strip().lower() != "no"
]
cert_dir = acme_home / main
days = _days_until(entry.get("renew", ""))
certs.append(
{
"domain": main,
"issuer": entry.get("ca", ""),
"expiry": entry.get("renew", ""),
"days_remaining": days,
"expired": days is not None and days <= 0,
"cert_path": str(cert_dir / "fullchain.cer"),
"key_path": str(cert_dir / f"{main}.key"),
"ca_path": str(cert_dir / "ca.cer"),
"issued_at": entry.get("created", ""),
"expires_at": entry.get("renew", ""),
"days_until_expiry": days,
"auto_renew": _has_auto_renew(main),
"san_domains": san_domains,
}
)
certs = list_certs()
except Exception:
logger.warning(
"ACME state collection failed, returning empty cert list",
@@ -875,11 +923,12 @@ register_collector("acme", _collect_acme)
# ---------------------------------------------------------------------------
def _collect_wireguard() -> dict[str, Any]:
"""Collect WireGuard config, status, and peers.
def _collect_wireguard() -> schema.WgState:
"""Collect WireGuard config, per-class status, and peers.
Returns:
Dict containing interface config, runtime status, and peers.
Dict containing interface config, per-class runtime status,
combined peers, and overall tunnel status.
"""
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
@@ -890,9 +939,12 @@ def _collect_wireguard() -> dict[str, Any]:
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"server_endpoint": "",
"description": "",
"post_up": None,
"post_down": None,
},
"access_classes": {},
"peers": {},
}
@@ -907,11 +959,22 @@ def _collect_wireguard() -> dict[str, Any]:
except Exception:
pass
# Safe config (strip private key)
safe = dict(cfg)
pending_changes = _APPLY_HASH_KEY not in cfg or cfg[_APPLY_HASH_KEY] != config_hash(
cfg
)
# Safe config (strip private keys from interface and access classes)
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
if "access_classes" in safe:
safe["access_classes"] = {}
for ck, cv in cfg.get("access_classes", {}).items():
if isinstance(cv, dict):
entry = dict(cv)
entry.pop("private_key", None)
safe["access_classes"][ck] = entry
# Peers list (safe)
peers: list[dict[str, Any]] = []
@@ -921,35 +984,49 @@ def _collect_wireguard() -> dict[str, Any]:
entry.pop("private_key", None)
peers.append(entry)
# Runtime status
status: dict[str, Any] = {"up": False, "interface": {}, "peers": []}
name = cfg["interface"]["name"]
peer_name = name if isinstance(name, str) else "wg0"
try:
res = run_proc(["wg", "show", peer_name], sudo=True, check=False)
if res.returncode == 0:
# Runtime status — per-class interfaces
status: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
"classes": {},
}
classes = cfg.get("access_classes", {})
any_up = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not isinstance(class_cfg, dict):
continue
ifname = f"wg-{class_key}"
try:
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode != 0:
status["classes"][class_key] = {
"up": False,
"interface": {},
"peers": [],
}
continue
raw = res.stdout.strip()
current_peer: dict[str, Any] | None = None
status_peers: list[dict[str, Any]] = []
class_peers: list[dict[str, Any]] = []
cls_up = False
cls_iface: dict[str, Any] = {}
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
status["up"] = True
status["interface"] = {}
cls_up = True
cls_iface = {}
current_peer = None
continue
if line.startswith("public key:"):
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
cls_iface["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
status["interface"]["listen_port"] = int(
line.split(":", 1)[1].strip()
)
continue
if line.startswith("fwmark:"):
status["interface"]["fwmark"] = line.split(":", 1)[1].strip()
cls_iface["listen_port"] = int(line.split(":", 1)[1].strip())
continue
if line.startswith("peer:"):
cur_key = line.split(":", 1)[1].strip()
@@ -962,7 +1039,7 @@ def _collect_wireguard() -> dict[str, Any]:
"transfer_sent": "0",
"persistent_keepalive": None,
}
status_peers.append(current_peer)
class_peers.append(current_peer)
continue
if current_peer is None:
continue
@@ -985,10 +1062,95 @@ def _collect_wireguard() -> dict[str, Any]:
current_peer["persistent_keepalive"] = int(
line.split(":", 1)[1].strip()
)
status["peers"] = status_peers
status["classes"][class_key] = {
"up": cls_up,
"interface": cls_iface,
"peers": class_peers,
}
if cls_up:
any_up = True
except Exception:
status["classes"][class_key] = {"up": False, "interface": {}, "peers": []}
# Also collect legacy single-interface status
try:
ifname = cfg["interface"].get("name", "wg0")
legacy_peers: list[dict[str, Any]] = []
current_peer: dict[str, Any] | None = None
res = run_proc(["wg", "show", ifname], sudo=True, check=False)
if res.returncode == 0:
raw = res.stdout.strip()
status["up"] = True
status["interface"] = {}
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("interface:"):
status["interface"] = {}
current_peer = None
continue
if line.startswith("public key:"):
status["interface"]["public_key"] = line.split(":", 1)[1].strip()
continue
if line.startswith("listening port:"):
status["interface"]["listen_port"] = int(
line.split(":", 1)[1].strip()
)
continue
if line.startswith("peer:"):
cur_key = line.split(":", 1)[1].strip()
current_peer = {
"public_key": cur_key,
"endpoint": None,
"allowed_ips": [],
"latest_handshake": None,
"transfer_received": "0",
"transfer_sent": "0",
"persistent_keepalive": None,
}
legacy_peers.append(current_peer)
continue
if current_peer is None:
continue
if line.startswith("endpoint:"):
current_peer["endpoint"] = line.split(":", 1)[1].strip()
elif line.startswith("allowed ips:"):
current_peer["allowed_ips"] = (
line.split(":", 1)[1].strip().split(", ")
)
elif line.startswith("latest handshake:"):
current_peer["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
rest = line.split(":", 1)[1].strip().split(", ")
if rest:
current_peer["transfer_received"] = rest[0].strip()
if len(rest) > 1:
current_peer["transfer_sent"] = rest[1].strip()
elif line.startswith("persistent-keepalive:"):
with contextlib.suppress(ValueError):
current_peer["persistent_keepalive"] = int(
line.split(":", 1)[1].strip()
)
status["peers"] = legacy_peers
any_up = True
except Exception:
pass
if any_up:
status["up"] = True
status["pending_changes"] = pending_changes
pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
# `safe` has private keys stripped; drop any private-key paths so
# the pending summary never exposes key material.
pending_diff = [
d for d in deep_diff(snap, safe) if "private_key" not in d["path"]
]
status["pending_diff"] = pending_diff
return {
"config": safe,
"status": status,
@@ -1005,6 +1167,9 @@ register_volatile(
"status.peers[].transfer_received",
"status.peers[].transfer_sent",
"status.peers[].latest_handshake",
"status.classes[].peers[].transfer_received",
"status.classes[].peers[].transfer_sent",
"status.classes[].peers[].latest_handshake",
}
),
)
@@ -1014,28 +1179,57 @@ register_volatile(
# ---------------------------------------------------------------------------
def _collect_networkd() -> dict[str, Any]:
def _collect_networkd() -> schema.NetworkdState:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output.
Returns empty data if networkctl is not available.
Dict with interface runtime state parsed from networkctl output,
config, and pending changes status.
"""
CONFIG_PATH = PROJECT_DIR / "config" / "network" / "config.json"
# Load config
net_cfg: dict[str, Any] = {}
if CONFIG_PATH.exists():
with contextlib.suppress(Exception):
net_cfg = load_json(CONFIG_PATH)
pending_changes = _APPLY_HASH_KEY not in net_cfg or net_cfg[
_APPLY_HASH_KEY
] != config_hash(net_cfg)
result: dict[str, dict[str, Any]] = {}
safe_net_cfg = strip_apply_meta(net_cfg)
net_status: dict[str, Any] = {"pending_changes": pending_changes}
net_pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = net_cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
net_pending_diff = deep_diff(snap, safe_net_cfg)
net_status["pending_diff"] = net_pending_diff
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {"interfaces": {}, "timestamp": _now_iso()}
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
except Exception:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"config": safe_net_cfg,
"status": net_status,
"timestamp": _now_iso(),
}
@@ -1050,6 +1244,129 @@ register_volatile(
),
)
# ---------------------------------------------------------------------------
# System metrics collector
# ---------------------------------------------------------------------------
def _parse_meminfo() -> dict[str, Any]:
"""Read /proc/meminfo and return dict with key memory stats in bytes."""
info: dict[str, int] = {}
try:
for line in Path("/proc/meminfo").read_text().splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
parts = value.strip().split()
val = int(parts[0])
# Convert kB to bytes
if parts and parts[-1] == "kB":
val *= 1024
info[key] = val
except (OSError, ValueError):
return {}
return info
def _collect_system() -> schema.SystemState:
"""Collect system-wide metrics: CPU load, memory, network traffic.
Reads from /proc and /sys no subprocess needed.
Returns:
Dict with load (1/5/15 min), memory usage, and per-interface traffic.
"""
# CPU load
loads = []
try:
parts = Path("/proc/loadavg").read_text().split()
loads = [float(x) for x in parts[:3]]
except (OSError, ValueError):
loads = [0.0, 0.0, 0.0]
# Memory
meminfo_raw = _parse_meminfo()
mem_total = meminfo_raw.get("MemTotal", 0)
mem_free = meminfo_raw.get("MemFree", 0)
mem_available = meminfo_raw.get("MemAvailable", mem_free)
mem_buffers = meminfo_raw.get("Buffers", 0)
mem_cached = meminfo_raw.get("Cached", 0)
mem_used = mem_total - mem_free - mem_buffers - mem_cached
if mem_used < 0:
mem_used = mem_total - mem_available
# Swap
swap_total = meminfo_raw.get("SwapTotal", 0)
swap_free = meminfo_raw.get("SwapFree", 0)
swap_used = swap_total - swap_free
# Network traffic from /sys/class/net/<iface>/statistics/
traffic: dict[str, dict[str, int]] = {}
try:
net_root = Path("/sys/class/net")
if net_root.is_dir():
for iface_dir in net_root.iterdir():
stats_dir = iface_dir / "statistics"
if not stats_dir.is_dir():
continue
iface_name = iface_dir.name
rx_bytes = 0
tx_bytes = 0
rx_packets = 0
tx_packets = 0
try:
rx_bytes = int((stats_dir / "rx_bytes").read_text().strip())
tx_bytes = int((stats_dir / "tx_bytes").read_text().strip())
rx_packets = int((stats_dir / "rx_packets").read_text().strip())
tx_packets = int((stats_dir / "tx_packets").read_text().strip())
except (OSError, ValueError):
continue
traffic[iface_name] = {
"rx_bytes": rx_bytes,
"tx_bytes": tx_bytes,
"rx_packets": rx_packets,
"tx_packets": tx_packets,
}
except OSError:
pass
return {
"load": {
"load1": loads[0],
"load5": loads[1],
"load15": loads[2],
},
"memory": {
"total": mem_total,
"available": mem_available,
"used": mem_used,
"used_pct": round(mem_used / mem_total * 100, 1) if mem_total > 0 else 0,
},
"swap": {
"total": swap_total,
"used": swap_used,
"used_pct": round(swap_used / swap_total * 100, 1) if swap_total > 0 else 0,
},
"traffic": traffic,
"timestamp": _now_iso(),
}
register_collector("system", _collect_system)
register_volatile(
"system",
frozenset(
{
"load",
"memory",
"swap",
"traffic",
}
),
)
__all__ = [
"_DEFAULT_POLL_INTERVALS",
"State",
+1046
View File
File diff suppressed because it is too large Load Diff
+955
View File
@@ -0,0 +1,955 @@
"""System config import on daemon startup.
On first start, vacuum-walld parses each subsystem's live system config
file and writes the canonical JSON config. This reconciles any drift
caused by install.sh or manual edits to system files.
"""
import contextlib
import logging
import re
from pathlib import Path
from typing import Any
from lib.common import load_json, run, save_json
from lib.firewall import _live_target_to_config, _parse_all_zones_output
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
def _safe_int(val: str) -> int | str:
"""Try to parse *val* as int; return original string on failure."""
with contextlib.suppress(ValueError):
return int(val)
return val
DNSMASQ_CONF = Path("/etc/dnsmasq.d/vacuum-wall.conf")
WG_CONF = Path("/etc/wireguard/wg0.conf")
NETWORKD_DIR = Path("/etc/systemd/network")
NGINX_SITES_DIR = PROJECT_DIR / "data" / "nginx" / "sites-enabled"
DNSTART = "# ---- vacuum-wall managed dnsmasq configuration ----"
DNEND = "# ---- end vacuum-wall config ----"
def import_all() -> list[str]:
"""Run all subsystem imports. Returns list of subsystems that were updated."""
updated: list[str] = []
for fn, name in [
(import_dnsmasq, "dnsmasq"),
(import_firewall, "firewall"),
(import_wireguard, "wireguard"),
(import_networkd, "network"),
(import_nginx, "nginx"),
]:
try:
if fn():
updated.append(name)
except Exception:
logger.warning("Import failed for %s", name, exc_info=True)
return updated
# ------------------------------------------------------------------
# Dnsmasq
# ------------------------------------------------------------------
def import_dnsmasq() -> bool:
"""Parse /etc/dnsmasq.d/vacuum-wall.conf -> config/dnsmasq/config.json."""
if not DNSMASQ_CONF.exists():
logger.debug("Skipping dnsmasq: %s not found", DNSMASQ_CONF)
return False
try:
text = DNSMASQ_CONF.read_text()
except OSError:
logger.debug("Skipping dnsmasq: cannot read %s", DNSMASQ_CONF)
return False
# Extract managed block
start_idx = text.find(DNSTART)
end_idx = text.rfind(DNEND)
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
logger.debug("Skipping dnsmasq: no managed markers found")
return False
block = text[start_idx + len(DNSTART) : end_idx]
try:
cfg = _parse_dnsmasq_block(block)
except Exception:
logger.warning("Failed to parse dnsmasq config", exc_info=True)
return False
cfg_path = PROJECT_DIR / "config" / "dnsmasq" / "config.json"
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping dnsmasq: config already matches")
return False
save_json(cfg_path, cfg)
summary = f"upstreams={len(cfg.get('dns', {}).get('upstreams', []))}, ranges={len(cfg.get('dhcp', {}).get('ranges', []))}"
logger.info("Imported dnsmasq config from %s: %s", DNSMASQ_CONF, summary)
return True
def _parse_dnsmasq_block(block: str) -> dict[str, Any]:
"""Parse a dnsmasq managed block into JSON config dict."""
upstreams: list[str] = []
domains: str | None = None
ranges: list[dict[str, Any]] = []
static_leases: list[dict[str, Any]] = []
custom_records: list[dict[str, Any]] = []
for line in block.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("server="):
upstreams.append(line.split("=", 1)[1].strip())
elif line == "no-resolv":
upstreams = []
elif line.startswith("domain="):
domains = line.split("=", 1)[1].strip()
elif line.startswith("dhcp-range="):
rng = _parse_dhcp_range(line.split("=", 1)[1])
ranges.append(rng)
elif line.startswith("dhcp-option="):
target = _attach_dhcp_option(line.split("=", 1)[1], ranges)
if target:
_set_dhcp_option_option(target, line.split("=", 1)[1])
elif line.startswith("dhcp-host="):
lease = _parse_dhcp_host(line.split("=", 1)[1])
if lease:
static_leases.append(lease)
elif line.startswith("addr/"):
rec = _parse_addr_directive(line)
if rec:
custom_records.append(rec)
elif line.startswith("host-record="):
pass # paired with addr/, skip
return {
"dhcp": {
"ranges": ranges,
"static_leases": static_leases,
},
"dns": {
"upstreams": upstreams,
"domain": domains,
"custom_records": custom_records,
},
}
def _parse_dhcp_range(value: str) -> dict[str, Any]:
"""Parse dhcp-range value into dict."""
parts = value.split(",")
if parts[0].startswith("set:"):
iface = parts[0][4:]
addr_parts = parts[1:4]
else:
iface = None
addr_parts = parts[0:3]
rng: dict[str, Any] = {}
if iface:
rng["interface"] = iface
rng["start"] = addr_parts[0] if len(addr_parts) > 0 else ""
rng["end"] = addr_parts[1] if len(addr_parts) > 1 else ""
rng["lease_time"] = addr_parts[2] if len(addr_parts) > 2 else "12h"
return rng
def _attach_dhcp_option(
value: str, ranges: list[dict[str, Any]]
) -> dict[str, Any] | None:
"""Parse dhcp-option and return the target range to attach to."""
m = re.match(r"tag:([^,]+),(\d+),(.+)", value)
if not m:
return None
tag_iface = m.group(1)
for rng in reversed(ranges):
if rng.get("interface") == tag_iface:
return rng
return None
def _set_dhcp_option_option(target: dict[str, Any], value: str) -> None:
"""Parse dhcp-option value string and set gateway/dns on target range."""
m = re.match(r"tag:[^,]+,(\d+),(.+)", value)
if not m:
return
option_code = m.group(1)
option_val = m.group(2).strip()
if option_code == "3":
target["gateway"] = option_val
elif option_code == "6":
target["dns"] = option_val
def _parse_dhcp_host(value: str) -> dict[str, Any] | None:
"""Parse dhcp-host value into dict."""
parts = value.split(",")
if len(parts) < 2:
return None
lease: dict[str, Any] = {
"mac": parts[0].strip().lower(),
"ip": parts[1].strip(),
}
if len(parts) >= 3:
lease["hostname"] = parts[2].strip()
return lease
def _parse_addr_directive(line: str) -> dict[str, Any] | None:
"""Parse addr/name/addr into dict."""
parts = line.split("/")
if len(parts) < 3:
return None
return {"name": parts[1].strip(), "address": parts[2].strip()}
# ------------------------------------------------------------------
# WireGuard
# ------------------------------------------------------------------
def import_wireguard() -> bool:
"""Parse /etc/wireguard/wg0.conf -> config/wireguard/config.json."""
try:
exists = WG_CONF.exists()
except OSError:
# Parent dir may be unreadable to the daemon user (e.g. /etc/wireguard
# is 0700). Treat as not present rather than failing the import.
logger.debug("Skipping wireguard: cannot stat %s", WG_CONF)
return False
if not exists:
logger.debug("Skipping wireguard: %s not found", WG_CONF)
return False
try:
text = WG_CONF.read_text()
except OSError:
logger.debug("Skipping wireguard: cannot read %s", WG_CONF)
return False
try:
cfg = _parse_wireguard_conf(text)
except Exception:
logger.warning("Failed to parse wireguard config", exc_info=True)
return False
cfg_path = PROJECT_DIR / "config" / "wireguard" / "config.json"
if cfg_path.exists():
existing = load_json(cfg_path)
if _cfgs_equal(existing, cfg):
logger.debug("Skipping wireguard: config already matches")
return False
save_json(cfg_path, cfg)
peer_count = len(cfg.get("peers", {}))
logger.info("Imported wireguard config from %s: peers=%d", WG_CONF, peer_count)
return True
def _parse_wireguard_conf(text: str) -> dict[str, Any]:
"""Parse wg-quick INI format into JSON config dict.
Uses a simple state machine: [Interface] section populates the interface
dict; each [Peer] section accumulates into current_peer until the next
section header triggers _flush_peer() to commit it.
"""
interface: dict[str, Any] = {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"post_up": None,
"post_down": None,
}
peers: dict[str, dict[str, Any]] = {}
current_section = None
current_peer_name = None
current_peer: dict[str, Any] | None = None
def _flush_peer() -> None:
"""Flush the current peer dict into the peers map if it has a public key.
Resets ``current_peer`` and ``current_peer_name`` to ``None``,
preparing for the next [Peer] section.
Note:
Only peers with a ``public_key`` are stored; sections without
a key (malformed or incomplete) are silently skipped.
"""
nonlocal current_peer, current_peer_name
if (
current_peer is not None
and current_peer_name
and current_peer.get("public_key")
):
peers[current_peer_name] = current_peer
current_peer = None
current_peer_name = None
for line in text.splitlines():
stripped = line.strip()
# Section headers — allow trailing content like comments
m = re.match(r"^\[(\w+)\](.*)?$", stripped)
if m:
section_name = m.group(1)
if section_name == "Interface":
current_section = "interface"
_flush_peer()
elif section_name == "Peer":
current_section = "peer"
_flush_peer()
# Look for name in comment after [Peer]
m2 = re.match(r"^\[Peer\]\s*#\s*(.+)", stripped)
current_peer_name = m2.group(1).strip() if m2 else None
current_peer = {}
else:
current_section = None
_flush_peer()
continue
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip()
if current_section == "interface":
if key == "PrivateKey":
interface["private_key"] = val
elif key == "Address":
interface["addresses"] = [a.strip() for a in val.split(",")]
elif key == "ListenPort":
parsed = _safe_int(val)
if isinstance(parsed, int):
interface["listen_port"] = parsed
elif key == "PostUp":
interface["post_up"] = val
elif key == "PostDown":
interface["post_down"] = val
elif current_section == "peer" and current_peer is not None:
if key == "PublicKey":
current_peer["public_key"] = val
if current_peer_name is None:
current_peer_name = val
elif key == "PresharedKey":
current_peer["preshared_key"] = val
elif key == "Endpoint":
current_peer["endpoint"] = val
elif key == "AllowedIPs":
current_peer["allowed_ips"] = [a.strip() for a in val.split(",")]
elif key == "PersistentKeepalive":
parsed = _safe_int(val)
current_peer["persistent_keepalive"] = (
parsed if isinstance(parsed, int) else None
)
# Flush any remaining peer
if (
current_peer is not None
and current_peer_name
and current_peer.get("public_key")
):
peers[current_peer_name] = current_peer
return {
"interface": interface,
"peers": peers,
}
# ------------------------------------------------------------------
# Networkd
# ------------------------------------------------------------------
def import_networkd() -> bool:
"""Parse /etc/systemd/network/*.network -> config/network/config.json."""
if not NETWORKD_DIR.exists():
logger.debug("Skipping network: %s not found", NETWORKD_DIR)
return False
network_files = sorted(NETWORKD_DIR.glob("*.network"))
if not network_files:
logger.debug("Skipping network: no *.network files")
return False
parsed_interfaces: dict[str, dict[str, Any]] = {}
for nf in network_files:
# Extract interface name from filename, stripping optional priority prefix:
# 99-eth0.network -> eth0
# eth0.network -> eth0
base = nf.stem
if "-" in base and base.split("-", 1)[0].isdigit():
iface_name = base.split("-", 1)[1]
else:
iface_name = base
try:
parsed_interface = _parse_network_file(nf)
if parsed_interface:
parsed_interfaces[iface_name] = parsed_interface
except Exception:
logger.warning("Failed to parse %s", nf, exc_info=True)
if not parsed_interfaces:
logger.debug("Skipping network: no valid .network files")
return False
cfg_path = PROJECT_DIR / "config" / "network" / "config.json"
existing: dict[str, Any] = load_json(cfg_path, {"interfaces": {}})
interfaces_cfg = existing.setdefault("interfaces", {})
# Only add/update interfaces with .network files; don't remove interfaces
# without a file (they may be pending apply).
changed = False
for name, entry in parsed_interfaces.items():
if name not in interfaces_cfg or not _cfgs_equal(interfaces_cfg[name], entry):
interfaces_cfg[name] = entry
changed = True
if not changed:
logger.debug("Skipping network: config already matches")
return False
save_json(cfg_path, existing)
logger.info(
"Imported network config from %s/*.network: interfaces=%s",
NETWORKD_DIR,
", ".join(parsed_interfaces.keys()),
)
return True
def _parse_network_file(path: Path) -> dict[str, Any] | None:
"""Parse a .network INI file into interface config dict.
State machine: [Match] section is skipped; [Link] keys go to iface["link"];
[Network] keys go directly on iface. Numbered sections ([Address#N], [Route#N])
accumulate into cur_addr / cur_route dicts until a section boundary triggers
_flush() to commit them into the corresponding list.
"""
text = path.read_text()
iface: dict[str, Any] = {}
cur_section: str | None = None
cur_addr: dict[str, Any] | None = None
cur_route: dict[str, Any] | None = None
def _flush() -> None:
"""Commit accumulated address/route dicts into the iface lists."""
nonlocal cur_addr, cur_route
if cur_addr is not None:
if "address" in cur_addr and len(cur_addr) == 1:
iface.setdefault("addresses", []).append(cur_addr["address"])
elif cur_addr:
iface.setdefault("addresses", []).append(cur_addr)
cur_addr = None
if cur_route is not None and cur_route:
iface.setdefault("routes", []).append(cur_route)
cur_route = None
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
m = re.match(r"^\[(.+)\]$", stripped)
if m:
_flush()
sec = m.group(1)
if sec.startswith("Address"):
cur_section = sec
cur_addr = {}
elif sec.startswith("Route"):
cur_section = sec
cur_route = {}
else:
cur_section = sec
continue
if "=" not in stripped:
continue
key, _, val = stripped.partition("=")
key = key.strip()
val = val.strip()
if cur_addr is not None:
cur_addr = _parse_addr_key(cur_addr, key, val)
continue
if cur_route is not None:
cur_route = _parse_route_key(cur_route, key, val)
continue
_parse_network_section(iface, cur_section, key, val)
_flush()
return iface if iface else None
def _parse_addr_key(entry: dict[str, Any], key: str, val: str) -> dict[str, Any]:
"""Parse an [Address] key-value pair, return updated entry."""
if key == "Address":
entry["address"] = val
elif key == "Label":
entry["label"] = val
elif key == "Scope":
entry["scope"] = val
elif key == "RouteMetric":
parsed = _safe_int(val)
if isinstance(parsed, int):
entry["route_metric"] = parsed
elif key == "DuplicateAddressDetection":
entry["duplicate_address_detection"] = val
elif key == "ManageTemporaryAddress":
entry["manage_temporary_address"] = _parse_bool(val)
elif key == "AddPrefixRoute":
entry["add_prefix_route"] = _parse_bool(val)
return entry
def _parse_route_key(entry: dict[str, Any], key: str, val: str) -> dict[str, Any]:
"""Parse a [Route] key-value pair, return updated entry."""
if key == "Destination":
entry["destination"] = val
elif key == "Gateway":
entry["gateway"] = val
elif key == "Metric":
parsed = _safe_int(val)
if isinstance(parsed, int):
entry["metric"] = parsed
elif key == "Table":
entry["table"] = val
elif key == "Type":
entry["type"] = val
elif key == "Scope":
entry["scope"] = val
elif key == "GatewayOnLink":
entry["gateway_on_link"] = _parse_bool(val)
elif key == "IPv6Preference":
entry["ipv6_preference"] = val
elif key == "MTUBytes":
parsed = _safe_int(val)
if isinstance(parsed, int):
entry["mtu_bytes"] = parsed
return entry
def _parse_network_section(
iface: dict[str, Any], section: str | None, key: str, val: str
) -> None:
"""Parse a [Match]/[Link]/[Network] key-value pair into iface dict."""
if section == "Match":
return
if section == "Link":
link = iface.setdefault("link", {})
_set_link_key(link, key, val)
return
if section == "Network":
_set_network_key(iface, key, val)
return
def _set_link_key(link: dict[str, Any], key: str, val: str) -> None:
"""Parse a [Link] section key-value pair and set the corresponding config field.
Maps systemd-networkd Link INI keys to snake_case config keys.
Boolean keys (ARP, Multicast, etc.) are auto-converted via ``_parse_bool``.
Args:
link: Link config dict to populate.
key: INI key name from the .network file.
val: Value string from the .network file.
"""
if key == "MTUBytes":
parsed = _safe_int(val)
if isinstance(parsed, int):
link["mtu_bytes"] = parsed
elif key == "MACAddress":
link["mac_address"] = val
elif key in ("ARP", "Multicast", "AllMulticast", "Promiscuous", "Unmanaged"):
pk = {
"ARP": "arp",
"Multicast": "multicast",
"AllMulticast": "all_multicast",
"Promiscuous": "promiscuous",
"Unmanaged": "unmanaged",
}[key]
link[pk] = _parse_bool(val)
elif key == "ActivationPolicy":
link["activation_policy"] = val
elif key == "RequiredForOnline":
link["required_for_online"] = val
def _set_network_key(iface: dict[str, Any], key: str, val: str) -> None:
"""Parse a [Network] section key-value pair and set the corresponding config field.
Maps systemd-networkd Network INI keys to snake_case config dict keys.
Comma-separated values (DNS, Domains, etc.) are split into lists.
Boolean and integer keys are auto-converted.
Args:
iface: Interface config dict to populate.
key: INI key name from the .network file.
val: Value string from the .network file.
"""
if key == "DHCP":
iface["dhcp"] = val
elif key == "Gateway":
iface["gateway"] = val
elif key == "IPv6Gateway":
iface["ipv6_gateway"] = val
elif key == "DNS":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("dns", []).append(d)
elif key == "IPv6DNS":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("ipv6_dns", []).append(d)
elif key == "Domains":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("domains", []).append(d)
elif key == "IPv6Domains":
for d in val.split(","):
d = d.strip()
if d:
iface.setdefault("ipv6_domains", []).append(d)
elif key == "DNSDefaultRoute":
iface["dns_default_route"] = _parse_bool(val)
elif key == "BindCarrier":
iface.setdefault("bind_carrier", []).append(val)
elif key == "IgnoreCarrierLoss":
iface["ignore_carrier_loss"] = val
elif key == "KeepConfiguration":
iface["keep_configuration"] = val
elif key == "ConfigureWithoutCarrier":
iface["configure_without_carrier"] = _parse_bool(val)
elif key == "LinkLocalAddressing":
iface["link_local_addressing"] = val
elif key == "IPv6LinkLocalAddressGenerationMode":
iface["ipv6_link_local_address_generation_mode"] = val
elif key == "IPv6StableSecretAddress":
iface["ipv6_stable_secret_address"] = val
elif key == "IPv4LLStartAddress":
iface["ipv4_ll_start_address"] = val
elif key == "IPv4LLRoute":
iface["ipv4_ll_route"] = _parse_bool(val)
elif key == "DefaultRouteOnDevice":
iface["default_route_on_device"] = _parse_bool(val)
elif key == "IPv6HopLimit":
parsed = _safe_int(val)
if isinstance(parsed, int):
iface["ipv6_hop_limit"] = parsed
elif key == "IPv6RetransmissionTimeSec":
iface["ipv6_retransmission_time_sec"] = val
elif key == "IPv4DuplicateAddressDetectionTimeoutSec":
iface["ipv4_duplicate_address_detection_timeout_sec"] = val
elif key == "IPv4ReversePathFilter":
iface["ipv4_reverse_path_filter"] = val
elif key == "IPv4AcceptLocal":
iface["ipv4_accept_local"] = _parse_bool(val)
elif key == "IPv4RouteLocalnet":
iface["ipv4_route_localnet"] = _parse_bool(val)
elif key == "IPv4ProxyARP":
iface["ipv4_proxy_arp"] = _parse_bool(val)
elif key == "IPv4ProxyARPPrivateVLAN":
iface["ipv4_proxy_arp_private_vlan"] = _parse_bool(val)
elif key == "IPv6ProxyNDP":
iface["ipv6_proxy_ndp"] = _parse_bool(val)
elif key == "IPv6ProxyNDPAddress":
iface["ipv6_proxy_ndp_address"] = val
elif key == "IPv6SendRA":
iface["ipv6_send_ra"] = _parse_bool(val)
elif key == "MPLSRouting":
iface["m_pls_routing"] = _parse_bool(val)
elif key == "KeepMaster":
iface["keep_master"] = _parse_bool(val)
elif key == "IPFamily":
iface["ip_family"] = val
def _parse_bool(val: str) -> bool | str:
"""Parse common boolean representations to bool, return as-is otherwise."""
if val.lower() in ("yes", "true", "1", "on"):
return True
if val.lower() in ("no", "false", "0", "off"):
return False
return val
# ------------------------------------------------------------------
# Nginx
# ------------------------------------------------------------------
def import_nginx() -> bool:
"""Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json.
Bootstraps config.json on hosts that already have rendered sites
(repo reinstalled over an existing data/ dir). If the declarative
config already exists it wins: sites are vacuum-wall's own generated
output ("do not edit manually") and re-parsing them is lossy backend
references get flattened to inline paths, which render empty nginx
sites and hide domains from the WebUI.
"""
cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json"
if cfg_path.exists():
logger.debug("Skipping nginx: %s already exists", cfg_path)
return False
if not NGINX_SITES_DIR.exists():
logger.debug("Skipping nginx: %s not found", NGINX_SITES_DIR)
return False
conf_files = sorted(NGINX_SITES_DIR.glob("*.conf"))
sites: dict[str, dict[str, Any]] = {}
for cf in conf_files:
# Skip acme challenge
if cf.name == "_acme-challenge.conf":
continue
domain = cf.stem
if not domain:
continue
try:
parsed = _parse_nginx_site(cf, domain)
if parsed:
sites[domain] = parsed
except Exception:
logger.warning("Failed to parse nginx site %s", cf, exc_info=True)
if not sites:
logger.debug("Skipping nginx: no valid site files")
return False
existing: dict[str, Any] = load_json(cfg_path, {"domains": {}, "ssl": {}})
domains_cfg = existing.setdefault("domains", {})
changed = False
for name, entry in sites.items():
if name not in domains_cfg or not _cfgs_equal(domains_cfg[name], entry):
domains_cfg[name] = entry
changed = True
if not changed:
logger.debug("Skipping nginx: config already matches")
return False
save_json(cfg_path, existing)
logger.info(
"Imported nginx config from %s/*.conf: domains=%s",
NGINX_SITES_DIR,
", ".join(sites.keys()),
)
return True
def _parse_nginx_site(path: Path, domain: str) -> dict[str, Any] | None:
"""Parse a vacuum-wall nginx site conf into domain config dict."""
text = path.read_text()
# Check it's our file
if "# Auto-generated by Vacuum Wall" not in text:
logger.debug("Skipping nginx site %s: not vacuum-wall generated", path)
return None
# Determine force_ssl: look for port 80 redirect block
force_ssl = bool(re.search(r"listen\s+80\b", text))
# Parse domain-level auth
domain_auth = None
auth_match = re.search(
r"auth_basic\s+.*;\s*\n\s*auth_basic_user_file\s+(.+?);", text
)
if auth_match:
domain_auth = {"htpasswd": auth_match.group(1).strip()}
# Infer cert type
cert = _infer_cert_type(text, domain)
# Parse location blocks
paths: dict[str, dict[str, Any]] = {}
_parse_location_blocks(text, paths, domain_auth)
entry: dict[str, Any] = {
"force_ssl": force_ssl,
"paths": paths,
}
if cert:
entry["cert"] = cert
if domain_auth:
entry["auth"] = domain_auth
return entry
def _infer_cert_type(text: str, domain: str) -> str | None:
"""Infer cert type from ssl_certificate path."""
m = re.search(r"ssl_certificate\s+(.+?);", text)
if not m:
return None
cert_path = m.group(1).strip()
if "acme" in cert_path:
return "acme"
elif "data/certs" in cert_path:
return "selfsigned"
else:
return "file"
def _parse_location_blocks(
text: str, paths: dict[str, dict[str, Any]], domain_auth: dict[str, Any] | None
) -> None:
"""Extract location blocks and their annotations."""
# Match annotation comment before location block
# # /path -> host:port (WebSocket)
# # /path -> host:port
loc_re = re.compile(
r"#\s*(/[\S]*)\s*->\s*(\S+?)(?:\s*\(WebSocket\))?\s*\n"
r"\s*location\s+\1\s*\{",
re.MULTILINE,
)
for m in loc_re.finditer(text):
ppath = m.group(1)
backend_str = m.group(2)
is_ws = "WebSocket" in m.group(0)
host, _, port_str = backend_str.partition(":")
try:
port = int(port_str)
except ValueError:
port = 80
is_websocket = is_ws
# Check for auth_basic off inside this location block
# Find the location block for this path
block_start = m.end()
# Find matching closing brace
depth = 1
idx = block_start
while idx < len(text) and depth > 0:
if text[idx] == "{":
depth += 1
elif text[idx] == "}":
depth -= 1
idx += 1
block_text = text[block_start:idx]
entry: dict[str, Any] = {
"backend": {
"host": host,
"port": port,
"proto": "http",
},
}
if is_websocket:
entry["is_websocket"] = True
elif "auth_basic off" in block_text:
entry["auth"] = None
if "is_management" in block_text or (
"/api" not in ppath and "management" in block_text.lower()
):
entry["is_management"] = True
# Check for WebSocket path
if ppath == "/ws":
entry["is_websocket"] = True
paths[ppath] = entry
# ------------------------------------------------------------------
# Firewall
# ------------------------------------------------------------------
def import_firewall() -> bool:
"""Run firewall-cmd --list-all-zones -> config/firewall/config.json."""
cfg_path = PROJECT_DIR / "config" / "firewall" / "config.json"
if cfg_path.exists():
logger.debug("Skipping firewall: config already exists at %s", cfg_path)
return False
try:
output = run(["firewall-cmd", "--list-all-zones"], sudo=True)
except RuntimeError:
logger.warning("Import failed for firewall: firewall-cmd unavailable")
return False
try:
zones = _parse_all_zones_output(output)
except Exception:
logger.warning("Failed to parse firewall zones", exc_info=True)
return False
zone_configs = {
zone_name: {
"target": _live_target_to_config(parsed["target"]),
"interfaces": parsed["interfaces"],
"services": parsed["services"],
"masquerade": parsed["masquerade"],
"rich_rules": [{"rule": r} for r in parsed["rich-rules"]],
"forward_ports": parsed["forward-ports"],
}
for zone_name, parsed in zones.items()
if parsed["interfaces"]
}
if not zone_configs:
logger.debug("Skipping firewall: no zones with interfaces")
return False
save_json(cfg_path, {"zones": zone_configs})
logger.info(
"Imported firewall config: zones=%s",
", ".join(zone_configs.keys()),
)
return True
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _cfgs_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
"""Compare two configs ignoring apply bookkeeping keys."""
from lib.common import strip_apply_meta
return strip_apply_meta(a) == strip_apply_meta(b)
+409
View File
@@ -0,0 +1,409 @@
"""WebAuthn passkey support for Vacuum Wall.
Uses the Duo Labs webauthn library (v3) to handle the FIDO2/WebAuthn ceremony:
registration, authentication, and credential management.
Credential data is stored in the database webauthn_creds table.
Configuration comes from config/auth/config.json (webauthn section).
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Any
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import (
AttestationConveyancePreference,
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
from lib.auth import AUTH_CONFIG_PATH
from lib.common import load_json
from lib.db import (
Q_DELETE_WEBAUTHN,
Q_INSERT_WEBAUTHN,
Q_SELECT_WEBAUTHN_COUNTS,
Q_SELECT_WEBAUTHN_ID,
Q_SELECT_WEBAUTHN_USER,
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
get_db,
)
logger = logging.getLogger(__name__)
# Crypto algorithms we support
_SUPPORTED_ALGS = [
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_384,
COSEAlgorithmIdentifier.ECDSA_SHA_512,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_512,
COSEAlgorithmIdentifier.EDDSA,
]
# b64url helpers
def b64u_encode(b: bytes) -> str:
return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=")
def b64u_decode(s: str) -> bytes:
padding = 4 - len(s) % 4
if padding != 4:
s += "=" * padding
return base64.urlsafe_b64decode(s)
def _get_webauthn_config() -> dict[str, Any]:
"""Load WebAuthn configuration from auth config."""
raw = load_json(AUTH_CONFIG_PATH)
return raw.get("webauthn", {})
def is_enabled() -> bool:
"""Return whether WebAuthn is enabled in config."""
cfg = _get_webauthn_config()
return cfg.get("enabled", True)
def get_rp_name() -> str:
"""Return the Relying Party name from config."""
return _get_webauthn_config().get("rp_name", "Vacuum Wall")
def get_management_domains() -> list[str]:
"""Return domain names eligible for WebAuthn.
Delegates to lib.nginx.get_management_domains() to read the
live proxy config and discover which domains serve the management UI.
"""
from lib.nginx import get_management_domains as _resolve
return _resolve()
def is_domain_valid(domain: str) -> bool:
"""Check if *domain* is eligible for WebAuthn."""
return domain in get_management_domains()
def check_webauthn_config() -> None:
"""Validate WebAuthn config at startup."""
enabled = is_enabled()
if not enabled:
logger.info("WebAuthn is disabled in config")
return
try:
domains = get_management_domains()
if not domains:
logger.warning(
"WebAuthn is enabled but no management domains are configured. "
"Add a domain with backend 'webui' to nginx config, or disable WebAuthn."
)
else:
logger.info("WebAuthn enabled for domains: %s", domains)
except Exception as exc:
logger.error("Failed to resolve management domains for WebAuthn: %s", exc)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def create_registration_options(
username: str,
origin: str,
rp_id: str,
) -> dict[str, Any]:
"""Create WebAuthn registration options for a new credential.
Args:
username: The user registering the credential.
origin: WebAuthn origin (must match the request origin).
rp_id: Relying Party ID (must match the request domain).
Returns a dict serializable to JSON, matching the format expected by
``navigator.credentials.create()``.
"""
# Load existing credential IDs to exclude
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
exclude_credentials = [
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
for row in rows
]
# Pad username to >= 8 bytes (required for user_id)
user_id = username.encode("utf-8")
if len(user_id) < 8:
user_id = user_id + b"\x00" * (8 - len(user_id))
options = generate_registration_options(
rp_id=rp_id,
rp_name=get_rp_name(),
user_name=username,
user_display_name=username,
user_id=user_id,
attestation=AttestationConveyancePreference.NONE,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
supported_pub_key_algs=_SUPPORTED_ALGS,
exclude_credentials=exclude_credentials,
)
# Serialize using the library's built-in function
return json.loads(options_to_json(options))
def verify_registration(
username: str,
credential_response: dict[str, Any],
registration_options: dict[str, Any],
credential_name: str = "",
origin: str = "",
rp_id: str = "",
) -> dict[str, Any]:
"""Verify a registration response and persist the credential.
Args:
username: The user registering the credential.
credential_response: Browser response from ``credentials.create()``.
registration_options: The options dict from ``create_registration_options``.
credential_name: Optional human-readable label.
origin: WebAuthn origin for verification.
rp_id: Relying Party ID for verification.
Returns:
Dict with ``id``, ``name``, ``transports``, ``sign_count``.
"""
challenge = b64u_decode(registration_options["challenge"])
# The library accepts the credential response as a JSON-serializable dict
# We pass it directly — the library handles the parsing
col = verify_registration_response(
credential=credential_response,
expected_challenge=challenge,
expected_origin=origin,
expected_rp_id=rp_id,
require_user_verification=False,
)
new_cred = col.credential
new_credential_id = b64u_encode(new_cred.id)
new_public_key = b64u_encode(new_cred.public_key)
new_sign_count = new_cred.sign_count
transports = []
if hasattr(new_cred.response, "transports") and new_cred.response.transports:
transports = [str(t) for t in new_cred.response.transports]
if not transports:
transports_raw = credential_response.get("response", {}).get("transports", [])
transports = [
t
for t in transports_raw
if t
in (
"internal",
"hybrid",
"nfc",
"ble",
"usb",
"smart-card",
)
] or ["internal"]
db = get_db()
db.run(
Q_INSERT_WEBAUTHN,
(
username,
new_credential_id,
new_public_key,
new_sign_count,
credential_name,
json.dumps(transports),
),
)
logger.info(
"WebAuthn credential registered for %s: %s",
username,
credential_name or new_credential_id[:16],
)
return {
"id": new_credential_id,
"name": credential_name,
"transports": transports,
"sign_count": new_sign_count,
}
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def create_authentication_options(
username: str,
rp_id: str,
) -> dict[str, Any] | None:
"""Create authentication options for a user.
Args:
username: The user authenticating.
rp_id: Relying Party ID (must match the request domain).
Returns a dict serializable to JSON (for ``navigator.credentials.get()``),
or ``None`` if the user has no registered credentials.
"""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
if not rows:
return None
allow_credentials = [
PublicKeyCredentialDescriptor(id=b64u_decode(row["credential_id"]))
for row in rows
]
options = generate_authentication_options(
rp_id=rp_id,
allow_credentials=allow_credentials,
user_verification=UserVerificationRequirement.PREFERRED,
)
return json.loads(options_to_json(options))
def verify_authentication(
username: str,
assertion_response: dict[str, Any],
auth_options: dict[str, Any],
origin: str = "",
rp_id: str = "",
) -> bool:
"""Verify an authentication assertion.
Args:
username: The user authenticating.
assertion_response: Browser response from ``credentials.get()``.
auth_options: The options dict from ``create_authentication_options``.
origin: WebAuthn origin for verification.
rp_id: Relying Party ID for verification.
Returns:
True on successful verification.
Raises:
ValueError: On verification failure.
"""
challenge = b64u_decode(auth_options["challenge"])
cred_id_str = assertion_response["id"]
# Load credential from DB
db = get_db()
cred_rows = db.query(Q_SELECT_WEBAUTHN_ID, (cred_id_str,))
if not cred_rows:
raise ValueError("Credential not found")
cred_row = cred_rows[0]
if cred_row["username"] != username:
raise ValueError("Credential not found")
public_key = b64u_decode(cred_row["public_key"])
old_sign_count = cred_row["sign_count"]
col = verify_authentication_response(
credential=assertion_response,
expected_challenge=challenge,
expected_origin=origin,
expected_rp_id=rp_id,
credential_public_key=public_key,
credential_current_sign_count=old_sign_count,
require_user_verification=False,
)
new_sign_count = col.credential_sign_count
if new_sign_count > old_sign_count:
db.run(
Q_UPDATE_WEBAUTHN_SIGN_COUNT,
(new_sign_count, cred_id_str),
)
logger.info("WebAuthn assertion verified for %s", username)
return True
# ---------------------------------------------------------------------------
# Credential management
# ---------------------------------------------------------------------------
def list_credentials(username: str) -> list[dict[str, Any]]:
"""List all registered credentials for a user."""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_USER, (username,))
result = []
for row in rows:
transports_str = row.get("transports", "[]")
try:
transports = json.loads(transports_str)
except (json.JSONDecodeError, TypeError):
transports = []
result.append(
{
"id": row["credential_id"],
"name": row.get("name") or "",
"transports": transports,
"sign_count": row.get("sign_count", 0),
}
)
return result
def remove_credential(username: str, credential_id: str) -> bool:
"""Remove a credential.
Raises:
ValueError: If credential not found or doesn't belong to user.
"""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_ID, (credential_id,))
if not rows:
raise ValueError(f"Credential {credential_id!r} not found")
if rows[0]["username"] != username:
raise ValueError(f"Credential {credential_id!r} not found")
db.run(Q_DELETE_WEBAUTHN, (credential_id,))
logger.info("WebAuthn credential removed: %s, %s", username, credential_id)
return True
def get_all_credential_counts() -> dict[str, int]:
"""Return credential counts for all users.
Returns:
Dict mapping usernames to credential counts.
"""
db = get_db()
rows = db.query(Q_SELECT_WEBAUTHN_COUNTS, ())
return {row["username"]: row["cred_count"] for row in rows}
+413 -86
View File
@@ -1,7 +1,8 @@
"""WireGuard Manager for Vacuum Wall SSL Proxy Firewall.
Generates wg-quick configurations, manages peers, and controls
the WireGuard tunnel interface.
the WireGuard tunnel interface. Supports multi-interface mode where
each access class gets its own WireGuard interface.
"""
import logging
@@ -19,7 +20,6 @@ logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = PROJECT_DIR / "config" / "wireguard" / "config.json"
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
WG_QUICK_BIN = "wg-quick"
WG_BIN = "wg"
@@ -30,6 +30,25 @@ ENV = Environment(
trim_blocks=True,
)
def _wg_conf_path(ifname: str) -> str:
"""Return the system WG conf path for an interface name."""
return f"/etc/wireguard/{ifname}.conf"
def _default_class_fields() -> dict[str, Any]:
"""Return the default set of fields for an access class entry."""
return {
"name": "",
"description": "",
"subnet": None,
"listen_port": None,
"lan_access": False,
"private_key": "",
"public_key": "",
}
DEFAULT_CONFIG: dict[str, Any] = {
"interface": {
"name": "wg0",
@@ -37,9 +56,31 @@ DEFAULT_CONFIG: dict[str, Any] = {
"private_key": "",
"public_key": "",
"addresses": ["10.137.0.1/24"],
"server_endpoint": "",
"description": "",
"post_up": None,
"post_down": None,
},
"access_classes": {
"full": {
"name": "Full LAN Access",
"description": "Peers get full access to internal networks",
"subnet": "10.137.0.0/24",
"listen_port": 51820,
"lan_access": True,
"private_key": "",
"public_key": "",
},
"internet": {
"name": "Internet Only",
"description": "Peers can only reach the internet",
"subnet": "10.137.1.0/24",
"listen_port": 51821,
"lan_access": False,
"private_key": "",
"public_key": "",
},
},
"peers": {},
}
@@ -62,23 +103,114 @@ def save_config(cfg: dict[str, Any]) -> None:
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = run_proc([WG_BIN, "genkey"], sudo=True)
res = run_proc([WG_BIN, "genkey"], sudo=False)
private_key = res.stdout.strip()
res2 = run_proc([WG_BIN, "pubkey"], sudo=True, input=private_key)
res2 = run_proc([WG_BIN, "pubkey"], sudo=False, input=private_key)
public_key = res2.stdout.strip()
return private_key, public_key
# --- wg0.conf generation ---
# --- Helpers ---
def _class_interface_name(class_key: str) -> str:
"""Derive interface name for an access class key."""
return f"wg-{class_key}"
def _class_zone_name(class_key: str) -> str:
"""Derive firewall zone name for an access class key."""
return f"vpn-{class_key}"
def get_class_interface_name(class_key: str) -> str:
"""Public wrapper for `_class_interface_name`."""
return _class_interface_name(class_key)
def get_class_zone_name(class_key: str) -> str:
"""Public wrapper for `_class_zone_name`."""
return _class_zone_name(class_key)
def _class_peers(cfg: dict[str, Any], class_key: str) -> dict[str, Any]:
"""Return peers assigned to a given access class."""
return {
name: info
for name, info in cfg.get("peers", {}).items()
if isinstance(info, dict) and info.get("access_class") == class_key
}
# --- wg-<class>.conf generation ---
def generate_class_conf(cfg: dict[str, Any], class_key: str) -> str | None:
"""Render a wg-quick config file for a single access class.
Returns ``None`` when the class has no peers assigned.
"""
classes = cfg.get("access_classes", {})
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
return None
peers = _class_peers(cfg, class_key)
if not peers:
return None
ifname = _class_interface_name(class_key)
subnet = class_cfg.get("subnet")
if not subnet:
subnet = "10.137.0.0/24"
_, prefix = subnet.rsplit("/", 1)
base = subnet.rsplit(".", 1)[0]
addr = f"{base}.1/{prefix}"
listen_port = class_cfg.get("listen_port")
if not listen_port:
listen_port = 51820
private_key = class_cfg.get("private_key", "")
if not private_key:
raise ValueError(
f"Access class '{class_key}' has no private key — generate one first."
)
class_iface = {
"name": ifname,
"listen_port": listen_port,
"private_key": private_key,
"addresses": [addr],
"post_up": cfg.get("interface", {}).get("post_up"),
"post_down": cfg.get("interface", {}).get("post_down"),
}
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=class_iface,
peers=peers,
)
def generate_conf(cfg: dict[str, Any]) -> str:
"""Render a valid wg-quick config file from *cfg* using Jinja2."""
"""Render a valid wg-quick config file from *cfg* using Jinja2.
Legacy single-interface mode uses the top-level ``interface`` block
and peers without an ``access_class`` assigned.
"""
fallback_peers = {
n: p
for n, p in cfg.get("peers", {}).items()
if isinstance(p, dict) and not p.get("access_class")
}
tmpl = ENV.get_template("wireguard.conf")
return tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
interface=cfg["interface"],
peers=cfg.get("peers", {}),
peers=fallback_peers if fallback_peers else {},
)
@@ -86,55 +218,187 @@ def generate_conf(cfg: dict[str, Any]) -> str:
def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick."""
"""Write the current config to disk and bring tunnels up with wg-quick.
In multi-interface mode, applies each access class's interface independently.
Falls back to legacy single-interface mode when no classes have peers.
"""
cfg = get_config()
classes = cfg.get("access_classes", {})
applied = False
for class_key in classes:
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
continue
if not _class_peers(cfg, class_key):
continue
try:
conf_text = generate_class_conf(cfg, class_key)
if not conf_text:
continue
except ValueError:
continue
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
applied = True
if applied:
save_config(cfg)
elif applied is False:
# Legacy single-interface fallback
legacy_apply(cfg)
def legacy_apply(cfg: dict[str, Any]) -> None:
"""Legacy single-interface apply."""
conf_text = generate_conf(cfg)
save_config(cfg)
ifname = cfg["interface"]["name"]
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / "wg0.conf.tmp"
local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), WG_CONF_PATH], sudo=True)
run(["chown", "root:root", WG_CONF_PATH], sudo=True, check=False)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", ifname)
run([WG_QUICK_BIN, "up", cfg["interface"]["name"]], sudo=True)
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
def apply_class(class_key: str) -> None:
"""Apply config for a single access class interface."""
cfg = get_config()
class_cfg = cfg.get("access_classes", {}).get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
raise ValueError(f"Access class '{class_key}' not found")
conf_text = generate_class_conf(cfg, class_key)
if not conf_text:
raise ValueError(f"No peers assigned to class '{class_key}'")
ifname = _class_interface_name(class_key)
conf_path = _wg_conf_path(ifname)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
local_tmp = local_dir / f"{ifname}.conf.tmp"
with open(local_tmp, "w") as f:
f.write(conf_text)
os.chmod(local_tmp, 0o600)
run(["cp", "--", str(local_tmp), conf_path], sudo=True)
run(["chown", "root:root", conf_path], sudo=True, check=False)
local_tmp.unlink(missing_ok=True)
run([WG_QUICK_BIN, "up", ifname], sudo=True)
save_config(cfg)
logger.info("WireGuard tunnel '%s' (class '%s') brought up", ifname, class_key)
def down() -> None:
"""Bring the WireGuard tunnel interface down."""
"""Bring all WireGuard tunnel interfaces down.
In multi-interface mode, brings down each class interface with peers.
"""
cfg = get_config()
name = cfg["interface"]["name"]
run([WG_QUICK_BIN, "down", name], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", name)
classes = cfg.get("access_classes", {})
for class_key in classes:
class_cfg = classes.get(class_key)
if not class_cfg or not isinstance(class_cfg, dict):
continue
if ifname := _class_interface_name(class_key):
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
except Exception:
pass
# Also try legacy interface (skip if name matches any class interface)
ifname = cfg["interface"].get("name", "")
if ifname:
class_names = {_class_interface_name(k) for k in classes}
if ifname not in class_names:
try:
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
except Exception:
pass
def down_class(class_key: str) -> None:
"""Bring down a single access class interface."""
ifname = _class_interface_name(class_key)
run([WG_QUICK_BIN, "down", ifname], sudo=True)
logger.info("WireGuard tunnel '%s' brought down", ifname)
# --- Status ---
def status() -> dict[str, Any]:
"""Query the live tunnel state via ``wg show``."""
"""Query the live tunnel state via ``wg show``.
In multi-interface mode, collects status for all class interfaces.
Returns combined status dict keyed by interface name.
"""
cfg = get_config()
name = cfg["interface"]["name"]
result: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
"classes": {},
}
# Collect per-class status
classes = cfg.get("access_classes", {})
for class_key in classes:
ifname = _class_interface_name(class_key)
try:
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode != 0:
result["classes"][class_key] = {"up": False, "peers": []}
continue
class_status = _parse_wg_show_output(res.stdout.strip())
result["classes"][class_key] = class_status
if class_status["up"]:
result["up"] = True
except Exception:
result["classes"][class_key] = {"up": False, "peers": []}
# Legacy single-interface status (still collected for backward compat)
try:
ifname = cfg["interface"].get("name", "wg0")
res = run_proc([WG_BIN, "show", ifname], sudo=True, check=False)
if res.returncode == 0:
parsed = _parse_wg_show_output(res.stdout.strip())
result["up"] = parsed["up"]
result["interface"] = parsed.get("interface", {})
result["peers"] = parsed.get("peers", [])
except Exception:
pass
return result
def _parse_wg_show_output(raw: str) -> dict[str, Any]:
"""Parse ``wg show`` output into structured dict."""
result: dict[str, Any] = {
"up": False,
"interface": {},
"peers": [],
}
try:
res = run_proc([WG_BIN, "show", name], sudo=True, check=False)
if res.returncode != 0:
return result
raw = res.stdout.strip()
except Exception:
return result
current_peer: dict[str, Any] | None = None
peers: list[dict[str, Any]] = []
@@ -169,8 +433,8 @@ def status() -> dict[str, Any]:
"endpoint": None,
"allowed_ips": [],
"latest_handshake": None,
"transfer_received": 0,
"transfer_sent": 0,
"transfer_received": "0",
"transfer_sent": "0",
"persistent_keepalive": None,
}
peers.append(current_peer)
@@ -214,36 +478,50 @@ def status() -> dict[str, Any]:
# --- Peer management ---
_UNSET = object()
def add_peer(
name: str,
endpoint: str | None = None,
allowed_ips: list[str] | None = None,
persistent_keepalive: int | None = None,
endpoint: str | None | object = _UNSET,
allowed_ips: list[str] | None | object = _UNSET,
persistent_keepalive: int | None | object = _UNSET,
preshared_key: str | None = None,
description: str | None = None,
access_class: str | None = None,
) -> dict[str, Any]:
"""Add (or update) a peer in the configuration."""
cfg = get_config()
peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
if name in peers:
peer = peers[name]
peer["endpoint"] = endpoint
peer["allowed_ips"] = allowed_ips
peer["persistent_keepalive"] = persistent_keepalive
if endpoint is not _UNSET:
peer["endpoint"] = endpoint
if allowed_ips is not _UNSET:
peer["allowed_ips"] = allowed_ips if allowed_ips is not None else []
if persistent_keepalive is not _UNSET:
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
if description is not None:
peer["description"] = description
if access_class is not None:
peer["access_class"] = access_class
logger.info("WireGuard peer '%s' updated", name)
else:
priv, pub = generate_keypair()
peer = {
"public_key": pub,
"private_key": priv,
"endpoint": endpoint,
"allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive,
"endpoint": endpoint if endpoint is not _UNSET else None,
"allowed_ips": allowed_ips if allowed_ips is not _UNSET else [],
"persistent_keepalive": persistent_keepalive
if persistent_keepalive is not _UNSET
else None,
"preshared_key": preshared_key,
"description": description,
"access_class": access_class,
}
peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
@@ -275,9 +553,17 @@ def get_peers() -> list[dict[str, Any]]:
def get_peer_status() -> list[dict[str, Any]]:
"""Return live peer status from ``wg show``."""
"""Return live peer status from ``wg show`` for all interfaces."""
st = status()
return st.get("peers", [])
all_peers: list[dict[str, Any]] = []
for _class_key, class_st in st.get("classes", {}).items():
for p in class_st.get("peers", []):
merged = dict(p)
merged["access_class"] = _class_key
all_peers.append(merged)
if not all_peers:
all_peers = st.get("peers", [])
return all_peers
# --- Client config generation ---
@@ -286,9 +572,13 @@ def get_peer_status() -> list[dict[str, Any]]:
def generate_client_conf(
peer_name: str,
server_endpoint: str,
server_pubkey: str,
server_pubkey: str | None = None,
) -> str:
"""Build a client-side wg-quick config snippet for *peer_name*."""
"""Build a client-side wg-quick config snippet for *peer_name*.
Uses the peer's access class to derive server address from the class
subnet and the class's listen port.
"""
cfg = get_config()
iface = cfg["interface"]
peer = cfg["peers"].get(peer_name)
@@ -301,12 +591,30 @@ def generate_client_conf(
f"Peer '{peer_name}' has no private key — cannot generate client config."
)
# Determine class info for address/port
access_class = peer.get("access_class")
sorted_peers = sorted(cfg.get("peers", {}).keys())
peer_index = sorted_peers.index(peer_name) + 2
srv_addr = iface["addresses"][0] if iface["addresses"] else "10.137.0.1/24"
addr_part, prefix = srv_addr.rsplit("/", 1)
prefix_base = addr_part.rsplit(".", 1)[0]
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
if access_class and access_class in cfg.get("access_classes", {}):
class_cfg = cfg["access_classes"][access_class]
subnet = class_cfg.get("subnet", "10.137.0.0/24")
listen_port = class_cfg.get("listen_port", 51820)
else:
subnet = iface.get("addresses", ["10.137.0.1/24"])[0]
listen_port = iface.get("listen_port", 51820)
if "/" not in subnet:
subnet = f"{subnet}/24"
addr = subnet.rsplit(".", 1)[0]
prefix = subnet.rsplit("/", 1)[1]
client_addr = f"{addr}.{peer_index}/{prefix}"
sk = server_pubkey or iface.get("public_key", "")
ep = server_endpoint or iface.get("server_endpoint", "")
if ep and listen_port:
host = ep.split(":")[0]
ep = f"{host}:{listen_port}"
tmpl = ENV.get_template("wireguard-client.conf")
conf = tmpl.render(
@@ -314,8 +622,8 @@ def generate_client_conf(
peer_name=peer_name,
client_priv=client_priv,
client_addr=client_addr,
server_pubkey=server_pubkey,
server_endpoint=server_endpoint,
server_pubkey=sk,
server_endpoint=ep,
allowed_ips=peer.get("allowed_ips", ["0.0.0.0/0"]),
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
@@ -351,66 +659,85 @@ def set_post_down(cmd: str | None) -> None:
save_config(cfg)
# --- Class key generation ---
def generate_class_keypair(class_key: str) -> tuple[str, str]:
"""Generate a key pair for an access class interface."""
cfg = get_config()
classes = cfg.setdefault("access_classes", {})
if class_key not in classes:
raise ValueError(f"Access class '{class_key}' not found")
class_cfg = classes[class_key]
if class_cfg.get("private_key"):
return class_cfg["private_key"], class_cfg["public_key"]
priv, pub = generate_keypair()
class_cfg["private_key"] = priv
class_cfg["public_key"] = pub
save_config(cfg)
logger.info("Key pair generated for class '%s'", class_key)
return priv, pub
# --- Initialise ---
def _ensure_class_defaults(cfg: dict[str, Any]) -> None:
"""Ensure access classes have required Phase-2 fields."""
classes = cfg.setdefault("access_classes", {})
for _key, c in classes.items():
if not isinstance(c, dict):
continue
for field, default in _default_class_fields().items():
if field not in c:
c[field] = default
def _ensure_access_classes(cfg: dict[str, Any]) -> None:
"""Pre-seed default access classes if missing or empty (idempotent).
Also upgrades existing classes with Phase-2 fields.
"""
classes = cfg.setdefault("access_classes", {})
if not classes:
full_defaults = deepcopy(DEFAULT_CONFIG["access_classes"])
classes.update(full_defaults)
return
_ensure_class_defaults(cfg)
def initialize() -> dict[str, Any]:
"""Perform first-time WireGuard setup."""
cfg = get_config()
if cfg["interface"].get("private_key"):
_ensure_access_classes(cfg)
save_config(cfg)
return cfg
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
_ensure_access_classes(cfg)
save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg
# --- Utility: parse wg show into structured peer map ---
def _parse_wg_show(output: str) -> dict[str, Any]:
"""Internal parser for ``wg show`` multiline output."""
peers: dict[str, dict[str, Any]] = {}
current: dict[str, Any] | None = None
for line in output.splitlines():
line = line.strip()
if line.startswith("peer:"):
key = line.split(":", 1)[1].strip()
current = {"_key": key}
peers[key] = current
continue
if current is None:
continue
if line.startswith("endpoint:"):
val = line.split(":", 1)[1].strip()
current["endpoint"] = val
elif line.startswith("allowed ips:"):
current["allowed_ips"] = line.split(":", 1)[1].strip()
elif line.startswith("latest handshake:"):
current["latest_handshake"] = line.split(":", 1)[1].strip()
elif line.startswith("transfer:"):
current["transfer_raw"] = line.split(":", 1)[1].strip()
elif line.startswith("persistent-keepalive:"):
current["persistent_keepalive"] = line.split(":", 1)[1].strip()
return peers
__all__ = [
"DEFAULT_CONFIG",
"add_peer",
"apply",
"apply_class",
"down",
"down_class",
"generate_class_conf",
"generate_class_keypair",
"generate_client_conf",
"generate_conf",
"generate_keypair",
"get_class_interface_name",
"get_class_zone_name",
"get_config",
"get_peer_status",
"get_peers",
+3
View File
@@ -12,6 +12,9 @@ dependencies = [
"aiohttp>=3.9,<4.0",
"passlib>=1.7.4,<2.0",
"requests-unixsocket>=0.2,<1.0",
"PyJWT>=2.8,<3.0",
"argon2-cffi>=23.1.0,<25.0",
"webauthn>=3.0.0,<4.0",
]
[project.scripts]
+95
View File
@@ -0,0 +1,95 @@
"""Bootstrap auth: initialize DB and seed admin user at install time.
Idempotent safe to run on every install (and re-install):
- Writes config/auth/config.json only if it does not exist (existing
JWT/WebAuthn settings are preserved).
- Creates the admin user if missing; if the user already exists, updates
the admin password to the provided value (docs/deployment.md: "On
re-run, updates the admin password if already present").
- Suppresses the last-resort builtin admin seed (VACUUM_WALL_SEED_BUILTIN_ADMIN=0):
bootstrap is the operator user's creator on a fresh install, so exactly
one account exists and no hardcoded admin with an unrecoverable random
password is left behind.
Usage:
python scripts/bootstrap_auth.py --project-dir /path/to/project \
--username admin \
--password secret \
--domain wall.example.com
"""
import argparse
import json
import os
import sys
from pathlib import Path
# Ensure project lib is importable
def main() -> None:
parser = argparse.ArgumentParser(description="Bootstrap Vacuum Wall auth")
parser.add_argument("--project-dir", required=True, help="Project root directory")
parser.add_argument("--username", required=True, help="Admin username")
parser.add_argument("--password", required=True, help="Admin password")
parser.add_argument("--domain", required=True, help="Management domain (rp_id)")
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
sys.path.insert(0, str(project_dir))
# Set DB path before importing lib modules
db_path = str(project_dir / "data" / "auth.db")
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
os.environ["VACUUM_WALL_DB_PATH"] = db_path
# Suppress the last-resort builtin admin seed in get_db(): bootstrap
# creates the operator user itself, so no seeded admin may shadow it.
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = "0"
from lib.auth_users import (
ALL_SUBSYSTEMS,
create_user,
find_user,
reset_password,
)
# Write config — only if missing, so re-runs never clobber existing
# JWT/WebAuthn settings (e.g. a customized rp_id/origin).
config_dir = project_dir / "config" / "auth"
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / "config.json"
if not config_path.exists():
config = {
"jwt": {
"access_token_ttl": 300,
"refresh_token_ttl": 604800,
"algorithm": "HS256",
},
"webauthn": {
"rp_name": "Vacuum Wall",
"rp_id": args.domain,
"origin": f"https://{args.domain}",
},
}
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
print(f"Wrote auth config: {config_path}")
else:
print(f"Auth config already present, leaving unchanged: {config_path}")
# Initialize DB and create the admin user, or sync the password on re-run
if find_user(args.username) is not None:
reset_password(args.username, args.password)
print(f"Updated existing user: {args.username} (password synced)")
else:
permissions = {sub: "rw" for sub in ALL_SUBSYSTEMS}
user = create_user(args.username, args.password, permissions)
print(f"Created admin user: {user['username']} (id={user['id']})")
print(f"Permissions: {len(permissions)} subsystems, all 'rw'")
if __name__ == "__main__":
main()
+111 -134
View File
@@ -35,14 +35,14 @@ while [[ $# -gt 0 ]]; do
--lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;;
-h|--help)
printf '%s\n' \
"Usage: install.sh [OPTIONS]" \
"Usage: scripts/install.sh [OPTIONS]" \
"" \
"Options:" \
" --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \
" --path, -p DIR Install directory (default: repo root)" \
" --dev Dev mode: auto-detect repo owner, skip safety warning" \
" --mgmt-pass PASS WebUI basic auth password (required)" \
" --mgmt-user USER WebUI basic auth username (default: admin)" \
" --mgmt-pass PASS Initial admin password (required)" \
" --mgmt-user USER Initial admin username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --wan-iface IFACE WAN interface name (auto-detected)" \
" --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \
@@ -54,10 +54,10 @@ while [[ $# -gt 0 ]]; do
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass" \
" ./scripts/install.sh --dev --mgmt-pass pass" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ./install.sh --user vacuum-wall"
" MGMT_PASS=pass ./scripts/install.sh --user vacuum-wall"
exit 0
;;
*)
@@ -67,11 +67,11 @@ while [[ $# -gt 0 ]]; do
done
# --- Resolve config: CLI flag > env var > default ---
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$(dirname "$0")/../" && pwd)"
# Required settings (no defaults — must be provided)
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
# Optional settings with defaults
# MGMT_PASS is strictly required — admin user is created at install time
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}"
# MGMT_DOMAIN — CLI > env > auto-detect from hostname
@@ -104,19 +104,7 @@ LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}"
[[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu."
# --- Validate required settings ---
missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
if (( ${#missing[@]} )); then
echo -e "${RED}[!!]${NC} Missing required settings:"
for v in "${missing[@]}"; do
case "$v" in
"MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';;
esac
done
printf '\nTo run: MGMT_PASS=pass ./install.sh\n'
exit 1
fi
[[ -n "$MGMT_PASS" ]] || err "MGMT_PASS is required (set --mgmt-pass or MGMT_PASS env var)"
ACME_HOME="$PROJECT_DIR/data/acme"
# Dev mode: auto-detect repo owner as service user
@@ -182,6 +170,10 @@ apt-get install -y -qq \
apache2-utils \
avahi-daemon
# --- 1b. Vendored libraries ---
log "Downloading vendored libraries..."
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
# --- 2. Setup users ---
log "WebUI user: $USER_NAME (group: $USER_GROUP)"
@@ -207,17 +199,6 @@ else
chmod -R g+x "${PROJECT_DIR}/.venv"
fi
# --- 2c. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME"
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
chmod +x "$ACME_HOME/acme.sh"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
else
log "acme.sh already installed."
fi
# Install the deploy hook into acme.sh's deploy directory
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
mkdir -p "$ACME_HOME/deploy"
@@ -231,15 +212,26 @@ mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall}
mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme}
mkdir -p /etc/wireguard
mkdir -p /etc/dnsmasq
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev
# Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev.
# The top-level .git (directory or worktree pointer file) is left untouched so
# the repo owner's git isn't tripped by git's dubious-ownership check.
if [[ "$_cli_is_dev" == true ]]; then
_dev_owner="$USER_NAME"
else
_dev_owner="$USER_DAEMON_NAME"
fi
chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod -R g+rwX "$PROJECT_DIR"
find "$PROJECT_DIR" -type d -exec chmod g+s '{}' +
(
shopt -s dotglob nullglob
for _entry in "$PROJECT_DIR"/*; do
[[ "$(basename "$_entry")" == ".git" ]] && continue
chown -R "$_dev_owner:$USER_GROUP" "$_entry"
chmod -R g+rwX "$_entry"
find "$_entry" -type d -exec chmod g+s '{}' +
done
# Top dir: ownership + shared-group access (never .git)
chown "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod g+rwX,g+s "$PROJECT_DIR"
)
# --- 4. Template rendering function ---
# Renders Jinja2 templates by injecting env vars as template context.
@@ -282,13 +274,23 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \
| install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service
install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer
# Volatile /run entries (sudo, firewalld) must exist before vacuum-walld
# spawns — systemd-tmpfiles-setup.service restores them at every boot.
install -m 0644 "${PROJECT_DIR}/system/tmpfiles.d/vacuum-wall.conf" /etc/tmpfiles.d/vacuum-wall.conf
systemd-tmpfiles --create
systemctl daemon-reload
# --- 7. Enable IP forwarding (persistent via sysctl.conf) ---
# --- 7. Enable IP forwarding (persistent via sysctl.conf + runtime apply) ---
log "Enabling IP forwarding..."
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
fi
# Apply immediately so NAT works without reboot
if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != "1" ]; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 && log "IP forwarding enabled at runtime" || \
warn "Could not enable IP forwarding at runtime"
fi
# --- 8. Detect network interfaces ---
log "Detecting network interfaces..."
@@ -355,108 +357,87 @@ else
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
# --- 10. Configure subsystems via daemon API ---
log "Configuring subsystems via daemon API..."
WAN_IFACE="$WAN_IFACE" \
LAN_IFACES="$LAN_IFACES" \
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
MGMT_PASS="$MGMT_PASS" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import daemon.client as c
from daemon.iface import (
POST_ACME_SELF_SIGNED, POST_NGINX_DOMAINS_ADD, POST_NGINX_APPLY,
POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET,
GET_NETWORK_INFER_DHCP_RANGES,
)
import sys
echo ""
echo " Setting up initial management configuration..."
domain = '${DOMAIN}'
mgmt_user = '${MGMT_USER}'
mgmt_pass = '${MGMT_PASS}'
wan_iface = '${WAN_IFACE}'
lan_ifaces = '${LAN_IFACES}'
# Bootstrap auth: generate config + seed admin user. bootstrap_auth.py
# is idempotent — on re-run it preserves the existing config and
# updates the admin password to MGMT_PASS (docs/deployment.md).
if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
echo ""
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
else
echo ""
echo " Syncing admin password for existing user: $MGMT_USER..."
fi
# Self-signed cert for management domain
try:
res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365})
print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}')
except Exception as e:
print(f' [cert] Warning: {e}', file=sys.stderr)
"${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \
--project-dir "$PROJECT_DIR" \
--username "$MGMT_USER" \
--password "$MGMT_PASS" \
--domain "$DOMAIN"
# Management proxy domain + htpasswd
try:
c.post(POST_NGINX_DOMAINS_ADD, {
'domain': domain,
'paths': {
'/': {
'backend': {'host': '127.0.0.1', 'port': 9090, 'proto': 'http'},
'is_management': True,
},
'/ws': {
'backend': {'host': '127.0.0.1', 'port': 9091, 'proto': 'http'},
'is_websocket': True,
},
},
'auth_user': mgmt_user,
'auth_pass': mgmt_pass,
})
c.post(POST_NGINX_APPLY)
print(f' [proxy] Management proxy configured for {domain}')
except Exception as e:
print(f' [proxy] Warning: {e}', file=sys.stderr)
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true
# Firewall config (interface detection done in bash above)
import json as _json
zones = {}
if wan_iface:
zones['public'] = {
'target': 'DEFAULT',
'interfaces': [i for i in wan_iface.split(',') if i],
'services': ['http', 'https', 'ssh'],
'masquerade': True,
# Helper: POST JSON to daemon API over Unix socket
_daemon_post() {
local endpoint="$1"
local json="$2"
local label="${3:-POST $endpoint}"
local resp
if resp=$(curl -s -f --unix-socket "$_SOCKET" \
"http://localhost${endpoint}" \
-H "Content-Type: application/json" \
-d "$json" 2>&1); then
log "$label"
return 0
else
warn "$label: $resp"
return 1
fi
}
if lan_ifaces:
zones['internal'] = {
'target': 'ACCEPT',
'interfaces': [i for i in lan_ifaces.split(',') if i],
'services': ['dhcp', 'dns', 'ntp'],
'masquerade': False,
}
# 1A. Self-signed certificate for management domain
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# Always create vpn zone skeleton for later WireGuard setup
zones['vpn'] = {
'target': 'ACCEPT',
'interfaces': [],
'services': [],
'masquerade': False,
}
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
mgmt_json="$(jq -n \
--arg domain "$DOMAIN" \
'{
domain: $domain,
backend: "webui",
cert: "selfsigned",
force_ssl: true
}')"
_daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \
_daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured"
try:
c.post(POST_FIREWALL_CONFIG, {'zones': zones})
c.post(POST_FIREWALL_CONFIG_APPLY)
print(' [firewall] Zones configured and applied')
except Exception as e:
print(f' [firewall] Warning: {e}', file=sys.stderr)
_daemon_post "/nginx/apply" "{}" "Nginx config applied"
# IP forwarding
try:
c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'})
print(' [network] IP forwarding enabled')
except Exception as e:
print(f' [network] Warning: {e}', file=sys.stderr)
# Firewall zone assignment
if [[ -n "$WAN_IFACE" ]]; then
_daemon_post "/firewall/zones/interfaces" \
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
'{zone: $zone, interfaces: [$iface]}')" \
"WAN interface assigned to public zone"
# Infer DHCP ranges (logged for user reference)
try:
ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES)
for iface, rng in ranges.get('ranges', {}).items():
print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}')
except Exception:
pass
"
log "Subsystem configuration complete"
# Open management services (HTTP, HTTPS, SSH) on the public/WAN zone
_daemon_post "/firewall/zones/services" \
"$(jq -n '{zone: "public", services: ["http", "https", "ssh"]}')" \
"Management services opened on public zone (http, https, ssh)"
fi
if [[ -n "$LAN_IFACES" ]]; then
# Convert comma-separated list to JSON array
LAN_JSON=$(echo "$LAN_IFACES" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s '.')
_daemon_post "/firewall/zones/interfaces" \
"$(jq -n --arg zone "internal" --argjson ifaces "$LAN_JSON" \
'{zone: $zone, interfaces: $ifaces}')" \
"LAN interfaces assigned to internal zone"
fi
unset _daemon_post
fi
systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI"
@@ -472,7 +453,7 @@ echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}"
echo "============================================"
echo ""
echo " Management UI: https://$DOMAIN"
echo " User: $MGMT_USER"
echo " Admin user: $MGMT_USER"
echo " Daemon service: vacuum-walld.service"
echo " WebUI service: vacuum-wall.service"
echo " ACME renewal: vacuum-wall-acme.timer"
@@ -496,7 +477,3 @@ echo " 3. Configure DHCP ranges for your LAN"
echo " 4. Add proxy domains with ACME certificates"
echo " 5. Set up WireGuard tunnel (optional)"
echo ""
echo " NOTE: A self-signed certificate was generated."
echo " From the WebUI, issue a real certificate for $DOMAIN"
echo " when DNS points to this appliance."
echo ""
@@ -1,5 +1,10 @@
#!/bin/bash
if [[ $EUID -ne 0 ]]; then
exec sudo "$0" "$@"
exit 0
fi
echo "systemctl restart nginx"
systemctl restart nginx
sleep 1
@@ -22,3 +27,5 @@ if [ "$failed" -eq 1 ]; then
exit 1
fi
exit 0
+62 -6
View File
@@ -1,27 +1,83 @@
#!/usr/bin/env bash
# Download and vendor libraries into vendor/.
# Run from the project root after updating the VERSION variables below.
# Files are named {pkg}-{ver}.{ext}, with {pkg}.{ext} symlinks for stable references.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VENDOR="$PROJECT_DIR/vendor"
WEBUI_VENDOR="$PROJECT_DIR/webui/static/vendor"
# ---- Library versions ----
ACME_VERSION="3.1.3"
HTM_VERSION="3.1.1"
VENDOR="vendor"
# ---- Helpers ----
download() {
local name="$1" url="$2" dest="$3"
if [[ -n "${SKIP_DOWNLOAD:-}" ]]; then
echo "[skip] $name (SKIP_DOWNLOAD is set)"
echo "[skip] $name"
return
fi
if [[ -f "$dest" ]]; then
echo "[skip] $name (already present)"
return
fi
echo "[download] $name$dest"
curl -sfL -o "$dest" "$url"
}
ensure_symlink() {
local link="$1" target="$2"
if [[ -L "$link" ]]; then
cur=$(readlink "$link")
if [[ "$cur" != "$target" ]]; then
echo "[symlink] $link$target (updated)"
ln -sf "$target" "$link"
fi
elif [[ -e "$link" ]]; then
echo "[symlink] $link (replacing existing file)"
mv -f "$link" "${link}.bak" && ln -sf "$target" "$link"
else
echo "[symlink] $link$target"
ln -sf "$target" "$link"
fi
}
# ---- acme.sh ----
ACME_VERSIONED="$VENDOR/acme-${ACME_VERSION}.sh"
ACME_SYMLINK="$VENDOR/acme.sh"
download "acme.sh@${ACME_VERSION}" \
"https://raw.githubusercontent.com/acmesh-official/acme.sh/${ACME_VERSION}/acme.sh" \
"${VENDOR}/acme.sh"
"$ACME_VERSIONED"
chmod +x "$ACME_VERSIONED"
ensure_symlink "$ACME_SYMLINK" "acme-${ACME_VERSION}.sh"
chmod +x "${VENDOR}/acme.sh"
# ---- htm ----
HTM_VERSIONED="$VENDOR/htm-${HTM_VERSION}.js"
HTM_SYMLINK="$VENDOR/htm.js"
download "htm@${HTM_VERSION}" \
"https://cdn.jsdelivr.net/npm/htm@${HTM_VERSION}/mini/index.module.js" \
"$HTM_VERSIONED"
ensure_symlink "$HTM_SYMLINK" "htm-${HTM_VERSION}.js"
# ---- ACME_HOME (acme.sh runtime home) ----
ACME_HOME="${ACME_HOME:-$PROJECT_DIR/data/acme}"
mkdir -p "$ACME_HOME"
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
cp "$ACME_SYMLINK" "$ACME_HOME/acme.sh"
chmod +x "$ACME_HOME/acme.sh"
fi
# ---- Webui symlinks (point to versioned files) ----
mkdir -p "$WEBUI_VENDOR"
WEBUI_LINKS=(
"htm.js:../../../vendor/htm-${HTM_VERSION}.js"
)
for entry in "${WEBUI_LINKS[@]}"; do
IFS=':' read -r name target <<< "$entry"
ensure_symlink "${WEBUI_VENDOR}/${name}" "$target"
done
echo "[done] All libraries vendored."
+2 -2
View File
@@ -33,8 +33,8 @@ server {
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %}
{% elif has_management %}
ssl_certificate {{ acme_cert_dir }}/fullchain.cer;
ssl_certificate_key {{ acme_cert_dir }}/{{ domain }}.key;
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
{% endif %}
include snippets/vacuum-wall-ssl.conf;
+8 -9
View File
@@ -9,37 +9,36 @@ Defaults:{{ USER_DAEMON_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active nginx
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/conf.d/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/nginx/snippets/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/include.tmp /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/ssl-snippet.tmp /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/conf.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/nginx/snippets/vacuum-wall-ssl.conf
# Dnsmasq management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl restart dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/misc/dnsmasq.leases
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/dnsmasq.d/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/dnsmasq.tmp /etc/dnsmasq.d/vacuum-wall.conf
# WireGuard management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/wireguard/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/wg0.conf.tmp /etc/wireguard/wg0.conf
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/chown root\:root /etc/wireguard/wg0.conf
# Network interface queries
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o link show
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/sbin/ip -o addr show *
# Networkd management
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl status *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reload
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /bin/networkctl reconfigure *
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp * /etc/systemd/network/*
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/systemd/network/*.network
{{ USER_DAEMON_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/systemd/network
+2 -1
View File
@@ -22,7 +22,8 @@ Environment=HOME={{ PROJECT_DIR }}
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
ReadWritePaths={{ PROJECT_DIR }} {{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp /var/log/vacuum-wall
LogsDirectory=vacuum-wall
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+32 -1
View File
@@ -12,14 +12,45 @@ WorkingDirectory={{ PROJECT_DIR }}
ExecStart={{ PROJECT_DIR }}/.venv/bin/python -m daemon
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
Environment=PATH=/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
Environment=HOME={{ PROJECT_DIR }}
# Runtime directories created before namespace setup. ProtectSystem=strict
# makes the whole hierarchy read-only, and namespace setup fails
# (exit 226/NAMESPACE) if any ReadWritePaths= entry is missing at spawn.
# /run is a fresh tmpfs at every boot, so volatile /run paths must be
# created up front (RuntimeDirectory= here; /run/firewalld via
# system/tmpfiles.d/vacuum-wall.conf and by firewalld itself) rather than
# at first use.
# vacuum-wall : secure temp files used during config apply
# nginx : /run/nginx (listed in ReadWritePaths)
RuntimeDirectory=vacuum-wall nginx
RuntimeDirectoryMode=0750
LogsDirectory=vacuum-wall
# Security hardening
ProtectSystem=strict
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx
# NOTE: every ReadWritePaths= entry must exist when the unit spawns or namespace
# setup fails (226/NAMESPACE). Volatile /run entries are pre-created:
# /run/vacuum-wall, /run/nginx → RuntimeDirectory= (above)
# /run/firewalld → system/tmpfiles.d/vacuum-wall.conf (and is
# present while firewalld runs, which starts
# before this unit)
# /run/sudo is intentionally NOT listed: the daemon's sudo children use the
# NOPASSWD whitelist and never need sudo's session directory (verified with
# the directory absent). Listing it made the unit crash-loop whenever it
# restarted after the last sudo session had ended and sudo removed /run/sudo.
# /run/nginx.pid IS listed: nginx -t opens the pid file for *writing* in
# addition to -s/acme reading it, so a read-only mount makes every daemon-side
# `nginx -t` (and therefore /nginx/apply) fail with EROFS. The file is
# pre-created by system/tmpfiles.d/vacuum-wall.conf so the ReadWritePaths=
# entry always exists at spawn (nginx rewrites it on start; nginx -t does
# not modify its contents).
ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
+25
View File
@@ -0,0 +1,25 @@
# Volatile /run entries that must exist before vacuum-walld spawns.
#
# vacuum-walld runs with ProtectSystem=strict and lists these paths in
# ReadWritePaths=; if a ReadWritePaths= entry is missing at spawn time,
# systemd's mount-namespace setup fails (exit 226/NAMESPACE) and the unit
# crash-loops without ever creating data/daemon.sock. /run is a fresh tmpfs
# at every boot, so every /run path the unit references needs a boot-time
# creator. Status per path:
#
# /run/vacuum-wall, /run/nginx -> unit RuntimeDirectory= (daemon-owned)
# /run/firewalld -> this file (the firewalld unit only creates
# it while firewalld itself is running)
# /run/nginx.pid -> this file (nginx rewrites it on start; the
# daemon's `nginx -t` must be able to open
# it for writing inside its ProtectSystem=strict
# namespace, so it needs both a boot-time
# creator and a ReadWritePaths= entry)
# /run/sudo -> not referenced by the unit (see
# ReadWritePaths note in vacuum-walld.service);
# the sudo package ships its own tmpfiles spec
#
# Applied at early boot by systemd-tmpfiles-setup.service and by the install
# script (`systemd-tmpfiles --create`) for existing hosts.
d /run/firewalld 0750 root root -
f /run/nginx.pid 0644 root root -
+239
View File
@@ -0,0 +1,239 @@
/**
* Tests for hoover/components/applyconfirm.js
*
* Component-level tests: VNode structure, buildRows logic,
* and integration behaviour. Run with `node tests/test-applyconfirm.js`.
*/
import { buildRows, isPending, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
const SUBSYSTEM_KEYS = SUBSYSTEM_LIST.map(s => s.key);
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
}
function assertIncludes(str, substr, msg) {
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
}
console.log('Testing ApplyConfirm component\n');
// === isPending ===
test('isPending returns true for needs_apply', () => {
assertEq(isPending({ needs_apply: true }), true);
});
test('isPending returns true for pending_changes', () => {
assertEq(isPending({ pending_changes: true }), true);
});
test('isPending returns false when neither flag set', () => {
assertEq(isPending({}), false);
});
test('isPending returns false for explicit false', () => {
assertEq(isPending({ needs_apply: false, pending_changes: false }), false);
});
// === SUBSYSTEM_LIST ===
test('SUBSYSTEM_LIST contains 5 subsystems', () => {
assertEq(SUBSYSTEM_LIST.length, 5);
});
test('SUBSYSTEM_LIST uses networkd key (not network)', () => {
assertIncludes(SUBSYSTEM_KEYS.join(','), 'networkd', 'SUBSYSTEM_LIST should contain networkd');
assert(SUBSYSTEM_KEYS.indexOf('network') === -1, 'SUBSYSTEM_LIST should NOT contain network');
});
test('SUBSYSTEM_LIST keys match daemon response keys', () => {
const expectedKeys = ['firewall', 'dnsmasq', 'nginx', 'wireguard', 'networkd'];
for (const key of expectedKeys) {
assert(SUBSYSTEM_KEYS.includes(key), `SUBSYSTEM_LIST should contain ${key}`);
}
});
// === buildRows ===
test('buildRows returns 5 rows for empty subsystems', () => {
const rows = buildRows({}, {});
assertEq(rows.length, 5, 'should have 5 subsystem rows for empty state');
});
test('buildRows marks pending firewall subsystem correctly', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, {});
const fwRow = rows[0];
assertIncludes(fwRow.props.class, 'pending', 'firewall row should have pending class');
});
test('buildRows changes are VNodes with proper structure', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, {});
const fwRow = rows[0];
// Status span should contain "1 pending changes"
const statusSpan = fwRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
assert(statusSpan, 'should have status span');
const textChild = statusSpan.ch.find(c => c.tag === '#text');
assert(textChild && textChild.text.includes('pending changes'), 'status should contain pending changes count');
});
test('buildRows marks pending dnsmasq subsystem correctly', () => {
const data = {
dnsmasq: {
pending_changes: true,
changes: [
{ summary: 'DHCP/DNS configuration has unapplied changes', detail: '' },
],
},
};
const rows = buildRows(data, {});
const dnsmasqRow = rows[1];
assertIncludes(dnsmasqRow.props.class, 'pending', 'dnsmasq row should have pending class');
});
test('buildRows shows correct change count text', () => {
const data = {
dnsmasq: {
pending_changes: true,
changes: [
{ summary: 'Range 1', detail: '' },
{ summary: 'Range 2', detail: '' },
],
},
};
const rows = buildRows(data, {});
const dnsmasqRow = rows[1];
const statusSpan = dnsmasqRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
const textChild = statusSpan.ch.find(c => c.tag === '#text');
assertEq(textChild.text, '2 pending changes');
});
test('buildRows shows up-to-date for non-pending', () => {
const data = {
nginx: { pending_changes: false, changes: [] },
wireguard: { pending_changes: false, changes: [] },
};
const rows = buildRows(data, {});
const nginxRow = rows[2];
assert(
!nginxRow.props.class.includes('pending'),
'nginx row should not have pending class',
);
const statusSpan = nginxRow.ch.find(c => c.props.class && c.props.class.includes('apply-subsystem-status'));
const textChild = statusSpan.ch.find(c => c.tag === '#text');
assertEq(textChild.text, 'Up to date');
});
test('buildRows shows expand icon and details when expanded', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
{ summary: 'Zone dmz: services changed', detail: '' },
],
},
};
const rows = buildRows(data, { firewall: true });
assertEq(rows.length, 6, 'should have 6 items (5 rows + 1 detail section)');
});
test('buildRows hides expand icon when not expanded', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, {});
assertEq(rows.length, 5, 'should only have 5 rows, no detail section');
});
test('buildRows pending flag but no changes treated as up-to-date', () => {
const data = {
firewall: { needs_apply: true, changes: [] },
};
const rows = buildRows(data, {});
const fwRow = rows[0];
assert(
!fwRow.props.class.includes('pending'),
'no changes = up to date',
);
});
test('buildRows detail section contains item VNodes', () => {
const data = {
firewall: {
needs_apply: true,
changes: [
{ summary: 'Zone internal: interfaces changed', detail: '' },
],
},
};
const rows = buildRows(data, { firewall: true });
const detailSection = rows[1];
assertIncludes(detailSection.props.class, 'apply-detail-section', 'should be detail section');
assert(detailSection.ch.length > 0, 'detail section should have children');
});
test('buildRows handles networkd key correctly', () => {
const data = {
networkd: {
pending_changes: true,
changes: [
{ summary: 'Network configuration has unapplied changes', detail: '' },
],
},
};
const rows = buildRows(data, {});
const networkdRow = rows[4]; // networkd is 5th in list
assertIncludes(networkdRow.props.class, 'pending', 'networkd row should have pending class');
});
test('buildRows row VNodes have correct tag', () => {
const data = {
firewall: { needs_apply: true, changes: [{ summary: 'test', detail: '' }] },
};
const rows = buildRows(data, {});
for (const row of rows.slice(0, 5)) {
assertEq(row.tag, 'div', 'row should be a div');
assert(row.props.class.includes('apply-subsystem-row'), 'row should have apply-subsystem-row class');
}
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+203
View File
@@ -0,0 +1,203 @@
/**
* Tests for hoover/auth_model.js
*
* Model-level tests: the 'check' action (session validation, including the
* single refresh fallback on a 401) and the 'refresh' action (rotation).
*
* auth_model.js only pulls in model.js reactivity.js (no DOM at import),
* so the tests run under plain node with stubbed globals (fetch,
* sessionStorage, document, window, timers).
*
* Run with `node tests/test-auth-model.js`.
*/
import { createAuthModel } from '../webui/static/hoover/auth_model.js';
import { modelRegister, modelFetch, getModel } from '../webui/static/hoover/model.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── Stubs ─────────────────────────────────────────────────── */
function makeStorage(initial = {}) {
const m = new Map(Object.entries(initial));
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
keys: () => [...m.keys()],
};
}
/** Programmed URL → response queue. Missing routes are 500. */
function makeFetch() {
const byUrl = new Map();
globalThis.fetch = async (url) => {
const arr = byUrl.get(url) || [];
const r = arr.length ? arr.shift() : { status: 500, body: { ok: false, error: 'unprogrammed route' } };
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
json: async () => r.body,
};
};
const state = { calls: [], route: (url, status, body) => { if (!byUrl.has(url)) byUrl.set(url, []); state.calls.push(url); byUrl.get(url).push({ status, body }); } };
return state;
}
/** Stub the TTL refresh timer so the node process never waits on it. */
const _timers = [];
globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; };
globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; };
const storedSession = {
'vw:access': 'access-old',
'vw:refresh': 'refresh-old',
'vw:session_id': 'sess-old',
'vw:access_ttl': '900000',
};
/**
* Stubs + model registration for one scenario.
* @param {{initialStorage?: object, hash?: string}} [cfg]
* @returns {{calls: string[], route: function, events: Array}}
*/
function setup({ initialStorage = storedSession, hash = '#/dashboard' } = {}) {
const { calls, route } = makeFetch();
globalThis.sessionStorage = makeStorage(initialStorage);
globalThis.document = { location: { hash } };
const events = [];
globalThis.window = {
dispatchEvent: (e) => events.push(e),
addEventListener: () => {},
};
modelRegister('auth', createAuthModel());
return { calls, route, events };
}
/** Run the auth model action through the real modelFetch (dedup, hooks). */
async function act(action) {
await modelFetch('auth', { action });
}
/* ── Tests ─────────────────────────────────────────────────── */
test('check 200 returns verified identity on the stored tokens', async () => {
const s = setup();
s.route('/api/auth/session', 200, {
ok: true,
data: { user: { username: 'alice' }, permissions: { firewall: 'rw' } },
});
await act('check');
assertEq(s.calls.length, 1, 'session check only');
assertEq(s.calls[0], '/api/auth/session');
const data = getModel('auth').data;
assertEq(data.token, 'access-old', 'stored access token kept');
assertEq(data.refresh, 'refresh-old', 'stored refresh token kept');
assertEq(data.session_id, 'sess-old', 'stored session_id kept');
assertEq(data.user?.username, 'alice', 'verified user merged');
assert(data.permissions?.firewall === 'rw', 'verified permissions merged');
});
test('check 401 with stored refresh token: one refresh, session preserved', async () => {
const s = setup();
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
s.route('/api/auth/refresh', 200, {
ok: true,
data: {
tokens: { access_token: 'access-new', refresh_token: 'refresh-new', session_id: 'sess-new' },
user: { username: 'alice' },
permissions: { firewall: 'rw' },
access_ttl: 300,
},
});
await act('check');
assertEq(s.calls.length, 2, 'exactly one refresh attempt');
assertEq(s.calls[0], '/api/auth/session', 'session check first');
assertEq(s.calls[1], '/api/auth/refresh', 'refresh fallback second');
const data = getModel('auth').data;
assertEq(data.token, 'access-new', 'rotated access token');
assertEq(data.refresh, 'refresh-new', 'rotated refresh token');
assertEq(data.session_id, 'sess-new', 'rotated session_id binding wins');
assertEq(data.user?.username, 'alice', 'refreshed identity');
assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 'sess-new', 'storage carries rotated session_id');
assert(s.events.length === 0, 'no terminal event on recovered session');
});
test('check 401 without refresh token: terminal, no refresh attempted', async () => {
const s = setup({ initialStorage: { 'vw:access': 'access-old', 'vw:session_id': 'sess-old' } });
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
await act('check');
assertEq(s.calls.length, 1, 'session check only');
assertEq(s.calls[0], '/api/auth/session');
const data = getModel('auth').data;
assert(!data?.token, 'terminal: no token');
assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared');
assertEq(globalThis.document.location.hash, '/login', 'redirected to login');
assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched');
});
test('check 401 with failed refresh: terminal', async () => {
const s = setup();
s.route('/api/auth/session', 401, { ok: false, error: 'unauthorized' });
s.route('/api/auth/refresh', 401, { ok: false, error: 'invalid refresh token' });
await act('check');
assertEq(s.calls.length, 2, 'refresh was attempted');
assert(!getModel('auth').data?.token, 'terminal: no token');
assert(globalThis.sessionStorage.keys().length === 0, 'storage cleared');
assertEq(globalThis.document.location.hash, '/login', 'redirected to login');
assert(s.events.some((e) => e.type === 'auth:logout'), 'terminal auth:logout dispatched');
});
test('refresh action rotates tokens; new session_id wins, omitted fields fall back', async () => {
const s = setup();
s.route('/api/auth/refresh', 200, {
ok: true,
data: {
tokens: { access_token: 'a2', refresh_token: 'r2', session_id: 's2' },
access_ttl: 300,
user: { username: 'alice' },
permissions: { firewall: 'rw' },
},
});
await act('refresh');
assertEq(s.calls.length, 1, 'single refresh call');
assertEq(s.calls[0], '/api/auth/refresh');
const data = getModel('auth').data;
assertEq(data.token, 'a2', 'rotated access token');
assertEq(data.refresh, 'r2', 'rotated refresh token');
assertEq(data.session_id, 's2', 'new session_id wins');
assertEq(data.ttl, 300 * 1000, 'ttl from access_ttl seconds → ms');
assertEq(globalThis.sessionStorage.getItem('vw:session_id'), 's2', 'storage rotated');
assertEq(data.user?.username, 'alice', 'user from response');
});
/* ── Runner ────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+87
View File
@@ -0,0 +1,87 @@
/**
* Tests for hoover/components/applyconfirm.js CancelConfirm
*
* Component-level tests: VNode structure of the cancel button.
* Run with `node tests/test-cancelconfirm.js`.
*/
import { CancelConfirm, buildRows, SUBSYSTEM_LIST } from '../webui/static/hoover/components/applyconfirm.js';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
}
function assertIncludes(str, substr, msg) {
if (!str.includes(substr)) throw new Error(msg || `Expected "${str}" to contain "${substr}"`);
}
console.log('Testing CancelConfirm component\n');
test('CancelConfirm renders a button vnode', () => {
const vnode = CancelConfirm();
assertEq(vnode.tag, 'button');
});
test('CancelConfirm default label', () => {
const vnode = CancelConfirm();
const text = vnode.ch.find(c => c.tag === '#text');
assert(text, 'should have text child');
assertEq(text.text, 'Cancel All Changes');
});
test('CancelConfirm default class is danger', () => {
const vnode = CancelConfirm();
assertIncludes(vnode.props.class, 'btn-danger');
assertIncludes(vnode.props.class, 'btn');
});
test('CancelConfirm accepts a custom class', () => {
const vnode = CancelConfirm({ cls: 'btn btn-sm btn-danger' });
assertEq(vnode.props.class, 'btn btn-sm btn-danger');
});
test('CancelConfirm accepts a custom label', () => {
const vnode = CancelConfirm({ label: 'Discard Changes' });
const text = vnode.ch.find(c => c.tag === '#text');
assertEq(text.text, 'Discard Changes');
});
test('CancelConfirm has a click handler', () => {
const vnode = CancelConfirm();
assert(typeof vnode.props['on:click'] === 'function', 'on:click should be a function');
});
// === shared modal row builder (used by the cancel modal) ===
test('buildRows still drives the cancel modal rows', () => {
const data = {
firewall: { needs_apply: true, changes: [{ summary: 'Zone internal: interfaces changed', detail: '' }] },
dnsmasq: { pending_changes: true, changes: [{ summary: 'x', detail: '' }] },
};
const rows = buildRows(data, {});
assertEq(rows.length, SUBSYSTEM_LIST.length);
const fwRow = rows[0];
assertIncludes(fwRow.props.class, 'pending');
const dmRow = rows[1];
assertIncludes(dmRow.props.class, 'pending');
});
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
+111
View File
@@ -0,0 +1,111 @@
/**
* Tests for hoover/model.js modelSet() the WS data-streaming entry point.
*
* modelSet() bypasses the fetch cycle: it assigns directly to the reactive
* model, clears loading unconditionally (schema defaults mean model.data is
* never null), clears error, and never sets refreshing.
*
* model.js imports only reactivity.js DOM-free at import, so the tests
* run under plain node (same pattern as test-auth-model.js).
*
* Run with `node tests/test-model-set.js`.
*/
import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js';
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/** Deep equality for objects/arrays (assertEq is reference-based). */
function assertDeep(a, b, msg) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── Tests ─────────────────────────────────────────────────── */
test('modelSet assigns data and clears loading unconditionally', () => {
modelRegister('firewall', {
subsystem: 'firewall',
defaultData: SUBSYSTEMS.firewall.defaults,
fetch: async () => ({}),
});
const m = getModel('firewall');
assertEq(m.loading, true, 'registering model is loading');
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'schema defaults pre-populated');
assertEq(m.refreshing, false, 'not refreshing at rest');
modelSet('firewall', { zones: { public: {} }, pending: { pending: [] } });
assertEq(m.loading, false, 'real data ends the initial load');
assertEq(m.refreshing, false, 'modelSet never sets refreshing');
assertEq(m.error, null, 'modelSet clears error');
assertDeep(m.data.zones?.public, {}, 'data assigned to the reactive model');
});
test('modelSet is a no-op for an unregistered name', () => {
assertEq(typeof modelSet('no-such-model', { x: 1 }), 'undefined', 'no throw, no return');
});
test('modelSet clears a fetch-set error and replaces error-state data', async () => {
modelRegister('dnsmasq', {
subsystem: 'dnsmasq',
defaultData: SUBSYSTEMS.dnsmasq.defaults,
fetch: async () => { throw new Error('boom'); },
});
const m = getModel('dnsmasq');
await modelFetch('dnsmasq');
assertEq(m.error, 'boom', 'fetch failure sets error');
assertEq(m.loading, false, 'fetch failure clears loading');
assertEq(m.data, SUBSYSTEMS.dnsmasq.defaults, 'failed fetch keeps schema defaults');
const delta = { leases: [{ mac: 'aa:bb', ip: '10.0.0.9' }] };
modelSet('dnsmasq', delta);
assertEq(m.error, null, 'subsequent real data clears the error');
assertEq(m.data.leases?.[0]?.mac, 'aa:bb', 'delta replaces default data');
});
test('modelSet works repeatedly without flag corruption', () => {
modelRegister('system', {
subsystem: 'system',
defaultData: SUBSYSTEMS.system.defaults,
fetch: async () => ({}),
});
const m = getModel('system');
modelSet('system', { load: { load1: 0.1 } });
modelSet('system', { load: { load1: 0.2 } });
assertEq(m.loading, false, 'still not loading');
assertEq(m.refreshing, false, 'still not refreshing');
assertEq(m.error, null, 'no error');
assertEq(m.data.load?.load1, 0.2, 'latest delta wins');
});
/* ── Runner ────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+175
View File
@@ -0,0 +1,175 @@
/**
* Tests for the 3-second HTTP-fallback contract (Phase 3e).
*
* app.js cannot be imported under node (it imports every page and touches
* the DOM), so this covers the model-layer contract the deferred fetch
* decides on:
*
* - a model registered with schema defaults keeps `loading: true`
* (data is never null that's why the guard is `if (model.loading)`)
* - `modelSet()` or a completed `modelFetch()` clears `loading`
* - the `if (model.loading) modelFetch(name)` decision fires ONLY for
* still-loading models a WS-delivered snapshot suppresses the HTTP
* fallback for that model
*
* Uses a fake setTimeout queue and recording fetch stubs (no timers, no
* network) same pattern as test-auth-model.js.
*
* Run with `node tests/test-reconnect-fallback.js`.
*/
import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js';
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/** Deep equality for objects/arrays (assertEq is reference-based). */
function assertDeep(a, b, msg) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── Stubs ─────────────────────────────────────────────────── */
/** Fake setTimeout queue — captures scheduled fallbacks, never runs them. */
const _timers = [];
globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; };
globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; };
/** Drain pending timers in order, like a real 3s elapse. */
async function runTimers() {
while (_timers.length) {
const t = _timers.shift();
if (t) await t.fn();
}
}
/** Register one state-backed model with recording fetch. */
function registerModel(name) {
const calls = { count: 0 };
modelRegister(name, {
subsystem: name,
defaultData: SUBSYSTEMS[name].defaults,
fetch: async () => {
calls.count++;
return { fetched: true, name };
},
});
return calls;
}
/* ── Mirrors app.js fetchInitialData() decision logic ───────── */
/** Schedule the per-model 3s fallback timers (as app.js does). */
function scheduleFallbacks(names) {
for (const name of names) {
setTimeout(() => {
const model = getModel(name);
if (model.loading) {
modelFetch(name);
}
}, 3000);
}
}
/* ── Tests ─────────────────────────────────────────────────── */
test('schema-defaulted model: loading stays true until modelSet or fetch completes', () => {
registerModel('firewall');
const m = getModel('firewall');
assertEq(m.loading, true, 'fresh model is loading');
assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'data is schema defaults (never null)');
assertEq(m.error, null, 'no error yet');
modelSet('firewall', { zones: { public: {} } });
assertEq(m.loading, false, 'modelSet clears loading');
registerModel('dnsmasq');
const d = getModel('dnsmasq');
assertEq(d.loading, true, 'a different fresh model is still loading');
});
test('3s fallback fetches only models still loading (snapshot suppresses HTTP)', async () => {
const firewallCalls = registerModel('firewall');
const dnsmasqCalls = registerModel('dnsmasq');
scheduleFallbacks(['firewall', 'dnsmasq']);
// The WS snapshot arrived first for firewall only.
modelSet('firewall', { zones: { internal: {} } });
await runTimers();
assertEq(firewallCalls.count, 0, 'snapshot-delivered model: no HTTP fallback');
assertEq(dnsmasqCalls.count, 1, 'still-loading model: HTTP fallback fired');
const fw = getModel('firewall');
assertEq(fw.loading, false, 'firewall not loading');
assertDeep(fw.data.zones?.internal, {}, 'firewall keeps the snapshot data, not fetch output');
const dm = getModel('dnsmasq');
assertEq(dm.loading, false, 'completed fetch clears loading');
assertEq(dm.data.fetched, true, 'dnsmasq got the fallback data');
});
test('a re-fired decision never double-fetches a settled model', async () => {
const calls = registerModel('acme');
scheduleFallbacks(['acme']);
await runTimers();
assertEq(calls.count, 1, 'first fallback fetch');
// A later decision pass (e.g. reconnect path) must not re-fetch.
const model = getModel('acme');
if (model.loading) modelFetch('acme');
await runTimers();
assertEq(calls.count, 1, 'no double fetch once settled');
});
test('fallback fetch failure lands in model.error, self-heals on next data', async () => {
modelRegister('wireguard', {
subsystem: 'wireguard',
defaultData: SUBSYSTEMS.wireguard.defaults,
fetch: async () => { throw new Error('state not populated yet'); },
});
const m = getModel('wireguard');
setTimeout(() => { if (m.loading) modelFetch('wireguard'); }, 3000);
await runTimers();
assertEq(m.error, 'state not populated yet', 'failure sets model.error');
assertEq(m.loading, false, 'failure still clears loading (finally)');
assertEq(m.data, SUBSYSTEMS.wireguard.defaults, 'schema defaults preserved on failure');
modelSet('wireguard', { up: false });
assertEq(m.error, null, 'next real data clears the error');
assertEq(m.data.up, false, 'real data lands');
});
/* ── Runner ────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+220
View File
@@ -0,0 +1,220 @@
/**
* Tests for hoover/websocket.js handleMessage() WS data streaming.
*
* handleMessage is driven through a real connect() against a stubbed
* globalThis.WebSocket: we record the constructed instance and call its
* onmessage handler with serialized daemonclient messages, then assert the
* reactive model state. Covers the snapshot fast path, per-subsystem deltas
* (including the networkdnetwork mapping), null-payload guards, and that
* retired/legacy message types are ignored (no model mutation, no throw).
*
* websocket.js pulls in model.js reactivity.js and auth_model.js
* (DOM-free at import), so it runs under plain node with stubbed globals.
*
* Run with `node tests/test-ws-handler.js`.
*/
import { connect } from '../webui/static/hoover/websocket.js';
import { modelRegister, getModel, modelSet } from '../webui/static/hoover/model.js';
import { SUBSYSTEMS } from '../webui/static/hoover/schema.js';
let passed = 0;
let failed = 0;
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function assert(cond, msg) {
if (!cond) throw new Error(msg || 'Assertion failed');
}
function assertEq(a, b, msg) {
if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/** Deep equality for objects/arrays (assertEq is reference-based). */
function assertDeep(a, b, msg) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`);
}
/* ── Stubs ─────────────────────────────────────────────────── */
function makeStorage(initial = {}) {
const m = new Map(Object.entries(initial));
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
keys: () => [...m.keys()],
};
}
// Stub the TTL refresh timer so the node process never waits on it.
const _timers = [];
globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; };
globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; };
globalThis.location = { protocol: 'http:', host: '127.0.0.1:9090' };
globalThis.sessionStorage = makeStorage({ 'vw:access': 'tok-abc.def.ghi' });
globalThis.document = { location: { hash: '#/dashboard' } };
globalThis.window = { dispatchEvent: () => {}, addEventListener: () => {} };
/** Records each constructed WebSocket so tests can drive onmessage. */
class FakeWebSocket {
static instances = [];
constructor(url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = 1;
this.onopen = null;
this.onclose = null;
this.onerror = null;
this.onmessage = null;
FakeWebSocket.instances.push(this);
}
close() { this.readyState = 3; }
send() {}
}
globalThis.WebSocket = FakeWebSocket;
/** Register the auth + state models the WS handler depends on. */
function setupModels() {
modelRegister('auth', { subsystem: 'auth', fetch: async () => ({}) });
modelSet('auth', { token: 'tok-abc.def.ghi', user: { username: 'admin' } });
for (const [name, subsystem] of [
['firewall', 'firewall'], ['dnsmasq', 'dnsmasq'], ['nginx', 'nginx'],
['acme', 'acme'], ['wireguard', 'wireguard'], ['network', 'networkd'], ['system', 'system'],
]) {
modelRegister(name, {
subsystem,
defaultData: SUBSYSTEMS[subsystem].defaults,
fetch: async () => ({}),
});
}
}
/** Establish a fresh WS connection and return the recorded instance. */
function freshConnect() {
const prev = FakeWebSocket.instances.at(-1);
if (prev && prev.readyState <= 1) { prev.onclose = null; prev.close(); }
connect();
return FakeWebSocket.instances.at(-1);
}
/** Feed one daemon→client message through the recorded instance. */
function emit(inst, msg) {
inst.onmessage({ data: JSON.stringify(msg) });
}
const SNAPSHOT = {
type: 'snapshot',
data: {
firewall: { zones: { public: {} } },
dnsmasq: { leases: [] },
nginx: null, // collector failed → must be skipped
acme: { certs: [] },
wireguard: { up: true },
networkd: { interfaces: { eth0: {} } },
system: { load: { load1: 0.5 } },
},
};
/* ── Tests ─────────────────────────────────────────────────── */
test('connect() sends the raw JWT as the Sec-WebSocket-Protocol subprotocol', () => {
setupModels();
const inst = freshConnect();
assert(inst, 'a WS instance was constructed');
assertEq(inst.url, 'ws://127.0.0.1:9090/ws', 'WS URL from origin');
assert(Array.isArray(inst.protocols), 'subprotocols passed');
assertEq(inst.protocols[0], 'tok-abc.def.ghi', 'bare JWT (no Bearer prefix)');
});
test('snapshot fast path sets every non-null model; null entries are skipped', () => {
setupModels();
const inst = freshConnect();
emit(inst, SNAPSHOT);
assertDeep(getModel('firewall').data.zones?.public, {}, 'firewall patched');
assertDeep(getModel('dnsmasq').data.leases, [], 'dnsmasq patched');
assertDeep(getModel('acme').data.certs, [], 'acme patched');
assertEq(getModel('wireguard').data.up, true, 'wireguard patched');
assertDeep(getModel('network').data.interfaces?.eth0, {}, 'networkd mapped → network');
assertEq(getModel('system').data.load?.load1, 0.5, 'system patched');
// nginx data was null — modelSet was skipped entirely.
const nginx = getModel('nginx');
assertDeep(nginx.data, SUBSYSTEMS.nginx.defaults, 'null entry keeps schema defaults');
assertEq(nginx.loading, true, 'null entry never clears loading');
// Non-null models had loading cleared by modelSet.
assertEq(getModel('firewall').loading, false, 'loading cleared on real data');
});
test('versions delta patches the mapped subsystem model', () => {
setupModels();
const inst = freshConnect();
const before = JSON.stringify(getModel('firewall').data);
emit(inst, { type: 'versions', subsystem: 'firewall', data: { zones: { dmz: {} } } });
assert(getModel('firewall').data.zones?.dmz !== undefined, 'firewall delta applied');
assertEq(JSON.stringify(getModel('dnsmasq').data), JSON.stringify(SUBSYSTEMS.dnsmasq.defaults), 'other models untouched');
});
test('networkd delta maps to the network model', () => {
setupModels();
const inst = freshConnect();
emit(inst, { type: 'versions', subsystem: 'networkd', data: { interfaces: { lo: {} } } });
assertDeep(getModel('network').data.interfaces?.lo, {}, 'networkd → network');
});
test('a null payload delta never overwrites good data (defense in depth)', () => {
setupModels();
const inst = freshConnect();
emit(inst, SNAPSHOT); // networkd has data
const kept = getModel('network').data;
emit(inst, { type: 'tick', subsystem: 'networkd', data: null });
assertDeep(getModel('network').data, kept, 'null data left model untouched');
emit(inst, { type: 'versions', subsystem: 'firewall', data: null });
assertDeep(getModel('firewall').data.zones?.public, {}, 'null firewall data left model untouched');
});
test('retired/legacy message types are ignored (no mutation, no throw)', () => {
setupModels();
const inst = freshConnect();
emit(inst, SNAPSHOT);
const before = {};
for (const n of ['firewall', 'dnsmasq', 'nginx', 'acme', 'wireguard', 'network', 'system']) {
before[n] = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading;
}
const legacy = [
{ type: 'versions', updated: { firewall: 1 } }, // legacy dict form
{ type: 'tick', subsystems: ['firewall', 'wireguard'] }, // legacy array form
{ type: 'refresh', topic: 'firewall' },
{ type: 'notify', topic: 'firewall' },
{ type: 'status', topic: 'firewall' },
{ type: 'unknown' },
];
for (const msg of legacy) emit(inst, msg);
for (const n of Object.keys(before)) {
const now = JSON.stringify(getModel(n).data) + '|' + getModel(n).loading;
assertEq(now, before[n], `model ${n} unchanged by legacy message`);
}
});
/* ── Runner ────────────────────────────────────────────────── */
(async () => {
for (const { name, fn } of tests) {
try {
await fn();
console.log(` \u2713 ${name}`);
passed++;
} catch (e) {
console.error(` \u2717 ${name}: ${e.message}`);
failed++;
}
}
console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`);
process.exitCode = failed ? 1 : 0;
})();
+14
View File
@@ -117,6 +117,20 @@ class TestParseListOutput:
result = acme._parse_list_output(raw)
assert result == []
def test_pipe_separated_raw_format(self):
"""Pipe-separated output with empty fields (what --listraw produces)."""
raw = (
"Main_Domain|KeyLength|SAN_Domains|Profile|CA|Created|Renew\n"
'example.com|"ec-256"|no||ZeroSSL.com|2026-01-01|2026-07-01\n'
)
result = acme._parse_list_output(raw)
assert len(result) == 1
assert result[0]["main_domain"] == "example.com"
assert result[0]["profile"] == ""
assert result[0]["ca"] == "ZeroSSL.com"
assert result[0]["created"] == "2026-01-01"
assert result[0]["renew"] == "2026-07-01"
class TestDaysUntil:
def test_future_date(self):
+136 -1
View File
@@ -40,6 +40,11 @@ def _ne(func, **kw):
return _patch(f"webui.api.network.{func}", **kw)
def _st(func, **kw):
"""Patch daemon.client.{func} in the status blueprint namespace."""
return _patch(f"webui.api.status.{func}", **kw)
@pytest.fixture
def client():
from flask import Flask
@@ -462,7 +467,7 @@ class TestProxyDomains:
mock_post.return_value = {"domain": "ex.com"}
resp = client.post(
"/api/proxy/domains",
json={"domain": "ex.com", "backend_host": "10.0.0.1", "backend_port": 80},
json={"domain": "ex.com", "backend": "webui"},
)
assert resp.status_code == 200
@@ -525,6 +530,64 @@ class TestCertsIssue:
assert resp.status_code == 400
class TestCertsRenew:
@_ce("post")
def test_start_renew(self, mock_post, client):
mock_post.return_value = {"request_id": "abc123", "domain": "example.com"}
resp = client.post("/api/certs/example.com/renew")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["request_id"] == "abc123"
args = mock_post.call_args.args
assert args[0] == ("POST", "/acme/renew")
assert args[1] == {"domain": "example.com"}
@_ce("post")
def test_start_renew_rejected(self, mock_post, client):
from daemon.client import BadRequest
mock_post.side_effect = BadRequest("'domain' is required")
resp = client.post("/api/certs/example.com/renew")
assert resp.status_code == 400
assert resp.get_json()["ok"] is False
@_ce("post")
def test_start_renew_runtime_error(self, mock_post, client):
mock_post.side_effect = RuntimeError("daemon unreachable")
resp = client.post("/api/certs/example.com/renew")
assert resp.status_code == 500
assert resp.get_json()["ok"] is False
class TestCertsRenewStatus:
@_ce("get")
def test_success(self, mock_get, client):
mock_get.return_value = {
"request_id": "abc123",
"domain": "example.com",
"status": "completed",
"steps": [],
}
resp = client.get("/api/certs/renew/abc123")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"]["status"] == "completed"
args = mock_get.call_args.args
assert args[0] == ("GET", "/acme/renew/status")
assert args[1] == {"id": "abc123"}
@_ce("get")
def test_not_found(self, mock_get, client):
from daemon.client import NotFound
mock_get.side_effect = NotFound("renewal request not found")
resp = client.get("/api/certs/renew/unknown")
assert resp.status_code == 404
assert resp.get_json()["ok"] is False
class TestCertsEmail:
def test_missing_email(self, client):
resp = client.post("/api/certs/email", json={})
@@ -883,3 +946,75 @@ class TestNetworkApplyAll:
mock_post.side_effect = RuntimeError("apply failed")
resp = client.post("/api/network/apply")
assert resp.status_code == 500
# ============================================================================
# Status
# ============================================================================
@pytest.fixture
def status_client():
from flask import Flask
from webui.api.status import bp as status_bp
app = Flask(__name__)
app.register_blueprint(status_bp, url_prefix="/api/status")
return app.test_client()
class TestStatusRefresh:
def test_filtered_subsystems_passed_through(self, status_client):
"""The subsystem body is forwarded to the daemon POST endpoint."""
from daemon.iface import POST_STATUS_REFRESH
with _st("post") as mock_post:
mock_post.return_value = {"firewall": {"zones": {}}}
resp = status_client.post(
"/api/status/refresh", json={"subsystems": ["firewall"]}
)
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"] == {"firewall": {"zones": {}}}
mock_post.assert_called_once_with(
POST_STATUS_REFRESH, {"subsystems": ["firewall"]}
)
def test_empty_body_forwards_empty_dict(self, status_client):
"""An empty body becomes {} (daemon-side 'all subsystems' default)."""
from daemon.iface import POST_STATUS_REFRESH
with _st("post") as mock_post:
mock_post.return_value = {}
resp = status_client.post("/api/status/refresh")
assert resp.status_code == 200
mock_post.assert_called_once_with(POST_STATUS_REFRESH, {})
@_st("post")
def test_runtime_error(self, mock_post, status_client):
mock_post.side_effect = RuntimeError("no daemon")
resp = status_client.post("/api/status/refresh", json={})
assert resp.status_code == 500
class TestStatusCancelAll:
@_st("post")
def test_success(self, mock_post, status_client):
from daemon.iface import POST_STATUS_CANCEL_ALL
mock_post.return_value = {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
resp = status_client.post("/api/status/cancel-all")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert data["data"] == {"cancelled": ["dnsmasq"], "skipped": {}, "errors": {}}
mock_post.assert_called_once_with(POST_STATUS_CANCEL_ALL)
@_st("post")
def test_runtime_error(self, mock_post, status_client):
mock_post.side_effect = RuntimeError("no daemon")
resp = status_client.post("/api/status/cancel-all")
assert resp.status_code == 500
assert resp.get_json()["ok"] is False
+1468
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
"""Tests for broadcast_versions per-subsystem contract (daemon.server).
broadcast_versions(subsystem) sends exactly one data-carrying message for
its subsystem no legacy `updated` field and skips the broadcast
entirely when the subsystem's state is None (collector failed).
"""
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import daemon.server as server
class TestBroadcastVersionsPerSubsystem:
def _ws(self):
ws = AsyncMock()
ws.send_str = AsyncMock()
server._ws_subscribers.add(ws)
return ws
def test_only_target_subsystem_sent(self):
"""Each subscriber gets one versions message carrying that subsystem."""
store = MagicMock()
store.get.side_effect = lambda name: {"v": name}
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
ws.send_str.assert_awaited_once()
msg = json.loads(ws.send_str.call_args[0][0])
assert msg == {
"type": "versions",
"subsystem": "firewall",
"data": {"v": "firewall"},
}
finally:
server._ws_subscribers.discard(ws)
def test_data_per_subsystem_not_shared(self):
"""The data payload is that subsystem's state, not another's."""
store = MagicMock()
store.get.side_effect = lambda name: {"name": name}
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("dnsmasq"))
asyncio.run(server.broadcast_versions("acme"))
msgs = [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
assert [(m["subsystem"], m["data"]) for m in msgs] == [
("dnsmasq", {"name": "dnsmasq"}),
("acme", {"name": "acme"}),
]
# No legacy diff field in any message.
for m in msgs:
assert "updated" not in m
finally:
server._ws_subscribers.discard(ws)
def test_none_state_produces_no_message(self):
"""A None payload (failed collection) is skipped — no clobber."""
store = MagicMock()
store.get.return_value = None
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
ws.send_str.assert_not_awaited()
finally:
server._ws_subscribers.discard(ws)
def test_no_bump_called(self):
"""broadcast_versions never bumps — callers own the version counter."""
store = MagicMock()
store.get.return_value = {"a": 1}
store.bump = MagicMock()
ws = self._ws()
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
store.bump.assert_not_called()
finally:
server._ws_subscribers.discard(ws)
def test_dead_subscriber_removed(self):
"""A failing subscriber is pruned and healthy ones still receive data."""
store = MagicMock()
store.get.return_value = {"a": 1}
dead = AsyncMock()
dead.send_str = AsyncMock(side_effect=Exception("broken"))
healthy = self._ws()
server._ws_subscribers.add(dead)
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
assert dead not in server._ws_subscribers
healthy.send_str.assert_awaited_once()
finally:
server._ws_subscribers.discard(dead)
server._ws_subscribers.discard(healthy)
+150
View File
@@ -0,0 +1,150 @@
"""Tests for lib.common apply-metadata and diff helpers."""
from __future__ import annotations
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
deep_diff,
load_json,
revert_to_applied,
save_json,
stamp_applied,
strip_apply_meta,
)
class TestStripApplyMeta:
def test_strips_both_keys(self):
cfg = {"a": 1, _APPLY_HASH_KEY: "h", _LAST_APPLIED_CONFIG_KEY: {}}
assert strip_apply_meta(cfg) == {"a": 1}
def test_missing_keys(self):
assert strip_apply_meta({"a": 1}) == {"a": 1}
def test_does_not_mutate_input(self):
cfg = {"a": 1, _APPLY_HASH_KEY: "h"}
strip_apply_meta(cfg)
assert _APPLY_HASH_KEY in cfg
class TestConfigHashIgnoresMeta:
def test_hash_unaffected_by_metadata(self):
cfg = {"a": 1}
stamped = {"a": 1, _APPLY_HASH_KEY: "x", _LAST_APPLIED_CONFIG_KEY: {"a": 1}}
assert config_hash(cfg) == config_hash(stamped)
class TestStampApplied:
def test_records_snapshot_and_hash(self):
cfg = {"a": 1}
stamp_applied(cfg)
assert cfg[_LAST_APPLIED_CONFIG_KEY] == {"a": 1}
assert cfg[_APPLY_HASH_KEY] == config_hash(cfg)
def test_stable(self):
cfg = {"a": 1}
stamp_applied(cfg)
# A pending-style check: hash matches the current (stripped) config.
assert _APPLY_HASH_KEY in cfg and cfg[_APPLY_HASH_KEY] == config_hash(cfg)
# No drift → no diff.
assert (
deep_diff(cfg.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(cfg))
== []
)
class TestDeepDiff:
def test_identical_empty(self):
assert deep_diff({"a": 1, _APPLY_HASH_KEY: "h"}, {"a": 1}) == []
def test_changed_scalar(self):
diff = deep_diff({"a": 1}, {"a": 2})
assert diff == [{"path": "a", "action": "changed", "old": 1, "new": 2}]
def test_added_removed(self):
added = deep_diff({}, {"a": 1})
assert added[0]["action"] == "added" and added[0]["new"] == 1
removed = deep_diff({"a": 1}, {})
assert removed[0]["action"] == "removed" and removed[0]["old"] == 1
def test_nested_and_list_index(self):
old = {"z": {"svc": ["http"], "ranges": [{"ip": "10.0.0.1", "n": 1}]}}
new = {"z": {"svc": ["http", "ssh"], "ranges": [{"ip": "10.0.0.2", "n": 1}]}}
paths = {d["path"] for d in deep_diff(old, new)}
assert "z.svc" in paths
assert "z.ranges[0].ip" in paths
assert not any(p.startswith("z.ranges[0].n") for p in paths)
class TestDashboardFallback:
def test_hash_subsystem_unchanged_generic(self):
# Guards that a pending status without a snapshot still yields a
# renderable pending flag (frontend falls back to a generic line).
status = {"pending_changes": True, "pending_diff": []}
assert status["pending_changes"] is True
assert status["pending_diff"] == []
class TestRevertToApplied:
def test_restores_snapshot_and_clears_pending(self, tmp_path):
path = tmp_path / "config.json"
applied = {"zones": {"lan": {"services": ["http"]}}}
stamped = dict(applied)
stamp_applied(stamped)
# Drift the file after apply (the "pending" state).
dirty = {"zones": {"lan": {"services": ["http", "ssh"]}}}
dirty[_LAST_APPLIED_CONFIG_KEY] = dict(applied)
dirty[_APPLY_HASH_KEY] = stamped[_APPLY_HASH_KEY]
save_json(path, dirty, indent=2)
# Sanity: pending check would report drift.
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty)
ok, reason = revert_to_applied(path)
assert ok and reason == ""
restored = load_json(path)
assert _APPLY_HASH_KEY in restored and restored[_APPLY_HASH_KEY] == config_hash(
restored
)
assert (
_LAST_APPLIED_CONFIG_KEY in restored
and restored[_LAST_APPLIED_CONFIG_KEY] == applied
)
assert (
deep_diff(
restored.get(_LAST_APPLIED_CONFIG_KEY, {}), strip_apply_meta(restored)
)
== []
)
def test_no_baseline(self, tmp_path):
path = tmp_path / "config.json"
save_json(path, {"zones": {}}, indent=2)
ok, reason = revert_to_applied(path)
assert not ok
assert reason
# File untouched.
assert _LAST_APPLIED_CONFIG_KEY not in load_json(path)
def test_missing_file(self, tmp_path):
ok, reason = revert_to_applied(tmp_path / "nope.json")
assert not ok
assert reason
def test_stale_hash_but_snapshot_present(self, tmp_path):
# Baseline recorded, hash stale (drifted) → still revertable.
path = tmp_path / "config.json"
applied = {"a": 1}
stamped = dict(applied)
stamp_applied(stamped)
stamp = dict(stamped)
stamp["a"] = 99 # edited without re-stamping
save_json(path, stamp, indent=2)
assert stamp[_APPLY_HASH_KEY] != config_hash(stamp)
ok, _ = revert_to_applied(path)
assert ok
restored = load_json(path)
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
-81
View File
@@ -70,87 +70,6 @@ class TestSaveConfig:
assert loaded["dns"]["domain"] == "test.lan"
class TestSetDhcpRange:
def test_add_new_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "192.168.1.100", "192.168.1.200")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["interface"] == "eth1"
assert cfg["dhcp"]["ranges"][0]["start"] == "192.168.1.100"
def test_replace_existing_range(self, temp_data_dir):
dnsmasq.set_dhcp_range("eth1", "10.0.0.100", "10.0.0.200")
dnsmasq.set_dhcp_range("eth1", "10.0.0.150", "10.0.0.250")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["ranges"]) == 1
assert cfg["dhcp"]["ranges"][0]["start"] == "10.0.0.150"
class TestStaticLeases:
def test_add_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC:DD:EE:FF", "10.0.0.50", "printer")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert cfg["dhcp"]["static_leases"][0]["hostname"] == "printer"
def test_update_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.51")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["ip"] == "10.0.0.51"
def test_remove_static_lease(self, temp_data_dir):
dnsmasq.add_static_lease("AA:BB:CC", "10.0.0.50")
dnsmasq.add_static_lease("11:22:33", "10.0.0.51")
dnsmasq.remove_static_lease("aa:bb:cc")
cfg = dnsmasq.get_config()
assert len(cfg["dhcp"]["static_leases"]) == 1
assert cfg["dhcp"]["static_leases"][0]["mac"] == "11:22:33"
class TestDnsRecords:
def test_add_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
def test_remove_dns_record(self, temp_data_dir):
dnsmasq.add_dns_record("host", "10.0.0.100")
dnsmasq.add_dns_record("other", "10.0.0.101")
dnsmasq.remove_dns_record("host")
cfg = dnsmasq.get_config()
assert len(cfg["dns"]["custom_records"]) == 1
assert cfg["dns"]["custom_records"][0]["name"] == "other"
class TestParseLeaseLine:
def test_valid_line(self):
line = "1700000000 AA:BB:CC:DD:EE:FF 10.0.0.50 printer eth1"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["mac"] == "AA:BB:CC:DD:EE:FF"
assert result["ip"] == "10.0.0.50"
assert result["hostname"] == "printer"
def test_empty_line(self):
assert dnsmasq._parse_lease_line("") is None
def test_comment_line(self):
assert dnsmasq._parse_lease_line("# comment") is None
def test_short_line(self):
assert dnsmasq._parse_lease_line("incomplete") is None
def test_minimal_fields(self):
line = "1700000000 AA:BB:CC 10.0.0.50"
result = dnsmasq._parse_lease_line(line)
assert result is not None
assert result["hostname"] == ""
assert result["interface"] == ""
class TestUpstreamsAndDomain:
def test_set_upstreams(self, temp_data_dir):
dnsmasq.set_upstreams(["1.1.1.1", "9.9.9.9"])
+564 -5
View File
@@ -1,12 +1,19 @@
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
from unittest.mock import patch
from copy import deepcopy
from unittest.mock import MagicMock, call, patch
import pytest
from daemon.handlers import firewall as daemonfirewall
from daemon.server import NotFoundError
from daemon.server import ConflictError, NotFoundError
from lib import firewall
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
strip_apply_meta,
)
# ---------------------------------------------------------------------------
# lib/firewall.py — pure parsing (no sudo)
@@ -244,18 +251,87 @@ class TestConfigPending:
@patch("lib.firewall.get_config")
def test_detects_unmanaged_zones(self, mock_cfg):
# A custom live zone not in config is flagged as unmanaged.
mock_cfg.return_value = {"zones": {}}
state = {
"zones": {
"public": {
"interfaces": ["eth0"],
"guest": {
"interfaces": ["eth5"],
"services": [],
"masquerade": False,
},
},
}
result = firewall.config_pending(state)
assert "public" in result["unmanaged_zones"]
assert "guest" in result["unmanaged_zones"]
@patch("lib.firewall.get_config")
def test_built_in_zones_not_unmanaged(self, mock_cfg):
# firewalld built-in zones are always present and must not be
# reported as unmanaged, so they never surface as noise.
mock_cfg.return_value = {"zones": {}}
state = {
"zones": {
"public": {"interfaces": ["eth0"], "services": [], "masquerade": True},
"trusted": {"interfaces": ["lo"], "services": [], "masquerade": False},
"dmz": {"interfaces": ["eth7"], "services": [], "masquerade": False},
},
}
result = firewall.config_pending(state)
assert result["unmanaged_zones"] == {}
@patch("lib.firewall.get_config")
def test_public_masquerade_not_pending(self, mock_cfg):
# public zone masquerade is driven by apply's propagation step, so a
# config-vs-live masquerade mismatch on public is not a pending change.
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
state = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": True,
},
},
}
result = firewall.config_pending(state)
assert not any(c["type"] == "masquerade" for c in result["pending"])
assert result["needs_apply"] is False
@patch("lib.firewall.get_config")
def test_non_public_masquerade_is_pending(self, mock_cfg):
# A non-public zone with a masquerade mismatch IS a pending change.
mock_cfg.return_value = {
"zones": {
"internal": {
"interfaces": ["eth1"],
"services": [],
"masquerade": False,
},
},
}
state = {
"zones": {
"internal": {
"interfaces": ["eth1"],
"services": [],
"masquerade": True,
},
},
}
result = firewall.config_pending(state)
assert any(
c["type"] == "masquerade" and c["zone"] == "internal"
for c in result["pending"]
)
# ---------------------------------------------------------------------------
@@ -462,11 +538,237 @@ class TestDaemonConfigApply:
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
# ---------------------------------------------------------------------------
# Management-lockout guard: default zone must keep https or ssh
# ---------------------------------------------------------------------------
class TestDaemonMgmtLockoutGuard:
ZONES_OUT = "public\ninternal"
@patch("daemon.handlers.firewall._default_zone", return_value="public")
def test_set_zone_services_blocks_default_zone(self, mock_dz):
with (
patch(
"daemon.handlers.firewall.run", return_value=self.ZONES_OUT
) as mock_run,
pytest.raises(ConflictError) as exc,
):
daemonfirewall.set_zone_services(
None, {"zone": "public", "services": ["http"]}
)
assert "https and ssh" in str(exc.value)
# Guard fires before any mutation: only the zone-existence check ran.
assert mock_run.call_args_list == [
call(["firewall-cmd", "--get-zones"], sudo=True),
]
@patch("daemon.handlers.firewall._default_zone", return_value="public")
def test_set_zone_services_force_bypasses_guard(self, mock_dz):
with (
patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT),
patch.object(
daemonfirewall, "_parse_zone_output", return_value={"services": []}
),
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config") as mock_save,
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
None, {"zone": "public", "services": ["http"], "force": True}
)
assert result == {"zone": "public", "services": ["http"]}
cfg = mock_save.call_args[0][0]
assert cfg["zones"]["public"]["services"] == ["http"]
@patch("daemon.handlers.firewall._default_zone", return_value="internal")
def test_set_zone_services_non_default_zone_allowed(self, mock_dz):
with (
patch("daemon.handlers.firewall.run", return_value=self.ZONES_OUT),
patch.object(
daemonfirewall, "_parse_zone_output", return_value={"services": []}
),
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.set_zone_services(
None, {"zone": "public", "services": []}
)
assert result == {"zone": "public", "services": []}
def test_would_remove_mgmt_keeps_https(self):
assert daemonfirewall._would_remove_mgmt("public", ["http", "https"]) is False
assert daemonfirewall._would_remove_mgmt("public", ["ssh"]) is False
def test_would_remove_mgmt_fails_closed_on_error(self):
with patch(
"daemon.handlers.firewall._default_zone", side_effect=RuntimeError("boom")
):
assert daemonfirewall._would_remove_mgmt("public", ["http"]) is True
@patch("daemon.handlers.firewall._default_zone", return_value="default-zone")
def test_would_remove_mgmt_other_zone(self, mock_dz):
assert daemonfirewall._would_remove_mgmt("public", ["http"]) is False
@patch(
"lib.firewall.get_config",
return_value={
"zones": {"public": {"services": ["http"], "interfaces": ["eth0"]}}
},
create=True,
)
@patch("daemon.handlers.firewall._default_zone", return_value="public")
def test_config_apply_blocks_lockout_before_backup(self, mock_dz, mock_cfg):
with (
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
assert "https and ssh" in str(exc.value)
mock_backup.assert_not_called()
@patch(
"lib.firewall.get_config",
return_value={
"zones": {
"public": {
"target": "DEFAULT",
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
},
create=True,
)
@patch(
"daemon.handlers.firewall.run",
return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n",
)
@patch("daemon.handlers.firewall._default_zone", return_value="public")
def test_config_apply_force_bypasses_guard(self, mock_dz, mock_run, mock_cfg):
with (
patch(
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
),
patch(
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply(force=True)
assert result["applied_zones"] == ["public"]
_STAMP_TEST_CFG = {
"zones": {
"public": {
"target": "DEFAULT",
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
class TestDaemonConfigApplyStamp:
"""Verify _config_apply records the applied baseline in the config file."""
ZONE_LIST_ALL_OUT = (
"target: default\ninterfaces: \nsources: \nservices: \nports: \n"
"protocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \n"
"icmp-blocks: \nmodule: \n"
)
@patch(
"lib.firewall.get_config",
return_value=_STAMP_TEST_CFG,
create=True,
)
@patch(
"daemon.handlers.firewall.run",
return_value=ZONE_LIST_ALL_OUT,
)
def test_stamps_applied_baseline(self, mock_run, mock_cfg):
with (
patch(
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
),
patch(
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(_STAMP_TEST_CFG),
),
patch("daemon.handlers.firewall._save_config") as mock_save,
):
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
saved = mock_save.call_args[0][0]
assert saved[_LAST_APPLIED_CONFIG_KEY] == strip_apply_meta(saved)
assert saved[_APPLY_HASH_KEY] == config_hash(saved)
# The snapshot is the applied (meta-stripped) config.
assert saved[_LAST_APPLIED_CONFIG_KEY] == _STAMP_TEST_CFG
class TestDaemonGetConfigEndpoint:
def test_strips_apply_meta(self):
with patch.object(
daemonfirewall,
"_get_config",
return_value={
"zones": {},
_APPLY_HASH_KEY: "h",
_LAST_APPLIED_CONFIG_KEY: {"zones": {}},
},
):
result = daemonfirewall.get_config(None, None)
assert result == {"zones": {}}
@patch(
"daemon.handlers.firewall._config_apply",
return_value={"applied_zones": ["public"], "backup": "/tmp/rules.json"},
)
@patch("daemon.handlers.firewall.bus")
def test_config_apply_handler_force_propagation(self, mock_bus, mock_apply):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
with patch("daemon.handlers.firewall.refresh_state"):
daemonfirewall.config_apply(None, None)
mock_apply.assert_called_once_with(force=False)
mock_apply.reset_mock()
daemonfirewall.config_apply(None, {"force": True})
mock_apply.assert_called_once_with(force=True)
class TestDaemonConfigPending:
@patch("lib.state.state")
def test_returns_pending(self, mock_st):
@@ -477,6 +779,172 @@ class TestDaemonConfigPending:
result = daemonfirewall.config_pending_handler(None, None)
assert result["needs_apply"] is True
@patch("lib.state.state")
def test_no_state_mutation(self, mock_st):
pending = {
"needs_apply": True,
"pending": [{"zone": "public", "type": "services"}],
}
mock_st.get.return_value = {**_mock_state(), "pending": pending}
original_keys = set(pending.keys())
result = daemonfirewall.config_pending_handler(None, None)
assert "pending_summary" in result
assert set(pending.keys()) == original_keys, (
"config_pending_handler must not mutate state store pending dict"
)
@patch("lib.state.state")
def test_detail_text_interfaces(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "internal",
"type": "interfaces",
"config": ["eth1", "eth2"],
"live": ["eth1"],
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
assert "eth2" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_services(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "dmz",
"type": "services",
"config": ["ssh", "dns"],
"live": ["ssh"],
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone dmz: services changed" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_rich_rules(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "public",
"type": "rich_rules",
"config_count": 3,
"live_count": 1,
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone public: rich rules differ" in result["pending_summary"][0]
assert "config: 3" in result["pending_summary"][0]
assert "live: 1" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_masquerade(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "wan",
"type": "masquerade",
"config": True,
"live": False,
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone wan: masquerade changed" in result["pending_summary"][0]
assert "config: True" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_target(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "trusted",
"type": "target",
"config": "ACCEPT",
"live": "default",
}
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone trusted: target changed" in result["pending_summary"][0]
assert "config: ACCEPT" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_unknown_type(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [{"zone": "public", "type": "foobarLayout"}],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 1
assert "Zone public: foobarLayout changed" in result["pending_summary"][0]
@patch("lib.state.state")
def test_detail_text_mixed_types(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "internal",
"type": "interfaces",
"config": ["eth1"],
"live": [],
},
{
"zone": "dmz",
"type": "services",
"config": ["ssh", "dns"],
"live": ["ssh"],
},
{
"zone": "public",
"type": "rich_rules",
"config_count": 2,
"live_count": 1,
},
],
},
}
result = daemonfirewall.config_pending_handler(None, None)
assert len(result["pending_summary"]) == 3
assert "Zone internal: interfaces changed" in result["pending_summary"][0]
assert "Zone dmz: services changed" in result["pending_summary"][1]
assert "Zone public: rich rules differ" in result["pending_summary"][2]
# ---------------------------------------------------------------------------
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
@@ -542,3 +1010,94 @@ class TestLibParseForwardPorts:
def test_empty_string(self):
assert firewall._parse_forward_ports("") == []
# ---------------------------------------------------------------------------
# lib/firewall.py — parse all zones output (--list-all-zones)
# ---------------------------------------------------------------------------
class TestParseAllZonesOutput:
def test_parses_single_zone(self):
result = firewall._parse_all_zones_output(
"public\n"
" target: default\n"
" interfaces: eth0\n"
" services: ssh http\n"
" masquerade: yes\n"
" rich rules: \n"
)
assert "public" in result
assert result["public"]["name"] == "public"
assert result["public"]["interfaces"] == ["eth0"]
assert result["public"]["services"] == ["ssh", "http"]
assert result["public"]["masquerade"] is True
assert result["public"]["rich-rules"] == []
def test_parses_multiple_zones(self):
result = firewall._parse_all_zones_output(
"public (default, active)\n"
" target: default\n"
" interfaces: eth0\n"
" services: ssh\n"
" masquerade: no\n"
" rich rules: \n"
"internal (active)\n"
" target: ACCEPT\n"
" interfaces: eth1\n"
" services: dhcp\n"
" masquerade: no\n"
" rich rules: \n"
"trusted\n"
" target: ACCEPT\n"
" interfaces: \n"
" services: \n"
" masquerade: no\n"
" rich rules: \n"
)
assert set(result.keys()) == {"public", "internal", "trusted"}
assert result["public"]["interfaces"] == ["eth0"]
assert result["internal"]["target"] == "ACCEPT"
assert result["trusted"]["services"] == []
def test_empty_output(self):
assert firewall._parse_all_zones_output("") == {}
assert firewall._parse_all_zones_output("\n \n") == {}
def test_handles_blank_lines_between_zones(self):
result = firewall._parse_all_zones_output(
"public\n"
" target: default\n"
" interfaces: eth0\n"
" rich rules: \n"
"\n"
"internal\n"
" target: ACCEPT\n"
" interfaces: eth1\n"
" rich rules: \n"
)
assert "public" in result
assert "internal" in result
assert result["public"]["interfaces"] == ["eth0"]
assert result["internal"]["interfaces"] == ["eth1"]
def test_all_default_fields_present(self):
result = firewall._parse_all_zones_output(
"dmz\n target: default\n interfaces: \n services: \n rich rules: \n"
)
zone = result["dmz"]
for field in (
"interfaces",
"sources",
"services",
"ports",
"protocols",
"forward-ports",
"masquerade",
"ics",
"icmp-blocks",
"module",
"target",
"rich-rules",
):
assert field in zone, f"Missing field: {field}"
+164 -26
View File
@@ -7,7 +7,9 @@ from unittest.mock import MagicMock, patch
import pytest
import daemon.handlers.acme as acme_mod
from daemon.handlers.acme import (
IssueRequest,
_check_account_registered,
_check_acme_account,
_check_acme_home_writable,
@@ -25,60 +27,75 @@ from daemon.handlers.acme import (
deactivate_account,
generate_self_signed,
get_account,
get_renew_status,
issue_cert,
register_account,
renew_cert,
)
from daemon.server import ConflictError
from daemon.server import ConflictError, NotFoundError
def _await_renew(body):
"""Start a renewal via renew_cert and await its background task."""
async def _run():
result = renew_cert(None, body)
task = acme_mod._ISSUANCE_TASKS.get(result.get("request_id"))
if task is not None:
await task
return result
return asyncio.run(_run())
class TestGenerateSelfSigned:
def test_generate_creates_files(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["domain"] == "test.local"
assert result["generated"] is True
cert_dir = tmp_path / "acme" / "test.local"
assert result["cert"] == str(cert_dir / "fullchain.cer")
assert result["key"] == str(cert_dir / "test.local.key")
assert (cert_dir / "fullchain.cer").is_file()
assert (cert_dir / "test.local.key").is_file()
certs_dir = tmp_path / "data" / "certs"
assert result["cert"] == str(certs_dir / "test.local.crt")
assert result["key"] == str(certs_dir / "test.local.key")
assert (certs_dir / "test.local.crt").is_file()
assert (certs_dir / "test.local.key").is_file()
def test_generate_idempotent_skips_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert")
(cert_dir / "test.local.key").write_text("dummy-key")
certs_dir = tmp_path / "data" / "certs"
certs_dir.mkdir(parents=True)
(certs_dir / "test.local.crt").write_text("dummy-cert")
(certs_dir / "test.local.key").write_text("dummy-key")
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is False
def test_generate_partial_existing(self, tmp_path):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True)
(cert_dir / "fullchain.cer").write_text("dummy-cert")
certs_dir = tmp_path / "data" / "certs"
certs_dir.mkdir(parents=True)
(certs_dir / "test.local.crt").write_text("dummy-cert")
# key missing -> should regenerate
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
result = generate_self_signed(None, {"domain": "test.local"})
assert result["generated"] is True
def test_generate_custom_days(self, tmp_path):
with (
patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"),
patch("daemon.handlers.acme.PROJECT_DIR", tmp_path),
patch("subprocess.run") as mock_run,
):
def _create_files(*args, **kwargs):
cert_dir = tmp_path / "acme" / "test.local"
cert_dir.mkdir(parents=True, exist_ok=True)
(cert_dir / "fullchain.cer").touch()
(cert_dir / "test.local.key").touch()
certs_dir = tmp_path / "data" / "certs"
certs_dir.mkdir(parents=True, exist_ok=True)
(certs_dir / "test.local.crt").touch()
(certs_dir / "test.local.key").touch()
return Path("")
mock_run.side_effect = _create_files
@@ -88,15 +105,15 @@ class TestGenerateSelfSigned:
idx = args.index("-days")
assert args[idx + 1] == "730"
cert_dir = tmp_path / "acme" / "test.local"
if (cert_dir / "fullchain.cer").is_file():
assert cert_dir.is_dir()
certs_dir = tmp_path / "data" / "certs"
if (certs_dir / "test.local.crt").is_file():
assert certs_dir.is_dir()
def test_generate_creates_directory(self, tmp_path):
with patch("daemon.handlers.acme._ACME_HOME", tmp_path / "acme"):
with patch("daemon.handlers.acme.PROJECT_DIR", tmp_path):
generate_self_signed(None, {"domain": "test.local"})
assert (tmp_path / "acme" / "test.local").is_dir()
assert (tmp_path / "data" / "certs").is_dir()
def test_generate_requires_domain(self):
with pytest.raises(ValueError, match="domain"):
@@ -1165,3 +1182,124 @@ class TestIssueCertExistingCerts:
assert result["domain"] == "example.com"
assert "request_id" in result
mock_run_issue.assert_called_once()
class TestRenewCert:
def test_requires_body(self):
with pytest.raises(ValueError, match="Request body required"):
renew_cert(None, None)
def test_requires_domain(self):
with pytest.raises(ValueError, match="'domain' is required"):
renew_cert(None, {"force": True})
def test_starts_background_task(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch(
"daemon.handlers.acme._run_acme", return_value="Renewed 'example.com'"
),
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
):
result = _await_renew({"domain": "example.com"})
req = acme_mod._ISSUANCES[result["request_id"]]
assert result["domain"] == "example.com"
assert "request_id" in result
assert req.status == "completed"
assert [s.status for s in req.steps] == ["done", "done", "done"]
mock_refresh.assert_called_once_with(["acme"])
def test_skip_when_not_due(self):
output = (
"[Thu Aug 20 01:27:41 AM UTC 2026] Skipping. "
"Next renewal time is: 1785068261 (2026-07-26T12:17:41Z)"
)
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme", return_value=output),
patch("daemon.handlers.acme.refresh_state") as mock_refresh,
):
result = _await_renew({"domain": "example.com"})
req = acme_mod._ISSUANCES[result["request_id"]]
assert req.status == "skipped"
assert req.steps[0].status == "done"
mock_refresh.assert_not_called()
def test_failure_marks_failed(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch(
"daemon.handlers.acme._run_acme",
side_effect=RuntimeError("acme.sh failed with exit code 1: boom"),
),
):
result = _await_renew({"domain": "example.com"})
req = acme_mod._ISSUANCES[result["request_id"]]
assert req.status == "failed"
assert req.steps[0].status == "error"
assert "boom" in req.steps[0].message
def test_force_appends_flag(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme", return_value="ok") as mock_acme,
patch("daemon.handlers.acme.refresh_state"),
):
_await_renew({"domain": "example.com", "force": True})
renew_args = mock_acme.call_args_list[0].args[0]
assert "--force" in renew_args
def test_dedup_running_returns_existing(self):
existing = IssueRequest(request_id="existing123", domain="example.com")
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme") as mock_acme,
):
acme_mod._ISSUANCES["existing123"] = existing
result = renew_cert(None, {"domain": "example.com"})
assert result == {
"request_id": "existing123",
"domain": "example.com",
"status": "existing",
}
mock_acme.assert_not_called()
class TestGetRenewStatus:
def test_missing_id(self):
with pytest.raises(ValueError, match="'id' is required"):
get_renew_status(None, None)
with pytest.raises(ValueError, match="'id' is required"):
get_renew_status(None, {})
def test_unknown_id(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
pytest.raises(NotFoundError, match="not found"),
):
get_renew_status(None, {"id": "unknown"})
def test_returns_request_dict(self):
with (
patch.dict("daemon.handlers.acme._ISSUANCES", clear=True),
patch.dict("daemon.handlers.acme._ISSUANCE_TASKS", clear=True),
patch("daemon.handlers.acme._run_acme", return_value="Renewed"),
patch("daemon.handlers.acme.refresh_state"),
):
result = _await_renew({"domain": "example.com"})
status = get_renew_status(None, {"id": result["request_id"]})
assert status["request_id"] == result["request_id"]
assert status["domain"] == "example.com"
assert status["status"] == "completed"
assert [s["name"] for s in status["steps"]] == ["renew", "deploy", "refresh"]
+16
View File
@@ -160,10 +160,14 @@ class TestApplyAll:
}
)
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
@@ -188,11 +192,15 @@ class TestApplyAll:
}
)
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams,
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
@@ -208,6 +216,9 @@ class TestApplyAll:
def test_apply_all_handles_dns_sync_failure(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}})
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
@@ -216,6 +227,7 @@ class TestApplyAll:
side_effect=RuntimeError("fail"),
),
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
@@ -233,10 +245,14 @@ class TestApplyAll:
sys_dir.mkdir(parents=True)
(sys_dir / "stale-file.network").write_text("[Match]\nName=old\n")
runtime_dir = tmp_network / "run" / "vacuum-wall"
runtime_dir.mkdir(parents=True)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "99-eth0.network"],
+632 -25
View File
@@ -59,13 +59,27 @@ class TestGetConfig:
class TestSaveConfig:
def test_saves_and_reloads(self, temp_data_dir):
cfg = {
"domains": {"example.com": {"backend": {"host": "localhost", "port": 80}}}
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {
"backend": {
"host": "localhost",
"port": 80,
"proto": "http",
}
}
},
}
},
"domains": {"example.com": {"backend": "myapp"}},
}
nginx.save_config(cfg)
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"] == "myapp"
assert (
loaded["domains"]["example.com"]["paths"]["/"]["backend"]["host"]
== "localhost"
loaded["backends"]["myapp"]["paths"]["/"]["backend"]["host"] == "localhost"
)
@@ -76,46 +90,76 @@ class TestGetDomains:
def test_returns_domain_list(self, temp_data_dir):
cfg = {
"domains": {
"example.com": {
"backend": {"host": "localhost", "port": 8080, "proto": "http"},
"force_ssl": True,
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {
"backend": {
"host": "localhost",
"port": 8080,
"proto": "http",
}
},
"/api": {
"backend": {
"host": "localhost",
"port": 8081,
"proto": "http",
}
},
},
}
}
},
"domains": {
"example.com": {"backend": "myapp", "force_ssl": True},
},
}
nginx.save_config(cfg)
result = nginx.get_domains()
assert len(result) == 1
assert len(result) == 2
assert result[0]["domain"] == "example.com"
assert result[0]["backend_name"] == "myapp"
assert result[0]["path"] == "/"
class TestAddDomain:
@patch("lib.nginx.get_config")
def test_add_domain(self, mock_get, temp_data_dir):
mock_get.return_value = {
def test_add_domain(self, temp_data_dir):
cfg = {
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {
"backend": {
"host": "10.0.0.5",
"port": 8080,
"proto": "http",
}
}
},
}
},
"domains": {},
"management": None,
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.add_domain("example.com", "10.0.0.5", 8080)
cfg = nginx.get_config()
assert "example.com" in cfg["domains"]
assert (
cfg["domains"]["example.com"]["paths"]["/"]["backend"]["host"] == "10.0.0.5"
)
assert cfg["domains"]["example.com"]["paths"]["/"]["backend"]["port"] == 8080
nginx.save_config(cfg)
nginx.add_domain("example.com", "myapp")
loaded = nginx.get_config()
assert "example.com" in loaded["domains"]
assert loaded["domains"]["example.com"]["backend"] == "myapp"
assert loaded["domains"]["example.com"]["force_ssl"] is True
@patch("lib.nginx.get_config")
def test_duplicate_domain_raises(self, mock_get, temp_data_dir):
mock_get.return_value = {
"domains": {
"example.com": {"backend": {"host": "x", "port": 80, "proto": "http"}}
},
"management": None,
"backends": {"myapp": {}},
"domains": {"example.com": {"backend": "myapp"}},
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.save_config(mock_get.return_value)
with pytest.raises(ValueError):
nginx.add_domain("example.com", "10.0.0.5", 8080)
nginx.add_domain("example.com", "myapp")
class TestRemoveDomain:
@@ -380,3 +424,566 @@ class TestHashPasswordFallback:
result = nginx._hash_password("test")
assert isinstance(result, str)
assert len(result) > 0
# ---------------------------------------------------------------------------
# Backend CRUD and resolution tests (daemon handler)
# NOTE: The daemon handler modules cannot be called as-is because they
# require sudo and live service paths. Instead we test through `lib.nginx`
# public API where possible, and unit-test the handler-internal functions
# by importing them directly.
# ---------------------------------------------------------------------------
class TestResolvePaths:
def test_resolves_from_backend(self, temp_data_dir):
"""Paths resolve from backends[domain_cfg['backend']].paths."""
backends = {
"myapp": {
"label": "My App",
"paths": {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
"/api": {"backend": {"host": "b", "port": 8080, "proto": "http"}},
},
}
}
domain_cfg = {"backend": "myapp"}
resolved = nginx._resolve_paths(domain_cfg, backends)
assert resolved == backends["myapp"]["paths"]
def test_returns_empty_when_backend_missing(self, temp_data_dir):
"""Empty dict when backend ref not found in backends."""
resolved = nginx._resolve_paths({"backend": "nonexistent"}, {})
assert resolved == {}
def test_returns_empty_when_no_backend_key(self, temp_data_dir):
"""Empty dict when domain has no backend key."""
resolved = nginx._resolve_paths({}, {"myapp": {"paths": {"/": {}}}})
assert resolved == {}
class TestResolveAuth:
def test_domain_auth_wins(self, temp_data_dir):
"""Domain-level auth overrides backend auth."""
backends = {
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
}
domain_cfg = {
"backend": "myapp",
"auth": {"user": "domain", "htpasswd": "/domain/.htpasswd"},
}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved == {"user": "domain", "htpasswd": "/domain/.htpasswd"}
def test_domain_auth_null_disables(self, temp_data_dir):
"""Domain auth set to None disables all auth."""
backends = {
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
}
domain_cfg = {"backend": "myapp", "auth": None}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved is None
def test_backend_auth_applies(self, temp_data_dir):
"""Backend auth applies when domain has no auth key."""
backends = {
"myapp": {"auth": {"user": "backend", "htpasswd": "/backend/.htpasswd"}}
}
domain_cfg = {"backend": "myapp"}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved == {"user": "backend", "htpasswd": "/backend/.htpasswd"}
def test_no_auth_when_absent_everywhere(self, temp_data_dir):
"""None when neither domain nor backend define auth."""
backends = {"myapp": {"paths": {}}}
domain_cfg = {"backend": "myapp"}
resolved = nginx._resolve_auth(domain_cfg, backends)
assert resolved is None
class TestBackendCRUD:
"""Test daemon handler backend CRUD operations directly."""
def test_validate_paths_valid(self, temp_data_dir):
"""_validate_paths accepts correct schemas."""
from daemon.handlers.nginx import _validate_paths
paths = {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
"/ws": {
"backend": {"host": "b", "port": 9091, "proto": "http"},
"is_websocket": True,
},
}
_validate_paths(paths) # no exception
def test_validate_paths_missing_host(self, temp_data_dir):
"""_validate_paths raises when backend.host missing."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'host'"):
_validate_paths({"/": {"backend": {"port": 80, "proto": "http"}}})
def test_validate_paths_missing_port(self, temp_data_dir):
"""_validate_paths raises when backend.port missing."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'port'"):
_validate_paths({"/": {"backend": {"host": "a", "proto": "http"}}})
def test_validate_paths_missing_proto(self, temp_data_dir):
"""_validate_paths raises when backend.proto missing."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'proto'"):
_validate_paths({"/": {"backend": {"host": "a", "port": 80}}})
def test_validate_paths_no_backend(self, temp_data_dir):
"""_validate_paths raises when path lacks backend dict."""
from daemon.handlers.nginx import _validate_paths
with pytest.raises(ValueError, match="missing 'backend'"):
_validate_paths({"/": {"something": "else"}})
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_add_backend(self, mock_save, mock_get, temp_data_dir):
"""_add_backend creates a new backend entry."""
from daemon.handlers.nginx import _add_backend
mock_get.return_value = {
"backends": {},
"domains": {},
"ssl": {},
}
_add_backend(
"test",
"Test Label",
{
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
},
)
saved_cfg = mock_save.call_args[0][0]
assert "test" in saved_cfg["backends"]
assert saved_cfg["backends"]["test"]["label"] == "Test Label"
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_add_backend_duplicate_raises(self, mock_save, mock_get, temp_data_dir):
"""_add_backend raises ValueError for duplicate name."""
from daemon.handlers.nginx import _add_backend
mock_get.return_value = {
"backends": {"test": {"label": "Existing"}},
"domains": {},
"ssl": {},
}
with pytest.raises(ValueError, match="already exists"):
_add_backend(
"test",
"New Label",
{
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
},
)
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_update_backend(self, mock_save, mock_get, temp_data_dir):
"""_update_backend modifies label and paths."""
from daemon.handlers.nginx import _update_backend
mock_get.return_value = {
"backends": {
"test": {
"label": "Old",
"paths": {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}}
},
}
},
"domains": {},
"ssl": {},
}
_update_backend(
"test",
label="New",
paths={
"/api": {"backend": {"host": "b", "port": 9000, "proto": "http"}},
},
)
saved_cfg = mock_save.call_args[0][0]
assert saved_cfg["backends"]["test"]["label"] == "New"
assert "/api" in saved_cfg["backends"]["test"]["paths"]
@patch("daemon.handlers.nginx._get_config")
def test_update_backend_notfound_raises(self, mock_get, temp_data_dir):
"""_update_backend raises KeyError for unknown backend."""
from daemon.handlers.nginx import _update_backend
mock_get.return_value = {"backends": {}, "domains": {}, "ssl": {}}
with pytest.raises(KeyError):
_update_backend("nonexistent", label="X")
@patch("daemon.handlers.nginx._get_config")
def test_update_backend_builtin_raises(self, mock_get, temp_data_dir):
"""_update_backend raises ValueError for builtin backend."""
from daemon.handlers.nginx import _update_backend
mock_get.return_value = {
"backends": {"webui": {"label": "WebUI", "builtin": True, "paths": {}}},
"domains": {},
"ssl": {},
}
with pytest.raises(ValueError, match="Cannot modify"):
_update_backend("webui", label="Hacked")
@patch("daemon.handlers.nginx._get_config")
@patch("daemon.handlers.nginx._save_config")
def test_remove_backend(self, mock_save, mock_get, temp_data_dir):
"""_remove_backend deletes non-builtin backend."""
from daemon.handlers.nginx import _remove_backend
mock_get.return_value = {
"backends": {"test": {"label": "Test", "paths": {}}},
"domains": {},
"ssl": {},
}
_remove_backend("test")
saved_cfg = mock_save.call_args[0][0]
assert "test" not in saved_cfg["backends"]
@patch("daemon.handlers.nginx._get_config")
def test_remove_backend_notfound_raises(self, mock_get, temp_data_dir):
"""_remove_backend raises KeyError for unknown backend."""
from daemon.handlers.nginx import _remove_backend
mock_get.return_value = {"backends": {}, "domains": {}, "ssl": {}}
with pytest.raises(KeyError):
_remove_backend("nonexistent")
@patch("daemon.handlers.nginx._get_config")
def test_remove_backend_builtin_raises(self, mock_get, temp_data_dir):
"""_remove_backend raises ValueError for builtin backend."""
from daemon.handlers.nginx import _remove_backend
mock_get.return_value = {
"backends": {"webui": {"label": "WebUI", "builtin": True, "paths": {}}},
"domains": {},
"ssl": {},
}
with pytest.raises(ValueError, match="Cannot remove"):
_remove_backend("webui")
@patch("daemon.handlers.nginx._get_config")
def test_remove_backend_referenced_raises_conflict(self, mock_get, temp_data_dir):
"""_remove_backend raises ConflictError when domains reference it."""
from daemon.handlers.nginx import _remove_backend
from daemon.server import ConflictError
mock_get.return_value = {
"backends": {"myapp": {"label": "App", "paths": {}}},
"domains": {"example.com": {"backend": "myapp"}},
"ssl": {},
}
with pytest.raises(ConflictError, match="referenced by domain"):
_remove_backend("myapp")
class TestMigration:
def test_ensure_webui_backend_creates(self, temp_data_dir):
"""_ensure_webui_backend creates webui backend if missing."""
cfg = {}
nginx._ensure_webui_backend(cfg)
assert "webui" in cfg["backends"]
assert cfg["backends"]["webui"]["_migrated"] is True
assert cfg["backends"]["webui"]["label"] == "Vacuum Wall WebUI"
assert cfg["backends"]["webui"]["builtin"] is True
def test_ensure_webui_backend_skips_migrated(self, temp_data_dir):
"""_ensure_webui_backend skips if _migrated is true."""
cfg = {
"backends": {
"webui": {
"label": "Custom",
"_migrated": True,
"paths": {},
}
}
}
nginx._ensure_webui_backend(cfg)
# Label unchanged — not recreated
assert cfg["backends"]["webui"]["label"] == "Custom"
def test_migrate_mgmt_domains_application_webui(self, temp_data_dir):
"""Domains with application='webui' and mgmt paths get backend='webui'."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"mgmt.example.com": {
"application": "webui",
"paths": {
"/": {"is_management": True},
"/ws": {"is_websocket": True},
},
"auth": {"user": "admin"},
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["mgmt.example.com"]
assert dom["backend"] == "webui"
assert "application" not in dom
assert "paths" not in dom
assert "auth" not in dom
def test_migrate_mgmt_domains_strips_application_only(self, temp_data_dir):
"""application='webui' stripped even when paths don't match mgmt shape."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"odd.example.com": {
"application": "webui",
"paths": {"/": {"is_management": True}},
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["odd.example.com"]
assert "application" not in dom
assert "backend" not in dom # paths didn't match full mgmt shape
assert "paths" in dom # not removed
def test_migrate_mgmt_domains_detects_webui_paths(self, temp_data_dir):
"""Domains matching webui path shape get migrated to backend."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"mgmt.example.com": {
"paths": {
"/": {
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http",
},
"is_management": True,
},
"/ws": {
"backend": {
"host": "127.0.0.1",
"port": 9091,
"proto": "http",
},
"is_websocket": True,
},
}
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["mgmt.example.com"]
assert dom["backend"] == "webui"
assert "paths" not in dom
def test_migrate_mgmt_domains_skips_non_mgmt(self, temp_data_dir):
"""Non-management domains are left untouched."""
cfg = {
"backends": {"webui": {"_migrated": True, "paths": {}}},
"domains": {
"app.example.com": {
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 80, "proto": "http"}
}
}
}
},
}
nginx._migrate_mgmt_domains(cfg)
dom = cfg["domains"]["app.example.com"]
assert "backend" not in dom
assert "paths" in dom
def test_migrate_config_full(self, temp_data_dir):
"""_migrate_config runs _ensure_webui_backend then _migrate_mgmt_domains."""
cfg = {
"domains": {
"mgmt.example.com": {
"application": "webui",
"paths": {
"/": {"is_management": True},
"/ws": {"is_websocket": True},
},
}
}
}
result = nginx._migrate_config(cfg)
assert result is cfg
assert "webui" in cfg["backends"]
assert cfg["domains"]["mgmt.example.com"]["backend"] == "webui"
class TestDomainSwap:
"""Domain can change which backend it references."""
def test_swap_backend(self, temp_data_dir):
"""update_domain allows changing backend field."""
cfg = {
"backends": {
"webui": {"label": "WebUI", "paths": {}},
"myapp": {"label": "App", "paths": {}},
},
"domains": {"example.com": {"backend": "webui", "force_ssl": True}},
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.save_config(cfg)
nginx.update_domain("example.com", backend="myapp")
loaded = nginx.get_config()
assert loaded["domains"]["example.com"]["backend"] == "myapp"
def test_swap_backend_nonexistent_raises(self, temp_data_dir):
"""update_domain raises ValueError when new backend not found."""
cfg = {
"backends": {"webui": {"label": "WebUI", "paths": {}}},
"domains": {"example.com": {"backend": "webui"}},
"ssl": {"protocols": "TLSv1.2 TLSv1.3"},
}
nginx.save_config(cfg)
with pytest.raises(ValueError, match="Backend 'nonexistent' not found"):
nginx.update_domain("example.com", backend="nonexistent")
class TestGetDomainsWithBackends:
"""get_domains resolves paths from backends, includes backend_name."""
def test_flattens_by_backend_paths(self, temp_data_dir):
"""Each backend path becomes a separate entry."""
cfg = {
"backends": {
"myapp": {
"label": "My App",
"paths": {
"/": {"backend": {"host": "a", "port": 80, "proto": "http"}},
"/api": {
"backend": {"host": "b", "port": 8080, "proto": "http"}
},
"/ws": {
"backend": {"host": "c", "port": 9091, "proto": "http"},
"is_websocket": True,
},
},
}
},
"domains": {"example.com": {"backend": "myapp", "force_ssl": True}},
"ssl": {},
}
nginx.save_config(cfg)
result = nginx.get_domains()
assert len(result) == 3
paths = {r["path"] for r in result}
assert paths == {"/", "/api", "/ws"}
for r in result:
assert r["backend_name"] == "myapp"
assert r["domain"] == "example.com"
def test_skips_domains_without_backend(self, temp_data_dir):
"""Domains without a backend key are skipped."""
cfg = {
"backends": {},
"domains": {
"good.com": {"backend": "webui"},
"bad.com": {"some": "orphan"},
},
"ssl": {},
}
nginx.save_config(cfg)
result = nginx.get_domains()
domains = {r["domain"] for r in result}
assert "good.com" in domains
assert "bad.com" not in domains
def test_includes_management_and_websocket_flags(self, temp_data_dir):
"""is_management and is_websocket flags propagate to entries."""
cfg = {
"backends": {
"webui": {
"label": "WebUI",
"paths": {
"/": {
"backend": {
"host": "127.0.0.1",
"port": 9090,
"proto": "http",
},
"is_management": True,
},
"/ws": {
"backend": {
"host": "127.0.0.1",
"port": 9091,
"proto": "http",
},
"is_websocket": True,
},
},
}
},
"domains": {"mgmt.local": {"backend": "webui"}},
"ssl": {},
}
nginx.save_config(cfg)
result = nginx.get_domains()
entries_by_path = {r["path"]: r for r in result}
assert entries_by_path["/"]["is_management"] is True
assert entries_by_path["/ws"]["is_websocket"] is True
class TestGenerateServerConfWithBackends:
"""generate_server_conf resolves paths via backends parameter."""
def test_with_backends_param(self, temp_data_dir):
"""When backends provided, paths resolve from backend."""
backends = {
"myapp": {
"paths": {
"/": {
"backend": {"host": "10.0.0.1", "port": 8080, "proto": "http"}
}
}
}
}
domain_cfg = {"domain": "example.com", "backend": "myapp", "force_ssl": True}
out = nginx.generate_server_conf(domain_cfg, backends)
assert "proxy_pass http://10.0.0.1:8080;" in out
class TestPatchConfigPreservesBackends:
"""PATCH_NGINX_CONFIG (deep_merge) does not overwrite backends fully."""
def test_ssl_patch_keeps_backends(self, temp_data_dir):
"""Patching ssl settings preserves backends dict (deep_merge semantics)."""
from lib.common import deep_merge
current = {
"backends": {"myapp": {"label": "App", "paths": {}}},
"domains": {"example.com": {"backend": "myapp"}},
"ssl": {"protocols": "TLSv1.2"},
}
patch = {"ssl": {"protocols": "TLSv1.3"}}
merged = deep_merge(current, patch)
assert merged["backends"]["myapp"]["label"] == "App"
assert merged["domains"]["example.com"]["backend"] == "myapp"
assert merged["ssl"]["protocols"] == "TLSv1.3"
+17 -9
View File
@@ -14,6 +14,11 @@ class TestPollIntervals:
assert _POLL_INTERVALS["wireguard"] == 10
assert _POLL_INTERVALS["dnsmasq"] == 10
assert _POLL_INTERVALS["networkd"] == 10
# Phase 5: real-time system metrics poll at 1s.
assert _POLL_INTERVALS["system"] == 1
# nginx/acme derive from config files; poll for drift self-heal.
assert _POLL_INTERVALS["nginx"] == 60
assert _POLL_INTERVALS["acme"] == 300
def test_env_override(self):
"""VACUUM_WALL_POLL_INTERVALS env var can override values."""
@@ -33,28 +38,31 @@ class TestPollIntervals:
class TestBroadcastTick:
def test_sends_tick_message(self):
from daemon.server import _ws_subscribers, broadcast_tick
import daemon.server as server
mock_ws = AsyncMock()
mock_ws.send_str = AsyncMock()
_ws_subscribers.add(mock_ws)
server._ws_subscribers.add(mock_ws)
try:
asyncio.run(broadcast_tick(["firewall", "wireguard"]))
with patch.object(server.state_store, "get", return_value={"up": True}):
asyncio.run(server.broadcast_tick("firewall"))
mock_ws.send_str.assert_called_once()
sent = json.loads(mock_ws.send_str.call_args[0][0])
assert sent["type"] == "tick"
assert sent["subsystems"] == ["firewall", "wireguard"]
assert sent["subsystem"] == "firewall"
assert sent["data"] == {"up": True}
finally:
_ws_subscribers.discard(mock_ws)
server._ws_subscribers.discard(mock_ws)
def test_prunes_dead_subscribers(self):
from daemon.server import _ws_subscribers, broadcast_tick
import daemon.server as server
mock_ws = AsyncMock()
mock_ws.send_str = AsyncMock(side_effect=Exception("broken"))
_ws_subscribers.add(mock_ws)
asyncio.run(broadcast_tick(["firewall"]))
assert mock_ws not in _ws_subscribers
server._ws_subscribers.add(mock_ws)
with patch.object(server.state_store, "get", return_value={"up": True}):
asyncio.run(server.broadcast_tick("firewall"))
assert mock_ws not in server._ws_subscribers
class TestPollTasks:
+144
View File
@@ -0,0 +1,144 @@
"""Tests for refresh_state / refresh_status WS broadcasting (daemon.server).
refresh_state() and refresh_status() re-collect state and broadcast a
data-carrying versions message for every (requested) subsystem so all
viewers stay in sync.
"""
import asyncio
import json
from contextlib import suppress
from unittest.mock import AsyncMock, MagicMock, patch
import daemon.server as server
def _run_and_drain(fn):
"""Run *fn* inside a running event loop (required for broadcast tasks),
then drain the fire-and-forget broadcast tasks."""
async def drive():
fn()
for _ in range(20):
await asyncio.sleep(0)
if not server._ws_tasks:
break
for task in list(server._ws_tasks):
with suppress(Exception):
await task
asyncio.run(drive())
def _new_ws():
ws = AsyncMock()
ws.send_str = AsyncMock()
server._ws_subscribers.add(ws)
return ws
def _messages(ws):
return [json.loads(c[0][0]) for c in ws.send_str.call_args_list]
class TestRefreshStateBroadcast:
def test_broadcasts_each_requested_subsystem(self):
"""refresh_state(["firewall","dnsmasq"]) broadcasts both, and bumps."""
store = MagicMock()
store.get.side_effect = lambda name: {"s": name}
ws = _new_ws()
try:
with patch.object(server, "state_store", store):
_run_and_drain(lambda: server.refresh_state(["firewall", "dnsmasq"]))
store.populate.assert_called_once_with(["firewall", "dnsmasq"])
store.bump.assert_any_call("firewall")
store.bump.assert_any_call("dnsmasq")
msgs = _messages(ws)
assert sorted(m["subsystem"] for m in msgs) == ["dnsmasq", "firewall"]
assert all(m["type"] == "versions" for m in msgs)
for m in msgs:
assert "updated" not in m
assert m["data"] == {"s": m["subsystem"]}
finally:
server._ws_subscribers.discard(ws)
def test_failed_subsystem_skipped_others_buzz(self):
"""A subsystem whose collection failed (None) is not broadcast."""
store = MagicMock()
store.get.side_effect = lambda name: {"s": name} if name != "acme" else None
ws = _new_ws()
try:
with patch.object(server, "state_store", store):
_run_and_drain(lambda: server.refresh_state(["firewall", "acme"]))
subs = sorted(m["subsystem"] for m in _messages(ws))
assert subs == ["firewall"]
finally:
server._ws_subscribers.discard(ws)
def test_no_subsystems_arg_broadcasts_all(self):
"""refresh_state() with no filter targets every subsystem."""
from lib.state import State
store = State()
for name in State.SUBSYSTEMS:
store.set(name, {"k": name})
ws = _new_ws()
try:
with patch.object(server, "state_store", store):
_run_and_drain(lambda: server.refresh_state())
subs = sorted(m["subsystem"] for m in _messages(ws))
assert subs == sorted(State.SUBSYSTEMS)
finally:
server._ws_subscribers.discard(ws)
class TestRefreshStatusBroadcast:
def test_filtered_response_and_broadcast(self):
"""POST /status/refresh replies only with the requested subsystems
and broadcasts each of them."""
store = MagicMock()
store.get.side_effect = lambda name: {"s": name}
ws = _new_ws()
try:
with patch.object(server, "state_store", store):
async def drive():
request = MagicMock()
request.json = AsyncMock(return_value={"subsystems": ["firewall"]})
response = await server.refresh_status(request)
await asyncio.sleep(0.01)
return response
response = asyncio.run(drive())
body = json.loads(response.body)
assert body["ok"] is True
assert set(body["data"]) == {"firewall"}
store.bump.assert_not_called()
subs = sorted(m["subsystem"] for m in _messages(ws))
assert subs == ["firewall"]
finally:
server._ws_subscribers.discard(ws)
def test_no_body_returns_all_subsystems(self):
store = MagicMock()
store.get.side_effect = lambda name: {"s": name}
store.SUBSYSTEMS = ["firewall", "dnsmasq"]
ws = _new_ws()
try:
with patch.object(server, "state_store", store):
async def drive():
request = MagicMock()
request.json = AsyncMock(return_value=None)
response = await server.refresh_status(request)
await asyncio.sleep(0.01)
return response
response = asyncio.run(drive())
body = json.loads(response.body)
assert body["ok"] is True
assert set(body["data"]) == {"firewall", "dnsmasq"}
finally:
server._ws_subscribers.discard(ws)
+147
View File
@@ -0,0 +1,147 @@
"""Tests that collector outputs match the lib.schema TypedDict shapes.
Each collector's return value is asserted against its TypedDict's required
keys at runtime (subprocess/shell calls mocked no system services). The
TypedDicts in lib/schema.py are the authoritative state-store contract;
these tests catch drift between the schemas and the collectors.
"""
import json
from unittest.mock import Mock, patch
import lib.state
from lib import schema
def _missing(required_keys: frozenset, data: dict) -> set[str]:
return set(required_keys) - set(data)
class TestCollectorShapesMatchSchema:
def test_firewall_state(self):
with patch.object(lib.state, "run") as mock_run:
def run_side(args, **kwargs):
if "--get-active-zones" in args:
return "public\n eth0"
if "--get-services" in args:
return "ssh http"
if "--list-all-zones" in args:
return (
"public\n"
" target: default\n"
" interfaces: eth0\n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
)
if "ip" in args[0]:
if "link" in args:
return (
"1: lo: <LOOPBACK,UP> mtu 65536\n"
"2: eth0: <BROADCAST,UP> mtu 1500 link/ether aa:bb\n"
)
if "addr" in args:
return "2: eth0 inet 192.168.1.1/24\n"
return ""
mock_run.side_effect = run_side
result = lib.state._collect_firewall()
assert not _missing(schema.FirewallState.__required_keys__, result)
for iface in result["interfaces"]:
for k in schema.FirewallInterface.__required_keys__:
assert k in iface, f"FirewallInterface missing {k}"
def test_dnsmasq_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = lib.state._collect_dnsmasq()
assert not _missing(schema.DnsmasqState.__required_keys__, result)
for k in schema.DnsmasqStatus.__required_keys__:
assert k in result["status"], f"DnsmasqStatus missing {k}"
def test_nginx_state(self):
result = lib.state._collect_nginx()
assert not _missing(schema.NginxState.__required_keys__, result)
assert "pending_changes" in result["status"]
def test_acme_state(self):
with (
patch.object(lib.state, "_get_acme_email", return_value="a@b.c"),
patch("lib.acme.list_certs", return_value=[]),
patch.object(
lib.state,
"_parse_account_conf",
return_value={"registered": False, "email": "", "ca": ""},
),
):
result = lib.state._collect_acme()
assert not _missing(schema.AcmeState.__required_keys__, result)
def test_wireguard_state(self):
with patch.object(lib.state, "run_proc") as mock_proc:
mock_proc.return_value = Mock(stdout="", returncode=1)
result = lib.state._collect_wireguard()
assert not _missing(schema.WgState.__required_keys__, result)
for k in schema.WgStatus.__required_keys__:
assert k in result["status"], f"WgStatus missing {k}"
assert "classes" in result["status"]
def test_networkd_state(self):
networkctl = {
"Interfaces": [
{
"Name": "eth0",
"Type": "ether",
"OperationalState": "routable",
"HardwareAddress": [1, 2, 3, 4, 5, 6],
"Addresses": [
{"Address": [192, 168, 30, 50], "Family": 2, "PrefixLength": 24}
],
"Routes": [
{
"Family": 2,
"Destination": [0, 0, 0, 0],
"DestinationPrefixLength": 0,
"Gateway": [192, 168, 30, 1],
}
],
"DNS": [{"Address": [1, 1, 1, 1], "Family": 2}],
}
]
}
with patch.object(lib.state, "run", return_value=json.dumps(networkctl)):
result = lib.state._collect_networkd()
assert not _missing(schema.NetworkdState.__required_keys__, result)
assert "eth0" in result["interfaces"]
entry = result["interfaces"]["eth0"]
for k in schema.NetworkdInterface.__required_keys__:
assert k in entry, f"NetworkdInterface missing {k}"
assert entry["gateway"] == "192.168.30.1"
assert entry["addresses"] == ["192.168.30.50/24"]
def test_system_state(self):
"""Reads /proc and /sys directly — no mocking needed on Linux."""
result = lib.state._collect_system()
assert not _missing(schema.SystemState.__required_keys__, result)
for k in schema.CpuLoad.__required_keys__:
assert k in result["load"], f"CpuLoad missing {k}"
for k in schema.MemoryStats.__required_keys__:
assert k in result["memory"], f"MemoryStats missing {k}"
for k in schema.SwapStats.__required_keys__:
assert k in result["swap"], f"SwapStats missing {k}"
def test_volatile_system_registered(self):
"""Phase 5: system metrics are volatile (tick, not version bumps)."""
from lib.state import _VOLATILE
expected = frozenset({"load", "memory", "swap", "traffic"})
assert _VOLATILE.get("system") == expected
+55 -27
View File
@@ -4,6 +4,8 @@ from unittest.mock import patch
import pytest
from webui.server import _has_permission, _subsystem_from_path
@pytest.fixture
def client():
@@ -30,44 +32,33 @@ class TestSPARoutes:
def test_api_routes_still_work(self, client):
resp = client.get("/api/firewall/zones")
assert resp.status_code in (200, 500)
assert resp.status_code in (401, 500)
data = resp.get_json()
assert data is not None
assert data.get("ok") is False
assert data.get("error") == "unauthorized"
class TestWsUrlGeneration:
def test_ws_url_ipv4_host(self, client):
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
assert b"ws://192.168.1.1:9090/ws" in resp.data
def test_ws_url_ipv6_host(self, client):
resp = client.get("/", headers={"Host": "[::1]:9090"})
assert b"ws://[::1]:9090/ws" in resp.data
class TestApiStatusAll:
@patch("webui.server.get")
def test_success(self, mock_get, client):
mock_get.return_value = {"firewall": {"zones": {}}, "dnsmasq": {}}
resp = client.get("/api/status/all")
class TestSpaRoot:
def test_serves_index_html_as_is(self, client):
resp = client.get("/")
assert resp.status_code == 200
data = resp.get_json()
assert data["ok"] is True
assert "firewall" in data["data"]
assert b"/static/app.js" in resp.data
@patch("webui.server.get")
def test_error(self, mock_get, client):
mock_get.side_effect = RuntimeError("connection refused")
resp = client.get("/api/status/all")
assert resp.status_code == 500
data = resp.get_json()
assert data["ok"] is False
def test_no_ws_url_substitution(self, client):
"""index.html is served verbatim — no WS URL placeholder substitution."""
resp = client.get("/", headers={"Host": "192.168.1.1:9090"})
assert b"__WS_URL_PLACEHOLDER__" not in resp.data
assert b"ws://" not in resp.data
class TestBlueprintsRegistered:
def test_all_blueprints_registered(self, client):
from webui.server import BLUEPRINTS
assert len(BLUEPRINTS) == 7
assert len(BLUEPRINTS) == 9
names = [name for name, _ in BLUEPRINTS]
assert "auth" in names
assert "firewall" in names
assert "network" in names
assert "dhcp" in names
@@ -109,3 +100,40 @@ class TestGroupWriteHandler:
mode = os.stat(log_file).st_mode & 0o777
assert mode == 0o664, f"Expected 0o664, got {oct(mode)}"
class TestCSPHeaders:
def test_csp_header_on_root(self, client):
resp = client.get("/")
csp = resp.headers.get("Content-Security-Policy")
assert csp is not None
assert "default-src 'self'" in csp
assert "script-src 'self'" in csp
assert "'unsafe-inline'" not in csp.split("script-src")[1].split(";")[0]
def test_x_content_type_options(self, client):
resp = client.get("/")
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
def test_frame_ancestors_none(self, client):
resp = client.get("/")
csp = resp.headers.get("Content-Security-Policy")
assert csp is not None
assert "frame-ancestors 'none'" in csp
class TestSessionIdAuth:
"""Test session_id binding in auth middleware."""
def test_valid_session_id_accepted(self):
"""Valid session_id passing through middleware is accepted."""
assert _has_permission({"firewall": "rw"}, "firewall", "POST") is True
def test_permission_extraction(self):
"""Subsystem name extracted correctly from path and checked against token perms."""
sub = _subsystem_from_path("/api/firewall/zones")
assert sub == "firewall"
perms = {"firewall": "read"}
assert _has_permission(perms, "firewall", "GET") is True
assert _has_permission(perms, "firewall", "POST") is False
+116 -14
View File
@@ -1,5 +1,6 @@
"""Tests for lib/state.py — state store and collect functions."""
import json
from unittest.mock import patch
from lib.state import State, state
@@ -28,6 +29,25 @@ class TestState:
assert state is not None
assert isinstance(state, State)
def test_get_snapshot_empty(self):
"""Fresh store: snapshot lists every subsystem, all None."""
s = State()
snap = s.get_snapshot()
assert set(snap) == set(s.SUBSYSTEMS)
assert all(v is None for v in snap.values())
def test_get_snapshot_reflects_set_and_none(self):
"""Snapshot carries set data; failed collections stay None."""
s = State()
s.set("firewall", {"zones": {}})
s.set("system", {"load": {"load1": 0.0}})
s.set("dnsmasq", None)
snap = s.get_snapshot()
assert snap["firewall"] == {"zones": {}}
assert snap["system"] == {"load": {"load1": 0.0}}
assert snap["dnsmasq"] is None
assert snap["acme"] is None
class TestCollectAll:
@patch("lib.state.run")
@@ -35,24 +55,35 @@ class TestCollectAll:
from lib.state import _collect_firewall
def run_side(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args:
return "public\n eth0"
if "--get-default-zone" in args:
return "public\n"
if "--get-services" in args:
return "ssh http"
if "ip" in args[0]:
if "link" in args:
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
return ""
if "--list-all" in args:
return "target: default\ninterfaces: eth0\nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n"
return ""
if "--list-all-zones" in args:
return (
"public\n"
" target: default\n"
" interfaces: eth0\n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
)
mock_run.side_effect = run_side
result = _collect_firewall()
assert isinstance(result, dict)
assert "active_zones" in result
assert "default_zone" in result
assert result["default_zone"] == "public"
assert "interfaces" in result
assert "timestamp" in result
@@ -62,10 +93,10 @@ class TestCollectAll:
from lib.state import _collect_firewall
def run_side(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args:
return "public\n eth0\ninternal eth0.100"
return "public\n eth0\ninternal\n eth0.100"
if "--get-default-zone" in args:
return "public\n"
if "--get-services" in args:
return "ssh http"
if "ip" in args[0]:
@@ -81,14 +112,27 @@ class TestCollectAll:
"3: eth0.100@if100 inet 10.0.0.1/24\n"
)
return ""
if "--list-all" in args:
if "--list-all-zones" in args:
return (
"target: default\ninterfaces: eth0\nsources: "
"services: \nports: \nprotocols: \nforward-ports: "
"masquerade: no\nics: no\nrich-rules: "
"icmp-blocks: \nmodule: \n"
"public\n"
" target: default\n"
" interfaces: eth0\n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
"internal\n"
" target: ACCEPT\n"
" interfaces: eth0.100\n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
)
return ""
mock_run.side_effect = run_side
result = _collect_firewall()
@@ -112,6 +156,64 @@ class TestCollectAll:
assert "config" in result
assert "leases" in result
@patch("lib.state.run_proc")
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
from unittest.mock import Mock
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
from lib.state import _collect_dnsmasq
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
applied = {
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "10.4.20.101",
"end": "10.4.20.200",
"lease_time": "1h",
"gateway": "10.4.20.1",
}
],
"static_leases": [],
},
"dns": {"upstreams": ["8.8.8.8"], "domain": None, "custom_records": []},
}
cfg = {
"dhcp": {
"ranges": [
{
"interface": "eth1",
"start": "10.4.20.100",
"end": "10.4.20.200",
"lease_time": "12h",
"gateway": "10.4.20.1",
}
],
"static_leases": [],
},
"dns": {
"upstreams": ["8.8.8.8", "1.1.1.1"],
"domain": None,
"custom_records": [],
},
_LAST_APPLIED_CONFIG_KEY: applied,
_APPLY_HASH_KEY: "stale-hash",
}
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
# service check -> active; lease file read -> no lines
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
result = _collect_dnsmasq()
assert result["status"]["pending_changes"] is True
paths = {d["path"] for d in result["status"]["pending_diff"]}
assert "dhcp.ranges[0].start" in paths
assert "dhcp.ranges[0].lease_time" in paths
# Apply metadata must not leak into the returned config.
assert _LAST_APPLIED_CONFIG_KEY not in result["config"]
assert _APPLY_HASH_KEY not in result["config"]
class TestCollectFailure:
def test_state_clears_on_failure(self):
+221
View File
@@ -0,0 +1,221 @@
"""Tests for daemon/handlers/status.py — cancel-all (revert to applied)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, ClassVar
from unittest.mock import patch
from daemon.handlers import status
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
stamp_applied,
)
def _pending(firewall: bool = False, **flags: bool) -> dict[str, Any]:
"""Build status_pending() output with the given subsystems pending.
Mirrors the real handler shape: every subsystem key is always present.
"""
data: dict[str, Any] = {
"firewall": {
"needs_apply": firewall,
"change_count": 1 if firewall else 0,
"changes": [],
},
}
total = 1 if firewall else 0
for name in ("dnsmasq", "nginx", "wireguard", "networkd"):
flagged = bool(flags.get(name, False))
data[name] = {
"pending_changes": flagged,
"summary": "x" if flagged else "Up to date",
"changes": [],
}
total += 1 if flagged else 0
data["total_changes"] = total
return data
class TestConfigPathResolution:
"""SYS_CONFIG_PATHS points at the constants the handlers actually use."""
def test_resolves_to_module_constants(self):
import daemon.handlers.dnsmasq as dm_h
import daemon.handlers.firewall as fw_h
import daemon.handlers.nginx as ngx_h
import lib.network as net_lib
import lib.wireguard as wg_lib
assert status._config_path("firewall") == fw_h.CONFIG_FILE
assert status._config_path("dnsmasq") == dm_h.CONFIG_PATH
assert status._config_path("nginx") == ngx_h.CONFIG_FILE
assert status._config_path("wireguard") == wg_lib.CONFIG_PATH
assert status._config_path("networkd") == net_lib.CONFIG_FILE
def test_all_subsystems_mapped(self):
assert set(status.SYS_CONFIG_PATHS) == set(status.SYS_ORDER)
class TestStatusCancelAll:
"""Test the cancel-all endpoint.
Patches the ``_config_path`` seam directly since it resolves the real
module constants via getattr at call time.
"""
_fake_pending_all: ClassVar[dict[str, Any]] = _pending()
def _write_config(
self, path: Path, cfg: dict[str, Any], baseline: dict | None
) -> None:
"""Write *cfg* (with an attached applied baseline when *baseline* given)."""
path.parent.mkdir(parents=True, exist_ok=True)
stamped = dict(cfg)
if baseline is not None:
b = dict(baseline)
stamp_applied(b)
stamped[_LAST_APPLIED_CONFIG_KEY] = dict(baseline)
stamped[_APPLY_HASH_KEY] = b[_APPLY_HASH_KEY]
path.write_text(json.dumps(stamped, indent=2) + "\n")
def _read_config(self, path: Path) -> dict[str, Any]:
return json.loads(path.read_text())
@patch("daemon.handlers.status.status_pending")
@patch("daemon.handlers.status.refresh_state")
def test_nothing_pending(self, mock_refresh, mock_pending):
mock_pending.return_value = self._fake_pending_all
with patch("daemon.handlers.status._config_path", side_effect=AssertionError):
result = status.status_cancel_all(None, None)
assert result == {"cancelled": [], "skipped": {}, "errors": {}}
mock_refresh.assert_not_called()
def test_reverts_pending_subsystem(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
applied = {"dns": {"upstreams": ["8.8.8.8"]}}
# Baseline applied, then the user edited the config (drift).
self._write_config(
paths["dnsmasq"], {"dns": {"upstreams": ["1.1.1.1"]}}, applied
)
dirty = self._read_config(paths["dnsmasq"])
assert dirty[_APPLY_HASH_KEY] != config_hash(dirty) # really pending
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(dnsmasq=True),
),
patch("daemon.handlers.status.refresh_state") as mock_refresh,
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
assert result == {
"cancelled": ["dnsmasq"],
"skipped": {},
"errors": {},
}
mock_refresh.assert_called_once_with(status.SYS_ORDER)
restored = self._read_config(paths["dnsmasq"])
assert restored.get(_LAST_APPLIED_CONFIG_KEY) == applied
# Cancel restored the baseline: the pending check now passes.
assert restored[_APPLY_HASH_KEY] == config_hash(restored)
def test_skipped_when_no_baseline(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
cfg_no_baseline = {"peers": {"a": {}}}
self._write_config(paths["wireguard"], cfg_no_baseline, None)
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(wireguard=True),
),
patch("daemon.handlers.status.refresh_state") as mock_refresh,
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
assert result["cancelled"] == []
assert "WireGuard" in result["skipped"]
assert result["errors"] == {}
mock_refresh.assert_not_called()
# File left untouched.
assert self._read_config(paths["wireguard"]) == cfg_no_baseline
def test_error_in_one_subsystem_others_still_cancel(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
for name in ("firewall", "nginx"):
self._write_config(
paths[name],
{"zones": {}} if name == "firewall" else {"domains": {}},
{"x": 1},
)
def fake_revert(path):
if "nginx" in str(path):
raise RuntimeError("disk full")
return True, ""
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(firewall=True, nginx=True),
),
patch("daemon.handlers.status.refresh_state"),
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
# SYS_ORDER runs firewall before nginx.
assert result["cancelled"] == ["firewall"]
assert result["errors"] == {"Nginx": "disk full"}
assert result["skipped"] == {}
def test_order_matches_sys_order(self, tmp_path):
paths = {name: tmp_path / name / "config.json" for name in status.SYS_ORDER}
call_order = []
def fake_revert(path):
call_order.append(next(n for n, p in paths.items() if p == path))
return True, ""
with (
patch(
"daemon.handlers.status.status_pending",
return_value=_pending(
firewall=True,
dnsmasq=True,
nginx=True,
wireguard=True,
networkd=True,
),
),
patch("daemon.handlers.status.refresh_state"),
patch("daemon.handlers.status.revert_to_applied", side_effect=fake_revert),
patch(
"daemon.handlers.status._config_path",
side_effect=lambda name: paths[name],
),
):
result = status.status_cancel_all(None, None)
assert result["cancelled"] == status.SYS_ORDER
assert call_order == status.SYS_ORDER
+342
View File
@@ -0,0 +1,342 @@
"""Tests for daemon/handlers/status.py — aggregate pending + apply-all."""
from typing import Any, ClassVar
from unittest.mock import MagicMock, patch
from daemon.handlers import status
from lib.state import State
def _make_state(**kwargs):
"""Create a minimal in-memory state for pending checks."""
st = State()
for name, data in kwargs.items():
st.set(name, data)
return st
def _mock_state_store(state_dict):
"""Return a mock that looks like state_store.get()."""
mock = MagicMock()
mock.get.side_effect = lambda name: state_dict.get(name)
return mock
class TestFwChangeSummary:
"""Test fw_change_summary helper from status module."""
def test_interfaces_summary(self):
s = status.fw_change_summary(
"internal", "interfaces", {"config": ["eth1"], "live": []}
)
assert "Zone internal: interfaces changed" in s
assert "eth1" in s
def test_services_summary(self):
s = status.fw_change_summary(
"dmz", "services", {"config": ["ssh", "dns"], "live": ["ssh"]}
)
assert "Zone dmz: services changed" in s
def test_rich_rules_summary(self):
s = status.fw_change_summary(
"public", "rich_rules", {"config_count": 2, "live_count": 1}
)
assert "config: 2" in s
assert "live: 1" in s
def test_forward_ports_summary(self):
s = status.fw_change_summary(
"wan", "forward_ports", {"config_count": 3, "live_count": 0}
)
assert "Zone wan: port forwards differ" in s
def test_masquerade_summary(self):
s = status.fw_change_summary(
"lan", "masquerade", {"config": True, "live": False}
)
assert "Zone lan: masquerade changed" in s
def test_target_summary(self):
s = status.fw_change_summary(
"vpn", "target", {"config": "ACCEPT", "live": "default"}
)
assert "Zone vpn: target changed" in s
def test_unknown_type_summary(self):
s = status.fw_change_summary("public", "weird", {})
assert "Zone public: weird changed" in s
class TestHashSubsystem:
"""Test _hash_subsystem helper from status module."""
def test_no_state(self):
result = status._hash_subsystem("nginx", None)
assert result["pending_changes"] is False
assert result["summary"] == "Up to date"
def test_pending_true(self):
st = {"status": {"pending_changes": True}}
result = status._hash_subsystem("wireguard", st)
assert result["pending_changes"] is True
assert "unapplied changes" in result["summary"]
assert len(result["changes"]) == 1
def test_pending_false(self):
st = {"status": {"pending_changes": False}}
result = status._hash_subsystem("networkd", st)
assert result["pending_changes"] is False
def test_empty_status(self):
st = {}
result = status._hash_subsystem("dnsmasq", st)
assert result["pending_changes"] is False
class TestStatusPending:
"""Test the aggregate pending endpoint."""
@patch("daemon.handlers.status.state_store")
def test_all_synced(self, mock_store):
mock_store.get.return_value = {
"firewall": {"pending": {"needs_apply": False, "pending": []}},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
result = status.status_pending(None, None)
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
assert not result["dnsmasq"]["pending_changes"]
def _patch_store(self, data):
mock = MagicMock()
mock.get.side_effect = lambda name: data.get(name)
return patch("daemon.handlers.status.state_store", mock)
def test_firewall_pending_only(self):
with self._patch_store(
{
"firewall": {
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "internal",
"type": "interfaces",
"config": ["eth1"],
"live": [],
}
],
}
},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": False}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["total_changes"] == 1
assert result["firewall"]["change_count"] == 1
def test_multiple_subsystems_pending(self):
with self._patch_store(
{
"firewall": {
"pending": {
"needs_apply": True,
"pending": [
{
"zone": "lan",
"type": "services",
"config": ["ssh"],
"live": [],
},
{
"zone": "wan",
"type": "masquerade",
"config": True,
"live": False,
},
],
}
},
"dnsmasq": {"status": {"pending_changes": True}},
"nginx": {"status": {"pending_changes": False}},
"wireguard": {"status": {"pending_changes": True}},
"networkd": {"status": {"pending_changes": False}},
}
):
result = status.status_pending(None, None)
assert result["total_changes"] == 4 # 2 FW + 1 DHCP + 1 WG
assert result["firewall"]["change_count"] == 2
assert result["firewall"]["needs_apply"] is True
assert result["dnsmasq"]["pending_changes"] is True
assert result["wireguard"]["pending_changes"] is True
def test_empty_state(self):
with self._patch_store({}):
result = status.status_pending(None, None)
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
def test_firewall_no_pending_key(self):
with self._patch_store(
{
"firewall": {},
"dnsmasq": {"status": {"pending_changes": False}},
"nginx": None,
"wireguard": None,
"networkd": None,
}
):
result = status.status_pending(None, None)
assert result["total_changes"] == 0
assert not result["firewall"]["needs_apply"]
class TestStatusApplyAll:
"""Test the apply-all endpoint.
Patches SYS_APPLY dict entries directly since they hold function
references at import time.
"""
_fake_pending_all: ClassVar[dict[str, Any]] = {
"firewall": {"needs_apply": False, "change_count": 0, "changes": []},
"dnsmasq": {"pending_changes": False, "summary": "Up to date", "changes": []},
"nginx": {"pending_changes": False, "summary": "Up to date", "changes": []},
"wireguard": {"pending_changes": False, "summary": "Up to date", "changes": []},
"networkd": {"pending_changes": False, "summary": "Up to date", "changes": []},
}
@patch("daemon.handlers.status.status_pending")
@patch("daemon.handlers.status.refresh_state")
def test_nothing_to_apply(self, mock_refresh, mock_pending):
mock_pending.return_value = self._fake_pending_all
result = status.status_apply_all(None, None)
assert result["applied"] == []
assert result["errors"] == {}
mock_refresh.assert_called_once()
def test_applies_pending_subsystems(self):
mock_net = MagicMock()
mock_fw = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
pending_data["networkd"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"networkd": mock_net,
"firewall": mock_fw,
},
),
):
result = status.status_apply_all(None, None)
assert "networkd" in result["applied"]
assert "firewall" in result["applied"]
mock_net.assert_called_once()
mock_fw.assert_called_once()
def test_error_in_subsystem(self):
mock_fw = MagicMock(side_effect=RuntimeError("firewalld not running"))
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict("daemon.handlers.status.SYS_APPLY", {"firewall": mock_fw}),
):
result = status.status_apply_all(None, None)
assert "firewall" not in result["applied"]
assert "Firewall" in result["errors"]
assert "firewalld not running" in result["errors"]["Firewall"]
def test_order_is_respected(self):
call_order = []
def track(name):
def wrapper(*args):
call_order.append(name)
return wrapper
mock_net = MagicMock(side_effect=track("networkd"))
mock_wg = MagicMock(side_effect=track("wireguard"))
pending_data = {**self._fake_pending_all}
pending_data["wireguard"]["pending_changes"] = True
pending_data["networkd"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"networkd": mock_net,
"wireguard": mock_wg,
},
),
):
status.status_apply_all(None, None)
assert call_order == ["networkd", "wireguard"]
def test_partial_failure_still_applies_others(self):
mock_fw = MagicMock(side_effect=RuntimeError("fail"))
mock_nginx = MagicMock()
pending_data = {**self._fake_pending_all}
pending_data["firewall"]["needs_apply"] = True
pending_data["firewall"]["change_count"] = 1
pending_data["nginx"]["pending_changes"] = True
with (
patch("daemon.handlers.status.status_pending", return_value=pending_data),
patch("daemon.handlers.status.refresh_state"),
patch.dict(
"daemon.handlers.status.SYS_APPLY",
{
"firewall": mock_fw,
"nginx": mock_nginx,
},
),
):
result = status.status_apply_all(None, None)
assert "firewall" not in result["applied"]
assert "nginx" in result["applied"]
assert "Firewall" in result["errors"]
mock_nginx.assert_called_once()
class TestSysOrder:
"""Verify SYS_ORDER and SYS_LABELS constants."""
def test_order_network_first(self):
assert status.SYS_ORDER[0] == "networkd"
def test_all_subsystems_present(self):
expected = {"networkd", "firewall", "wireguard", "dnsmasq", "nginx"}
assert set(status.SYS_ORDER) == expected
def test_labels_match(self):
for name in status.SYS_ORDER:
assert name in status.SYS_LABELS
assert name in status.SYS_APPLY
def test_apply_functions_callable(self):
for name in status.SYS_ORDER:
assert callable(status.SYS_APPLY[name])
+1029
View File
File diff suppressed because it is too large Load Diff
+675
View File
@@ -0,0 +1,675 @@
"""Tests for lib/system_import module."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from lib import system_import
from lib.common import save_json
@pytest.fixture
def temp_project(tmp_path):
"""Patch all module-level path constants to tmp_path subdirs."""
originals = {
"PROJECT_DIR": system_import.PROJECT_DIR,
"DNSMASQ_CONF": system_import.DNSMASQ_CONF,
"WG_CONF": system_import.WG_CONF,
"NETWORKD_DIR": system_import.NETWORKD_DIR,
"NGINX_SITES_DIR": system_import.NGINX_SITES_DIR,
}
system_import.PROJECT_DIR = tmp_path
system_import.DNSMASQ_CONF = tmp_path / "etc" / "dnsmasq.d" / "vacuum-wall.conf"
system_import.WG_CONF = tmp_path / "etc" / "wireguard" / "wg0.conf"
system_import.NETWORKD_DIR = tmp_path / "etc" / "systemd" / "network"
system_import.NGINX_SITES_DIR = tmp_path / "data" / "nginx" / "sites-enabled"
yield tmp_path
system_import.PROJECT_DIR = originals["PROJECT_DIR"]
system_import.DNSMASQ_CONF = originals["DNSMASQ_CONF"]
system_import.WG_CONF = originals["WG_CONF"]
system_import.NETWORKD_DIR = originals["NETWORKD_DIR"]
system_import.NGINX_SITES_DIR = originals["NGINX_SITES_DIR"]
# ──────────────────────────────────────────────────────────────────────
# Dnsmasq
# ──────────────────────────────────────────────────────────────────────
class TestImportDnsmasq:
def _write_conf(self, tmp_path, content: str) -> Path:
p = tmp_path / "etc" / "dnsmasq.d"
p.mkdir(parents=True, exist_ok=True)
(p / "vacuum-wall.conf").write_text(content)
return p / "vacuum-wall.conf"
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "dnsmasq" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_conf_file(self, temp_project):
assert not system_import.import_dnsmasq()
def test_no_markers(self, temp_project, tmp_path):
self._write_conf(tmp_path, "# some random config\nserver=1.1.1.1\n")
assert not system_import.import_dnsmasq()
def test_empty_managed_block(self, temp_project, tmp_path):
self._write_conf(tmp_path, f"{system_import.DNSTART}\n{system_import.DNEND}")
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == []
assert cfg["dhcp"]["ranges"] == []
def test_upstreams_only(self, temp_project, tmp_path):
conf = (
f"{system_import.DNSTART}\n"
"server=8.8.8.8\n"
"server=1.1.1.1\n"
f"{system_import.DNEND}"
)
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
def test_full_config(self, temp_project, tmp_path):
conf = (
f"{system_import.DNSTART}\n"
"server=8.8.8.8\n"
"server=1.1.1.1\n"
"domain=lan\n"
"expand-hosts\n"
"dhcp-range=set:eth1,192.168.2.100,192.168.2.200,12h\n"
"dhcp-option=tag:eth1,3,192.168.2.1\n"
"dhcp-option=tag:eth1,6,192.168.2.1\n"
"dhcp-host=aa:bb:cc:dd:ee:ff,192.168.2.50,printer\n"
"addr/nas.lan/192.168.2.10\n"
f"{system_import.DNEND}"
)
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == ["8.8.8.8", "1.1.1.1"]
assert cfg["dns"]["domain"] == "lan"
assert len(cfg["dhcp"]["ranges"]) == 1
rng = cfg["dhcp"]["ranges"][0]
assert rng["interface"] == "eth1"
assert rng["start"] == "192.168.2.100"
assert rng["end"] == "192.168.2.200"
assert rng["lease_time"] == "12h"
assert rng["gateway"] == "192.168.2.1"
assert rng["dns"] == "192.168.2.1"
assert len(cfg["dhcp"]["static_leases"]) == 1
lease = cfg["dhcp"]["static_leases"][0]
assert lease["mac"] == "aa:bb:cc:dd:ee:ff"
assert lease["ip"] == "192.168.2.50"
assert lease["hostname"] == "printer"
assert len(cfg["dns"]["custom_records"]) == 1
assert cfg["dns"]["custom_records"][0] == {
"name": "nas.lan",
"address": "192.168.2.10",
}
def test_no_resolv_resets_upstreams(self, temp_project, tmp_path):
conf = f"{system_import.DNSTART}\nno-resolv\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
cfg = self._read_json(tmp_path)
assert cfg["dns"]["upstreams"] == []
def test_idempotent(self, temp_project, tmp_path):
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
assert system_import.import_dnsmasq()
assert not system_import.import_dnsmasq()
def test_parse_error_returns_false(self, temp_project, tmp_path):
# Conf with markers — parses fine, so this tests the exception handler
# by mocking _parse_dnsmasq_block to raise
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
self._write_conf(tmp_path, conf)
with patch(
"lib.system_import._parse_dnsmasq_block", side_effect=ValueError("bad")
):
assert not system_import.import_dnsmasq()
# ──────────────────────────────────────────────────────────────────────
# WireGuard
# ──────────────────────────────────────────────────────────────────────
class TestImportWireguard:
def _write_conf(self, tmp_path, content: str) -> Path:
p = tmp_path / "etc" / "wireguard"
p.mkdir(parents=True, exist_ok=True)
(p / "wg0.conf").write_text(content)
return p / "wg0.conf"
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "wireguard" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_conf_file(self, temp_project):
assert not system_import.import_wireguard()
def test_basic_interface(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg["interface"]["private_key"] == "abc123"
assert cfg["interface"]["addresses"] == ["10.137.0.1/24"]
assert cfg["interface"]["listen_port"] == 51820
assert cfg["interface"]["name"] == "wg0"
assert cfg["peers"] == {}
def test_full_with_peers(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = srv-priv\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
" PostUp = iptables -I FORWARD -i wg0 -j ACCEPT\n"
" PostDown = iptables -D FORWARD -i wg0 -j ACCEPT\n"
"\n"
"[Peer] # alice\n"
" PublicKey = alice-pub\n"
" Endpoint = 203.0.113.1:51820\n"
" AllowedIPs = 0.0.0.0/0\n"
" PersistentKeepalive = 25\n"
"\n"
"[Peer] # bob\n"
" PublicKey = bob-pub\n"
" AllowedIPs = 10.0.0.0/8,172.16.0.0/12\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert cfg["interface"]["post_up"] == "iptables -I FORWARD -i wg0 -j ACCEPT"
assert cfg["interface"]["post_down"] == "iptables -D FORWARD -i wg0 -j ACCEPT"
assert "alice" in cfg["peers"]
assert cfg["peers"]["alice"]["public_key"] == "alice-pub"
assert cfg["peers"]["alice"]["endpoint"] == "203.0.113.1:51820"
assert cfg["peers"]["alice"]["allowed_ips"] == ["0.0.0.0/0"]
assert cfg["peers"]["alice"]["persistent_keepalive"] == 25
assert "bob" in cfg["peers"]
assert cfg["peers"]["bob"]["allowed_ips"] == ["10.0.0.0/8", "172.16.0.0/12"]
def test_peer_without_name_uses_pubkey(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = srv-priv\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
"\n"
"[Peer]\n"
" PublicKey = anon-pub\n"
" AllowedIPs = 0.0.0.0/0\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
cfg = self._read_json(tmp_path)
assert "anon-pub" in cfg["peers"]
def test_idempotent(self, temp_project, tmp_path):
conf = (
"[Interface]\n"
" PrivateKey = abc123\n"
" Address = 10.137.0.1/24\n"
" ListenPort = 51820\n"
)
self._write_conf(tmp_path, conf)
assert system_import.import_wireguard()
assert not system_import.import_wireguard()
# ──────────────────────────────────────────────────────────────────────
# Networkd
# ──────────────────────────────────────────────────────────────────────
class TestImportNetworkd:
def _write_network(self, tmp_path, name: str, content: str) -> Path:
p = tmp_path / "etc" / "systemd" / "network"
p.mkdir(parents=True, exist_ok=True)
file_path = p / f"99-{name}.network"
file_path.write_text(content)
return file_path
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "network" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_network_dir(self, temp_project):
assert not system_import.import_networkd()
def test_no_files(self, temp_project, tmp_path):
(tmp_path / "etc" / "systemd" / "network").mkdir(parents=True, exist_ok=True)
assert not system_import.import_networkd()
def test_basic_interface(self, temp_project, tmp_path):
conf = (
"[Match]\n"
"Name=eth0\n"
"\n"
"[Network]\n"
"DHCP=no\n"
"Addresses=192.168.1.1/24\n"
"Gateway=192.168.1.254\n"
"DNS=8.8.8.8\n"
"DNS=1.1.1.1\n"
)
self._write_network(tmp_path, "eth0", conf)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
eth0 = cfg["interfaces"]["eth0"]
assert eth0["dhcp"] == "no"
assert eth0["gateway"] == "192.168.1.254"
assert eth0["dns"] == ["8.8.8.8", "1.1.1.1"]
def test_multiple_interfaces(self, temp_project, tmp_path):
self._write_network(
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
)
self._write_network(
tmp_path, "eth1", "[Match]\nName=eth1\n\n[Network]\nDHCP=no\n"
)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
assert "eth0" in cfg["interfaces"]
assert "eth1" in cfg["interfaces"]
assert cfg["interfaces"]["eth0"]["dhcp"] == "yes"
assert cfg["interfaces"]["eth1"]["dhcp"] == "no"
def test_preserves_existing_interfaces(self, temp_project, tmp_path):
# Pre-existing JSON has eth2 with no .network file
cfg_path = tmp_path / "config" / "network"
cfg_path.mkdir(parents=True, exist_ok=True)
save_json(cfg_path / "config.json", {"interfaces": {"eth2": {"dhcp": "no"}}})
self._write_network(
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
assert "eth0" in cfg["interfaces"]
assert "eth2" in cfg["interfaces"]
def test_idempotent(self, temp_project, tmp_path):
self._write_network(
tmp_path, "eth0", "[Match]\nName=eth0\n\n[Network]\nDHCP=yes\n"
)
assert system_import.import_networkd()
assert not system_import.import_networkd()
def test_address_section_parsed(self, temp_project, tmp_path):
conf = (
"[Match]\n"
"Name=eth0\n"
"\n"
"[Network]\n"
"DHCP=no\n"
"\n"
"[Address]\n"
"Address=192.168.1.1/24\n"
)
self._write_network(tmp_path, "eth0", conf)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
eth0 = cfg["interfaces"]["eth0"]
assert "192.168.1.1/24" in eth0.get("addresses", [])
def test_route_section_parsed(self, temp_project, tmp_path):
conf = (
"[Match]\n"
"Name=eth0\n"
"\n"
"[Network]\n"
"DHCP=no\n"
"\n"
"[Route]\n"
"Destination=10.0.0.0/8\n"
"Gateway=192.168.1.254\n"
"Metric=100\n"
)
self._write_network(tmp_path, "eth0", conf)
assert system_import.import_networkd()
cfg = self._read_json(tmp_path)
eth0 = cfg["interfaces"]["eth0"]
assert len(eth0.get("routes", [])) == 1
route = eth0["routes"][0]
assert route["destination"] == "10.0.0.0/8"
assert route["gateway"] == "192.168.1.254"
assert route["metric"] == 100
# ──────────────────────────────────────────────────────────────────────
# Nginx
# ──────────────────────────────────────────────────────────────────────
class TestImportNginx:
def _write_site(self, tmp_path, domain: str, content: str) -> Path:
p = tmp_path / "data" / "nginx" / "sites-enabled"
p.mkdir(parents=True, exist_ok=True)
file_path = p / f"{domain}.conf"
file_path.write_text(content)
return file_path
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "nginx" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_sites_dir(self, temp_project):
assert not system_import.import_nginx()
def test_no_files(self, temp_project, tmp_path):
(tmp_path / "data" / "nginx" / "sites-enabled").mkdir(
parents=True, exist_ok=True
)
assert not system_import.import_nginx()
def test_acme_challenge_skipped(self, temp_project, tmp_path):
(tmp_path / "data" / "nginx" / "sites-enabled").mkdir(
parents=True, exist_ok=True
)
(
tmp_path / "data" / "nginx" / "sites-enabled" / "_acme-challenge.conf"
).write_text("# stuff\n")
assert not system_import.import_nginx()
def test_unrecognized_file_skipped(self, temp_project, tmp_path):
self._write_site(tmp_path, "my-site", "# some random nginx config\nserver {}\n")
assert not system_import.import_nginx()
def test_basic_site(self, temp_project, tmp_path):
conf = (
"# Auto-generated by Vacuum Wall — do not edit manually\n"
"# Domain: example.com\n"
"\n"
"server {\n"
" listen 80;\n"
" listen [::]:80;\n"
" server_name example.com;\n"
" return 301 https://$host$request_uri;\n"
"}\n"
"\n"
"server {\n"
" listen 443 ssl;\n"
" listen [::]:443 ssl;\n"
" server_name example.com;\n"
"\n"
" ssl_certificate /home/wall/vacuum-wall/data/acme/example.com/fullchain.cer;\n"
" ssl_certificate_key /home/wall/vacuum-wall/data/acme/example.com/example.com.key;\n"
"\n"
" # / -> 192.168.2.50:8080\n"
" location / {\n"
" auth_basic off;\n"
" proxy_pass http://192.168.2.50:8080;\n"
" }\n"
"}\n"
)
self._write_site(tmp_path, "example.com", conf)
assert system_import.import_nginx()
cfg = self._read_json(tmp_path)
assert "example.com" in cfg["domains"]
dom = cfg["domains"]["example.com"]
assert dom["force_ssl"] is True
assert dom["cert"] == "acme"
assert "/" in dom["paths"]
assert dom["paths"]["/"]["backend"]["host"] == "192.168.2.50"
assert dom["paths"]["/"]["backend"]["port"] == 8080
def test_websocket_path(self, temp_project, tmp_path):
conf = (
"# Auto-generated by Vacuum Wall — do not edit manually\n"
"# Domain: example.com\n"
"\n"
"server {\n"
" listen 443 ssl;\n"
" server_name example.com;\n"
"\n"
" ssl_certificate /data/certs/example.com.crt;\n"
" ssl_certificate_key /data/certs/example.com.key;\n"
"\n"
" # / -> 127.0.0.1:9090\n"
" location / {\n"
" auth_basic off;\n"
" proxy_pass http://127.0.0.1:9090;\n"
" }\n"
"\n"
" # /ws -> 127.0.0.1:9091 (WebSocket)\n"
" location /ws {\n"
" auth_basic off;\n"
" proxy_pass http://127.0.0.1:9091;\n"
" }\n"
"}\n"
)
self._write_site(tmp_path, "example.com", conf)
assert system_import.import_nginx()
cfg = self._read_json(tmp_path)
dom = cfg["domains"]["example.com"]
assert dom["cert"] == "selfsigned"
assert dom["paths"]["/ws"]["is_websocket"] is True
assert dom["paths"]["/ws"]["backend"]["port"] == 9091
def test_idempotent(self, temp_project, tmp_path):
conf = (
"# Auto-generated by Vacuum Wall — do not edit manually\n"
"server {\n"
" listen 443 ssl;\n"
" server_name example.com;\n"
" ssl_certificate /data/acme/example.com/fullchain.cer;\n"
" ssl_certificate_key /data/acme/example.com/example.com.key;\n"
" # / -> 127.0.0.1:9090\n"
" location / {\n"
" auth_basic off;\n"
" proxy_pass http://127.0.0.1:9090;\n"
" }\n"
"}\n"
)
self._write_site(tmp_path, "example.com", conf)
assert system_import.import_nginx()
assert not system_import.import_nginx()
# ──────────────────────────────────────────────────────────────────────
# Firewall
# ──────────────────────────────────────────────────────────────────────
FIREWALL_ZONES_OUTPUT = (
"public (active)\n"
" target: default\n"
" interfaces: eth0 eth1\n"
" sources: \n"
" services: dhcpv6-cidr dns mdns ssh\n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
"\n"
"internal (active)\n"
" target: DEFAULT\n"
" interfaces: eth2\n"
" sources: \n"
" services: dhcpv6-cidr dns mdns samba-client ssh\n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
"\n"
"dmz (active)\n"
" target: DROP\n"
" interfaces: \n"
" sources: \n"
" services: dns\n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" source-ports: \n"
" icmp-blocks: \n"
" rich rules: \n"
)
class TestImportFirewall:
def _read_json(self, tmp_path) -> dict:
p = tmp_path / "config" / "firewall" / "config.json"
return json.loads(p.read_text()) if p.exists() else {}
def test_no_config_file_and_firewalld_down(self, temp_project, tmp_path):
with patch(
"lib.system_import.run", side_effect=RuntimeError("firewalld not running")
):
assert not system_import.import_firewall()
def test_existing_config_not_overwritten(self, temp_project, tmp_path):
cfg_path = tmp_path / "config" / "firewall"
cfg_path.mkdir(parents=True, exist_ok=True)
save_json(
cfg_path / "config.json", {"zones": {"public": {"interfaces": ["eth0"]}}}
)
assert not system_import.import_firewall()
def test_import_zones(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert "zones" in cfg
assert "public" in cfg["zones"]
assert "internal" in cfg["zones"]
assert cfg["zones"]["public"]["target"] == "DEFAULT"
assert cfg["zones"]["public"]["interfaces"] == ["eth0", "eth1"]
assert cfg["zones"]["public"]["services"] == [
"dhcpv6-cidr",
"dns",
"mdns",
"ssh",
]
assert cfg["zones"]["internal"]["target"] == "DEFAULT"
assert cfg["zones"]["internal"]["interfaces"] == ["eth2"]
def test_empty_interface_zones_skipped(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
assert system_import.import_firewall()
cfg = self._read_json(tmp_path)
assert "dmz" not in cfg["zones"]
def test_parse_error_returns_false(self, temp_project, tmp_path):
with patch("lib.system_import.run", return_value="garbage with no valid zones"):
assert not system_import.import_firewall()
# ──────────────────────────────────────────────────────────────────────
# import_all
# ──────────────────────────────────────────────────────────────────────
class TestImportAll:
def test_all_missing(self, temp_project, tmp_path):
from unittest.mock import MagicMock, patch
mock_run = MagicMock(side_effect=RuntimeError("command not found"))
with patch.object(system_import, "run", mock_run):
result = system_import.import_all()
assert result == []
def test_returns_updated_subsystems(self, temp_project, tmp_path):
# Create dnsmasq conf
etc = tmp_path / "etc" / "dnsmasq.d"
etc.mkdir(parents=True, exist_ok=True)
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
(etc / "vacuum-wall.conf").write_text(conf)
# Create wireguard conf
wg_etc = tmp_path / "etc" / "wireguard"
wg_etc.mkdir(parents=True, exist_ok=True)
(wg_etc / "wg0.conf").write_text(
"[Interface]\n PrivateKey = abc\n Address = 10.137.0.1/24\n ListenPort = 51820\n"
)
result = system_import.import_all()
assert "dnsmasq" in result
assert "wireguard" in result
assert "network" not in result
assert "nginx" not in result
def test_parse_error_does_not_crash(self, temp_project, tmp_path):
# Create a dnsmasq conf that will parse fine
etc = tmp_path / "etc" / "dnsmasq.d"
etc.mkdir(parents=True, exist_ok=True)
conf = f"{system_import.DNSTART}\nserver=8.8.8.8\n{system_import.DNEND}"
(etc / "vacuum-wall.conf").write_text(conf)
# Make wireguard import fail
wg_etc = tmp_path / "etc" / "wireguard"
wg_etc.mkdir(parents=True, exist_ok=True)
(wg_etc / "wg0.conf").write_text("[Interface]\n")
# This should not raise, just log warning
result = system_import.import_all()
assert "dnsmasq" in result
# ──────────────────────────────────────────────────────────────────────
# _cfgs_equal
# ──────────────────────────────────────────────────────────────────────
class TestCfgsEqual:
def test_equal(self):
assert system_import._cfgs_equal({"a": 1}, {"a": 1})
def test_not_equal(self):
assert not system_import._cfgs_equal({"a": 1}, {"a": 2})
def test_ignores_applied_hash(self):
a = {"a": 1, "_last_applied_hash": "abc"}
b = {"a": 1, "_last_applied_hash": "xyz"}
assert system_import._cfgs_equal(a, b)
def test_nested(self):
a = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
b = {"dhcp": {"ranges": [{"start": "1.2.3.4"}]}}
assert system_import._cfgs_equal(a, b)
# ──────────────────────────────────────────────────────────────────────
# Template marker consistency
# ──────────────────────────────────────────────────────────────────────
class TestTemplateMarker:
"""Verify nginx templates contain the expected auto-generated marker."""
def test_acme_challenge_has_marker(self):
content = (
Path(__file__).resolve().parent.parent
/ "system"
/ "nginx"
/ "acme-challenge.conf"
).read_text()
assert "# Auto-generated by Vacuum Wall" in content
def test_server_block_has_marker(self):
content = (
Path(__file__).resolve().parent.parent
/ "system"
/ "nginx"
/ "server_block.conf"
).read_text()
assert "# Auto-generated by Vacuum Wall" in content
+277 -6
View File
@@ -23,6 +23,13 @@ class TestDefaultConfig:
assert cfg["interface"]["private_key"] == ""
assert cfg["peers"] == {}
def test_classes_have_phase2_fields(self):
cfg = wireguard.DEFAULT_CONFIG
for ck, cv in cfg["access_classes"].items():
assert "subnet" in cv, f"Class {ck} missing subnet"
assert "listen_port" in cv, f"Class {ck} missing listen_port"
assert "lan_access" in cv, f"Class {ck} missing lan_access"
class TestGetConfig:
def test_returns_default_when_no_file(self, temp_config):
@@ -74,6 +81,92 @@ class TestGenerateKeyPair:
assert mock_run.call_args_list[1].kwargs.get("input") == "private-key"
class TestClassHelpers:
def test_class_interface_name(self):
assert wireguard.get_class_interface_name("full") == "wg-full"
assert wireguard.get_class_interface_name("internet") == "wg-internet"
assert wireguard.get_class_interface_name("custom") == "wg-custom"
def test_class_zone_name(self):
assert wireguard.get_class_zone_name("full") == "vpn-full"
assert wireguard.get_class_zone_name("internet") == "vpn-internet"
def test_class_peers(self):
cfg = {
"peers": {
"alice": {"access_class": "full", "public_key": "pk1"},
"bob": {"access_class": "internet", "public_key": "pk2"},
"carol": {"access_class": None, "public_key": "pk3"},
}
}
full_peers = wireguard._class_peers(cfg, "full")
assert len(full_peers) == 1
assert "alice" in full_peers
int_peers = wireguard._class_peers(cfg, "internet")
assert len(int_peers) == 1
assert "bob" in int_peers
class TestGenerateClassConf:
def test_returns_none_when_no_peers(self, temp_config):
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "test-key"
result = wireguard.generate_class_conf(cfg, "full")
assert result is None
@patch("lib.wireguard.ENV")
def test_renders_template_per_class(self, mock_env, temp_config):
mock_tmpl = MagicMock()
mock_tmpl.render.return_value = "[Interface]\nPrivateKey = x\n"
mock_env.get_template.return_value = mock_tmpl
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "test-key"
cfg["peers"]["alice"] = {
"access_class": "full",
"public_key": "pub1",
"private_key": "priv1",
"allowed_ips": ["0.0.0.0/0"],
}
result = wireguard.generate_class_conf(cfg, "full")
assert result is not None
assert mock_tmpl.render.call_count == 1
call_kwargs = mock_tmpl.render.call_args.kwargs
assert call_kwargs["interface"]["name"] == "wg-full"
assert call_kwargs["interface"]["private_key"] == "test-key"
assert "alice" in call_kwargs["peers"]
def test_raises_when_no_private_key(self, temp_config):
cfg = {
"interface": {
"name": "wg0",
"listen_port": 51820,
"private_key": "",
"addresses": ["10.137.0.1/24"],
},
"access_classes": {
"full": {
"name": "Full LAN Access",
"description": "Full access",
"subnet": "10.137.0.0/24",
"listen_port": 51820,
"lan_access": True,
},
},
"peers": {
"alice": {
"access_class": "full",
"public_key": "pub1",
},
},
}
wireguard.save_config(cfg)
cfg = wireguard.get_config()
with pytest.raises(ValueError, match="no private key"):
wireguard.generate_class_conf(cfg, "full")
class TestGetPeers:
def test_empty_peers(self, temp_config):
peers = wireguard.get_peers()
@@ -238,12 +331,190 @@ class TestStatus:
class TestGenerateWgShowParser:
def test_parses_peer_output(self):
output = (
"peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
"interface: wg0\n"
" public key: IFACE-PUB\n"
" listening port: 51820\n"
" peer: PUBKEY1\n endpoint: 203.0.113.1:51820\n allowed ips: 10.0.0.0/24\n"
)
result = wireguard._parse_wg_show(output)
assert "PUBKEY1" in result
assert result["PUBKEY1"]["endpoint"] == "203.0.113.1:51820"
result = wireguard._parse_wg_show_output(output)
assert result["up"] is True
assert result["interface"]["public_key"] == "IFACE-PUB"
assert result["interface"]["listen_port"] == 51820
assert len(result["peers"]) == 1
assert result["peers"][0]["public_key"] == "PUBKEY1"
assert result["peers"][0]["endpoint"] == "203.0.113.1:51820"
assert result["peers"][0]["allowed_ips"] == ["10.0.0.0/24"]
def test_empty_output(self):
result = wireguard._parse_wg_show("")
assert result == {}
result = wireguard._parse_wg_show_output("")
assert result["up"] is False
assert result["peers"] == []
class TestAccessClasses:
def test_default_config_has_access_classes(self):
cfg = wireguard.DEFAULT_CONFIG
assert "access_classes" in cfg
assert "full" in cfg["access_classes"]
assert "internet" in cfg["access_classes"]
def test_ensure_access_classes_empty(self, temp_config):
cfg = {"interface": {}, "access_classes": {}, "peers": {}}
wireguard._ensure_access_classes(cfg)
assert "full" in cfg["access_classes"]
assert "internet" in cfg["access_classes"]
def test_ensure_access_classes_preserves_existing(self, temp_config):
cfg = {
"interface": {},
"access_classes": {"custom": {"name": "Custom"}},
"peers": {},
}
wireguard._ensure_access_classes(cfg)
assert "custom" in cfg["access_classes"]
assert "full" not in cfg["access_classes"]
def test_ensure_class_defaults_adds_phase2_fields(self, temp_config):
cfg = {"interface": {}, "access_classes": {"old": {"name": "Old"}}, "peers": {}}
wireguard._ensure_access_classes(cfg)
c = cfg["access_classes"]["old"]
assert "subnet" in c
assert "listen_port" in c
assert "lan_access" in c
assert "private_key" in c
assert "public_key" in c
class TestAddPeerWithNewFields:
@patch("lib.wireguard.generate_keypair")
def test_add_peer_with_description_and_access_class(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
result = wireguard.add_peer(
"test-peer",
description="Test peer",
access_class="full",
)
assert result["description"] == "Test peer"
assert result["access_class"] == "full"
cfg = wireguard.get_config()
assert cfg["peers"]["test-peer"]["description"] == "Test peer"
assert cfg["peers"]["test-peer"]["access_class"] == "full"
@patch("lib.wireguard.generate_keypair")
def test_update_peer_preserves_existing_fields(self, mock_gen, temp_config):
mock_gen.return_value = ("priv", "pub")
wireguard.add_peer("p1", description="original", access_class="full")
wireguard.add_peer("p1", endpoint="1.2.3.4:51820")
cfg = wireguard.get_config()
assert cfg["peers"]["p1"]["endpoint"] == "1.2.3.4:51820"
assert cfg["peers"]["p1"]["description"] == "original"
assert cfg["peers"]["p1"]["access_class"] == "full"
class TestInterfaceHasNewFields:
def test_default_has_server_endpoint(self):
assert wireguard.DEFAULT_CONFIG["interface"].get("server_endpoint") == ""
def test_default_has_description(self):
assert wireguard.DEFAULT_CONFIG["interface"].get("description") == ""
class TestClassKeyGeneration:
@patch("lib.wireguard.run_proc")
def test_generates_keypair_for_class(self, mock_run, temp_config):
mock_run.side_effect = [
MagicMock(returncode=0, stdout="class-priv\n"),
MagicMock(returncode=0, stdout="class-pub\n"),
]
# Create config with a fresh class (no prior keys, no auto-merge from file)
cfg = {
"interface": {
"name": "wg0",
"private_key": "existing",
"public_key": "pub",
},
"access_classes": {"fresh": {"name": "Fresh", "description": "New class"}},
"peers": {},
}
wireguard.save_config(cfg)
priv, pub = wireguard.generate_class_keypair("fresh")
assert priv == "class-priv"
assert pub == "class-pub"
loaded = wireguard.get_config()
assert loaded["access_classes"]["fresh"]["private_key"] == "class-priv"
assert loaded["access_classes"]["fresh"]["public_key"] == "class-pub"
@patch("lib.wireguard.run_proc")
def test_returns_existing_keys(self, mock_run, temp_config):
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "existing-priv"
cfg["access_classes"]["full"]["public_key"] = "existing-pub"
wireguard.save_config(cfg)
priv, pub = wireguard.generate_class_keypair("full")
assert priv == "existing-priv"
assert pub == "existing-pub"
mock_run.assert_not_called()
def test_raises_for_missing_class(self, temp_config):
with pytest.raises(ValueError, match="not found"):
wireguard.generate_class_keypair("nonexistent")
class TestGetPeerStatusMultiInterface:
@patch("lib.wireguard.run_proc")
def test_aggregates_peers_across_classes(self, mock_run, temp_config):
def side_effect(cmd, **kwargs):
iface = cmd[-1]
if iface == "wg-full":
return MagicMock(
returncode=0,
stdout="interface:\n public key: FULL-PUB\n\npeer: PUB1\n",
)
if iface == "wg-internet":
return MagicMock(
returncode=0,
stdout="interface:\n public key: INT-PUB\n\npeer: PUB2\n",
)
# Legacy interface
if iface == "wg0":
return MagicMock(returncode=1, stdout="")
return MagicMock(returncode=1, stdout="")
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "full-priv"
cfg["access_classes"]["internet"]["private_key"] = "int-priv"
wireguard.save_config(cfg)
mock_run.side_effect = side_effect
peers = wireguard.get_peer_status()
assert len(peers) == 2
assert peers[0]["public_key"] == "PUB1"
assert peers[0]["access_class"] == "full"
assert peers[1]["public_key"] == "PUB2"
assert peers[1]["access_class"] == "internet"
class TestApplyClass:
@patch("lib.wireguard.run")
@patch("lib.wireguard.run_proc")
@patch("lib.wireguard.ENV")
def test_apply_class_writes_and_up(
self, mock_env, mock_proc, mock_run, temp_config
):
mock_tmpl = MagicMock()
mock_tmpl.render.return_value = "[Interface]\nPrivateKey = x\n"
mock_env.get_template.return_value = mock_tmpl
cfg = deepcopy(wireguard.DEFAULT_CONFIG)
cfg["access_classes"]["full"]["private_key"] = "test-key"
cfg["peers"]["alice"] = {
"access_class": "full",
"public_key": "pub1",
"private_key": "priv1",
"allowed_ips": ["0.0.0.0/0"],
}
wireguard.save_config(cfg)
wireguard.apply_class("full")
# Should call wg-quick up
mock_run.assert_any_call(["wg-quick", "up", "wg-full"], sudo=True)
+47
View File
@@ -0,0 +1,47 @@
"""Tests for WebSocket subprotocol token extraction (daemon.server)."""
import pytest
from daemon.server import _extract_ws_token
# Realistic-looking access token (base64url header.payload.signature).
TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.Y3WNO0CTxb4dlcVCPjUnQi"
class TestExtractWsToken:
def test_raw_jwt_subprotocol(self):
"""Bundled client path: the JWT is sent as the subprotocol name itself."""
assert _extract_ws_token([TOKEN]) == (TOKEN, TOKEN)
def test_raw_jwt_among_other_subprotocols(self):
assert _extract_ws_token(["vacuum-wall", TOKEN]) == (TOKEN, TOKEN)
@pytest.mark.parametrize(
"raw",
["none", "vacuum-wall", "a.b", "a.b.c.d"],
)
def test_no_auth_subprotocol(self, raw):
assert _extract_ws_token([raw]) == (None, None)
def test_empty_list(self):
assert _extract_ws_token([]) == (None, None)
def test_bearer_prefix_form(self):
"""Legacy non-browser form: 'Bearer <token>' subprotocol."""
assert _extract_ws_token([f"Bearer {TOKEN}"]) == (TOKEN, f"Bearer {TOKEN}")
def test_bearer_with_non_jwt_token(self):
assert _extract_ws_token(["Bearer abc123"]) == ("abc123", "Bearer abc123")
def test_bearer_without_token_ignored(self):
assert _extract_ws_token(["Bearer ", "Bearer"]) == (None, None)
def test_jwt_like_name_preferred_over_bearer(self):
"""A JWT-shaped subprotocol wins even when listed after a Bearer one."""
result = _extract_ws_token([f"Bearer {TOKEN}", TOKEN])
assert result == (TOKEN, TOKEN)
def test_subprotocol_with_space_rejected(self):
"""A space is not an RFC 6455 token character — 'Bearer <token>' must
go through the Bearer branch, never the JWT-shape branch."""
assert _extract_ws_token(["a b.c.d"]) == (None, None)
+114
View File
@@ -0,0 +1,114 @@
"""Tests for WS delta structure (daemon.server._poll_loop + broadcasts).
After the push-stream migration the poll loop drives per-subsystem deltas:
a structural diff bumps the version and broadcasts {type: versions,
subsystem, data}; a volatile-only diff broadcasts {type: tick, subsystem,
data}. No legacy `updated` dict / `subsystems` array is emitted.
"""
import asyncio
import hashlib
import json
from contextlib import suppress
from unittest.mock import AsyncMock, MagicMock, patch
import daemon.server as server
from lib.state import State
def _zero_offset_subsystem(interval: int) -> str:
"""Find a subsystem name whose md5 offset is 0 so the loop starts at once."""
for i in range(100_000):
name = f"sub{i}"
offset = int(hashlib.md5(name.encode()).hexdigest(), 16) % interval
if offset == 0:
return name
raise AssertionError("could not find zero-offset name")
def _run_one_poll_iteration(poll_result):
"""Run _poll_loop for a single iteration and return the broadcast mocks."""
namespaced = _zero_offset_subsystem(60)
async def drive():
store = MagicMock()
store.poll.return_value = poll_result
store.bump = MagicMock()
store.get.return_value = {"value": 1}
bv = AsyncMock()
bt = AsyncMock()
task = None
with (
patch.object(server, "state_store", store),
patch.object(server, "broadcast_versions", bv),
patch.object(server, "broadcast_tick", bt),
patch.object(server, "blacklist_expired"),
):
task = asyncio.create_task(server._poll_loop(namespaced, 60))
await asyncio.sleep(0.02) # let one full iteration run
task.cancel()
with suppress(asyncio.CancelledError):
await task
return store, bv, bt
store, bv, bt = asyncio.run(drive())
return store, bv, bt
class TestPollLoopDeltas:
def test_structural_change_broadcasts_versions(self):
store, bv, bt = _run_one_poll_iteration((True, False))
store.bump.assert_called_once_with(_zero_offset_subsystem(60))
bv.assert_awaited_once()
bt.assert_not_awaited()
def test_volatile_change_broadcasts_tick(self):
store, bv, bt = _run_one_poll_iteration((False, True))
store.bump.assert_not_called()
bv.assert_not_awaited()
bt.assert_awaited_once()
def test_no_change_no_broadcast(self):
store, bv, bt = _run_one_poll_iteration((False, False))
store.bump.assert_not_called()
bv.assert_not_awaited()
bt.assert_not_awaited()
class TestDeltaMessageShape:
def test_versions_message_carries_subsystem_and_data(self):
"""broadcast_versions emits {type, subsystem, data} — no `updated`."""
store = State()
store.set("firewall", {"zones": {"public": {}}})
ws = AsyncMock()
ws.send_str = AsyncMock()
server._ws_subscribers.add(ws)
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_versions("firewall"))
ws.send_str.assert_awaited_once()
msg = json.loads(ws.send_str.call_args[0][0])
assert msg["type"] == "versions"
assert msg["subsystem"] == "firewall"
assert msg["data"] == {"zones": {"public": {}}}
assert "updated" not in msg
finally:
server._ws_subscribers.discard(ws)
def test_tick_message_carries_subsystem_and_data(self):
store = State()
store.set("system", {"load": {"load1": 1.0}})
ws = AsyncMock()
ws.send_str = AsyncMock()
server._ws_subscribers.add(ws)
try:
with patch.object(server, "state_store", store):
asyncio.run(server.broadcast_tick("system"))
ws.send_str.assert_awaited_once()
msg = json.loads(ws.send_str.call_args[0][0])
assert msg["type"] == "tick"
assert msg["subsystem"] == "system"
assert msg["data"] == {"load": {"load1": 1.0}}
assert "subsystems" not in msg
finally:
server._ws_subscribers.discard(ws)
+127
View File
@@ -0,0 +1,127 @@
"""Tests for the WS connect snapshot (daemon.server._handle_ws).
After the push-stream migration, a successful WS handshake sends a full
state snapshot ({type: snapshot, data: {subsystem: state|null, ...}})
instead of the retired {type: init, versions: ...} message.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from lib.state import State
@pytest.fixture(autouse=False)
def db_reset():
"""Isolated in-memory DB so a builtin admin exists for token minting.
Mirrors the autouse _db_reset fixture in tests/test_auth.py (the DB
singleton must be reset and pointed at SQLite :memory: before each test).
"""
import os
from lib.db import get_db, reset_db_for_test
reset_db_for_test()
old_backend = os.environ.pop("VACUUM_WALL_DB_BACKEND", None)
old_path = os.environ.pop("VACUUM_WALL_DB_PATH", None)
old_seed = os.environ.pop("VACUUM_WALL_SEED_BUILTIN_ADMIN", None)
os.environ["VACUUM_WALL_DB_BACKEND"] = "sqlite"
os.environ["VACUUM_WALL_DB_PATH"] = ":memory:"
get_db() # triggers builtin-admin seed on the empty :memory: DB
yield
reset_db_for_test()
if old_backend is not None:
os.environ["VACUUM_WALL_DB_BACKEND"] = old_backend
if old_path is not None:
os.environ["VACUUM_WALL_DB_PATH"] = old_path
if old_seed is not None:
os.environ["VACUUM_WALL_SEED_BUILTIN_ADMIN"] = old_seed
@pytest.fixture
def access_token(db_reset):
"""Mint a real access token for the seeded builtin admin."""
from lib.auth import generate_tokens
tokens = generate_tokens("admin", {"firewall": "rw"})
return tokens["access_token"]
class TestWsSnapshot:
def test_snapshot_sent_on_auth_connect(self, access_token):
"""A valid JWT subprotocol yields a full snapshot after auth."""
import asyncio
import daemon.server as server
store = State()
store.set("firewall", {"zones": {"public": {}}})
store.set("system", {"load": {"load1": 0.1}})
# Remaining subsystems stay None (not populated).
ws = AsyncMock()
ws.prepare = AsyncMock()
ws.send_json = AsyncMock()
request = MagicMock()
request.headers = {"Sec-WebSocket-Protocol": access_token}
with (
patch("aiohttp.web.WebSocketResponse", return_value=ws),
patch.object(server, "state_store", store),
):
asyncio.run(server._handle_ws(request))
ws.send_json.assert_awaited_once()
payload = ws.send_json.call_args[0][0]
assert payload["type"] == "snapshot"
data = payload["data"]
# Every subsystem key is present (push-stream: no `init`/`versions` shape).
for name in State.SUBSYSTEMS:
assert name in data
assert data["firewall"] == {"zones": {"public": {}}}
assert data["system"] == {"load": {"load1": 0.1}}
# Unpopulated subsystems are present but None (partial snapshot).
assert data["dnsmasq"] is None
assert data["wireguard"] is None
def test_no_snapshot_without_token(self):
"""Missing token -> 401 JSON, no WS is opened, no snapshot sent."""
import asyncio
import daemon.server as server
ws = AsyncMock()
request = MagicMock()
request.headers = {}
with patch("aiohttp.web.WebSocketResponse") as mock_ctor:
result = asyncio.run(server._handle_ws(request))
assert result.status == 401
mock_ctor.assert_not_called()
ws.send_json.assert_not_awaited()
def test_no_snapshot_on_invalid_token(self, access_token):
"""A token that fails validation -> 401, no snapshot sent."""
import asyncio
import daemon.server as server
ws = AsyncMock()
request = MagicMock()
request.headers = {"Sec-WebSocket-Protocol": access_token}
with (
patch("aiohttp.web.WebSocketResponse") as mock_ctor,
patch("lib.auth.validate_token", return_value=None),
):
result = asyncio.run(server._handle_ws(request))
assert result.status == 401
mock_ctor.assert_not_called()
ws.send_json.assert_not_awaited()
View File
-8326
View File
File diff suppressed because it is too large Load Diff
Vendored Symlink
+1
View File
@@ -0,0 +1 @@
acme-3.1.3.sh
-4
View File
@@ -1,4 +0,0 @@
// htm mini (no caching) — https://github.com/developit/htm
// Vendored from: htm@3.1.1/mini/index.module.js
// License: Apache-2.0
export default function(n){for(var l,e,s=arguments,t=1,r="",u="",a=[0],c=function(n){1===t&&(n||(r=r.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?a.push(n?s[n]:r):3===t&&(n||r)?(a[1]=n?s[n]:r,t=2):2===t&&"..."===r&&n?a[2]=Object.assign(a[2]||{},s[n]):2===t&&r&&!n?(a[2]=a[2]||{})[r]=!0:t>=5&&(5===t?((a[2]=a[2]||{})[e]=n?r?r+s[n]:s[n]:r,t=6):(n||r)&&(a[2][e]+=n?r+s[n]:r)),r=""},h=0;h<n.length;h++){h&&(1===t&&c(),c(h));for(var i=0;i<n[h].length;i++)l=n[h][i],1===t?"<"===l?(c(),a=[a,"",null],t=3):r+=l:4===t?"--"===r&&">"===l?(t=1,r=""):r=l+r[0]:u?l===u?u="":r+=l:'"'===l||"'"===l?u=l:">"===l?(c(),t=1):t&&("="===l?(t=5,e=r,r=""):"/"===l&&(t<5||">"===n[h][i+1])?(c(),3===t&&(a=a[0]),t=a,(a=a[0]).push(this.apply(null,t.slice(1))),t=0):" "===l||"\t"===l||"\n"===l||"\r"===l?(c(),t=2):r+=l),3===t&&"!--"===r&&(t=4,a=a[0])}return c(),a.length>2?a.slice(1):a[1]}
Vendored Symlink
+1
View File
@@ -0,0 +1 @@
htm-3.1.1.js
+405
View File
@@ -0,0 +1,405 @@
"""Authentication API blueprint.
Exposed at /api/auth/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
import logging
from flask import Blueprint, request
from daemon.client import Conflict, delete, get, post
from daemon.iface import (
DELETE_AUTH_USER,
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
GET_AUTH_SESSION,
GET_AUTH_USERS,
GET_AUTH_WEBAUTHN_CAPABLE,
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
GET_AUTH_WEBAUTHN_CREDENTIALS,
POST_AUTH_LOGIN,
POST_AUTH_LOGOUT,
POST_AUTH_PASSWORD,
POST_AUTH_REFRESH,
POST_AUTH_USER_CREATE,
POST_AUTH_USER_UPDATE,
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("auth", __name__)
@bp.route("/login", methods=["POST"])
def login():
"""Authenticate user with username and password.
Endpoint:
POST /api/auth/login
Body:
{ "username": "admin", "password": "secretpass" }
Returns:
{ "tokens": { "access_token": "...", "refresh_token": "..." },
"user": { "id": 1, "username": "admin" },
"permissions": { ... } }
"""
try:
body = request.get_json(silent=True) or {}
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
return _ok(post(POST_AUTH_LOGIN, body))
except Exception as exc:
logger.error("Login failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/logout", methods=["POST"])
def logout():
"""Invalidate current session by blacklisting access and refresh tokens.
Endpoint:
POST /api/auth/logout
Body:
{ "refresh_token": "..." } -- client-provided refresh token
Returns:
{ "ok": true }
"""
try:
client_body = request.get_json(silent=True) or {}
body = {
**(request._user_ctx or {}),
"refresh_token": client_body.get("refresh_token"),
}
return _ok(post(POST_AUTH_LOGOUT, body))
except RuntimeError as exc:
logger.error("Logout failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/refresh", methods=["POST"])
def refresh():
"""Rotate tokens using a refresh token.
Endpoint:
POST /api/auth/refresh
Body:
{ "refresh_token": "..." }
Returns:
{ "tokens": { ... }, "user": { ... }, "permissions": { ... } }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_REFRESH, body))
except Exception as exc:
logger.error("Token refresh failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/session", methods=["GET"])
def session():
"""Return current user session info.
Endpoint:
GET /api/auth/session
Returns:
{ "user": { ... }, "permissions": { ... } }
"""
try:
return _ok(get(GET_AUTH_SESSION, {**(request._user_ctx or {})}))
except Exception as exc:
logger.error("Session check failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/password", methods=["POST"])
def change_password():
"""Change own password.
Endpoint:
POST /api/auth/password
Body:
{ "oldPassword": "...", "newPassword": "..." }
Returns:
{ "ok": true }
"""
try:
body = request.get_json(silent=True) or {}
user_ctx = getattr(request, "_user_ctx", None)
if user_ctx is not None:
body["username"] = user_ctx["username"]
return _ok(post(POST_AUTH_PASSWORD, body))
except Exception as exc:
logger.error("Password change failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/users", methods=["GET"])
def list_users():
"""List all users.
Endpoint:
GET /api/auth/users
Returns:
{ "users": [{ "id": 1, "username": "...", "permissions": { ... }, ... }] }
"""
try:
return _ok(get(GET_AUTH_USERS))
except RuntimeError as exc:
logger.error("List users failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/users", methods=["POST"])
def create_user():
"""Create a new user.
Endpoint:
POST /api/auth/users
Body:
{ "username": "...", "password": "...", "permissions": { ... } }
Returns:
{ "ok": true, "id": ..., "username": "...", "permissions": { ... } }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_USER_CREATE, body))
except Conflict as exc:
return _error(str(exc), 409)
except Exception as exc:
logger.error("Create user failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/users/<username>", methods=["POST"])
def update_user(username: str):
"""Update user permissions.
Endpoint:
POST /api/auth/users/<username>
Body:
{ "permissions": { ... } }
Returns:
{ "ok": true, "id": ..., "username": "..." }
"""
try:
body = {**(request.get_json(silent=True) or {}), "username": username}
return _ok(post(POST_AUTH_USER_UPDATE, body))
except Exception as exc:
err = str(exc)
status = 404 if "not found" in err.lower() else 400
logger.error("Update user failed: %s", exc)
return _error(err, status)
@bp.route("/users/<username>", methods=["DELETE"])
def delete_user(username: str):
"""Delete a user.
Endpoint:
DELETE /api/auth/users/<username>
Returns:
{ "ok": true }
"""
# Prevent self-deletion
user_ctx = getattr(request, "_user_ctx", None)
if user_ctx is not None and user_ctx.get("username") == username:
return _error("Cannot delete your own account", 403)
try:
return _ok(delete(DELETE_AUTH_USER, {"username": username}))
except Exception as exc:
err = str(exc)
status = 404 if "not found" in err.lower() else 400
logger.error("Delete user failed: %s", exc)
return _error(err, status)
# ---------------------------------------------------------------------------
# WebAuthn routes
# ---------------------------------------------------------------------------
def _resolve_webauthn_origin() -> tuple[str, str]:
"""Extract WebAuthn origin and rp_id from the current request.
Returns (origin, rp_id) derived from the actual request, falling back
to config values when the request metadata is unavailable.
"""
scheme = request.headers.get("X-Forwarded-Proto", request.scheme)
host = request.headers.get("X-Forwarded-Host", request.host.split(":")[0])
origin = f"{scheme}://{host}"
# rp_id is the registered domain (strip port numbers)
rp_id = host.split(":")[0]
return origin, rp_id
@bp.route("/webauthn/capable", methods=["GET"])
def webauthn_capable():
"""Check if WebAuthn is available on the current request domain.
Endpoint:
GET /api/auth/webauthn/capable
Returns:
{ "enabled": true/false, "rp_id": "...", "rp_name": "...", "origin": "..." }
or { "enabled": false, "reason": "..." }
"""
try:
origin, rp_id = _resolve_webauthn_origin()
body = {"webauthn_origin": origin, "webauthn_rp_id": rp_id}
return _ok(get(GET_AUTH_WEBAUTHN_CAPABLE, body))
except Exception as exc:
logger.error("WebAuthn capable check failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/webauthn/register-begin", methods=["POST"])
def webauthn_register_begin():
"""Begin WebAuthn registration.
Endpoint:
POST /api/auth/webauthn/register-begin
Returns:
Registration options for navigator.credentials.create()
"""
try:
body = request.get_json(silent=True) or {}
user_ctx = getattr(request, "_user_ctx", None)
if user_ctx is not None:
body["username"] = user_ctx["username"]
origin, rp_id = _resolve_webauthn_origin()
body["webauthn_origin"] = origin
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_BEGIN, body))
except Exception as exc:
logger.error("WebAuthn register begin failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/webauthn/register-finish", methods=["POST"])
def webauthn_register_finish():
"""Finish WebAuthn registration.
Endpoint:
POST /api/auth/webauthn/register-finish
Body:
{ "credential_response": {...}, "registration_options": {...}, "name": "..." }
Returns:
{ "ok": true, "credential": {...} }
"""
try:
body = request.get_json(silent=True) or {}
user_ctx = getattr(request, "_user_ctx", None)
if user_ctx is not None:
body["username"] = user_ctx["username"]
origin, rp_id = _resolve_webauthn_origin()
body["webauthn_origin"] = origin
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_FINISH, body))
except Exception as exc:
logger.error("WebAuthn register finish failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/webauthn/authenticate-begin", methods=["POST"])
def webauthn_authenticate_begin():
"""Begin WebAuthn authentication (public endpoint).
Endpoint:
POST /api/auth/webauthn/authenticate-begin
Body:
{ "username": "..." }
Returns:
Authentication options for navigator.credentials.get()
or { "no_webauthn": true } if user has no credentials.
"""
try:
body = request.get_json(silent=True) or {}
_, rp_id = _resolve_webauthn_origin()
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, body))
except Exception as exc:
logger.error("WebAuthn authenticate begin failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/webauthn/authenticate-finish", methods=["POST"])
def webauthn_authenticate_finish():
"""Finish WebAuthn authentication (public endpoint).
Endpoint:
POST /api/auth/webauthn/authenticate-finish
Body:
{ "username": "...", "assertion_response": {...}, "auth_options": {...} }
Returns:
{ "tokens": {...}, "user": {...}, "permissions": {...} }
"""
try:
body = request.get_json(silent=True) or {}
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
origin, rp_id = _resolve_webauthn_origin()
body["webauthn_origin"] = origin
body["webauthn_rp_id"] = rp_id
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body))
except Exception as exc:
logger.error("WebAuthn authenticate finish failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/webauthn/credentials", methods=["GET"])
def webauthn_credentials_list():
"""List registered WebAuthn credentials.
Endpoint:
GET /api/auth/webauthn/credentials
Returns:
{ "credentials": [...] }
"""
try:
return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIALS, {**(request._user_ctx or {})}))
except Exception as exc:
logger.error("List WebAuthn credentials failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/webauthn/credential-counts", methods=["GET"])
def webauthn_credential_counts():
"""Return credential counts for all users.
Admin endpoint returns a dict mapping usernames to credential counts.
Endpoint:
GET /api/auth/webauthn/credential-counts
Returns:
{ "counts": { "username": 2, ... } }
"""
try:
return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS))
except RuntimeError as exc:
logger.error("List credential counts failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/webauthn/creds/<credential_id>", methods=["DELETE"])
def webauthn_remove_credential(credential_id: str):
"""Remove a WebAuthn credential.
Endpoint:
DELETE /api/auth/webauthn/creds/<credential_id>
Returns:
{ "ok": true }
"""
try:
return _ok(
delete(
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
{**(request._user_ctx or {}), "credential_id": credential_id},
)
)
except Exception as exc:
err = str(exc)
status = 404 if "not found" in err.lower() else 400
logger.error("Remove WebAuthn credential failed: %s", exc)
return _error(err, status)
+32 -8
View File
@@ -15,6 +15,7 @@ from daemon.iface import (
GET_ACME_INFO,
GET_ACME_ISSUE_STATUS,
GET_ACME_LIST,
GET_ACME_RENEW_STATUS,
POST_ACME_ACCOUNT_REGISTER,
POST_ACME_EMAIL,
POST_ACME_ISSUE,
@@ -144,19 +145,21 @@ def issue_status(request_id: str):
@bp.route("/<domain>/renew", methods=["POST"])
def renew_bp(domain: str):
"""POST /api/certs/<domain>/renew — renew an existing certificate.
Args:
domain: Domain name whose certificate should be renewed.
"""POST /api/certs/<domain>/renew — start an (async) certificate renewal.
Returns:
Response confirming renewal or an error message.
Response containing a renewal request ID (poll it at
``/api/certs/renew/<request_id>``) or an error message.
"""
try:
logger.info("Certificate renewal requested for '%s' via API", domain)
post(POST_ACME_RENEW, {"domain": domain})
logger.info("Certificate renewed for '%s'", domain)
return _ok(None)
result = post(POST_ACME_RENEW, {"domain": domain})
logger.info(
"Certificate renewal started for '%s' (id=%s)",
domain,
result.get("request_id"),
)
return _ok(result)
except BadRequest as exc:
logger.info("Cert renew for '%s' rejected: %s", domain, exc)
return _error(str(exc), 400)
@@ -165,6 +168,27 @@ def renew_bp(domain: str):
return _error(str(exc), 500)
@bp.route("/renew/<request_id>", methods=["GET"])
def renew_status(request_id: str):
"""GET /api/certs/renew/<request_id> — poll status of a certificate renewal.
Args:
request_id: Renewal request identifier returned by renew_bp.
Returns:
Response containing renewal status or an error message.
"""
try:
result = get(GET_ACME_RENEW_STATUS, {"id": request_id})
return _ok(result)
except NotFound as exc:
logger.info("Renewal request '%s' not found: %s", request_id, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to get renewal status for '%s': %s", request_id, exc)
return _error(str(exc), 500)
@bp.route("/<domain>", methods=["DELETE"])
def remove_bp(domain: str):
"""DELETE /api/certs/<domain> — remove a certificate from ACME management.
+31
View File
@@ -19,6 +19,7 @@ from daemon.iface import (
POST_DNSMASQ_APPLY,
POST_DNSMASQ_CONFIG,
POST_DNSMASQ_DNS_RECORD_ADD,
POST_DNSMASQ_DOMAIN,
POST_DNSMASQ_RANGES_ADD,
POST_DNSMASQ_STATIC_LEASE_ADD,
)
@@ -306,6 +307,36 @@ def add_dns_record_bp():
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# DNS domain
# ---------------------------------------------------------------------------
@bp.route("/domain", methods=["POST"])
def set_domain_bp():
"""POST /api/dhcp/domain — Set or clear the DNS search domain.
Args:
request: JSON body with `domain` field (string or null to clear).
Returns:
JSON response with success status or an error.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
post(POST_DNSMASQ_DOMAIN, {"domain": body.get("domain")})
logger.info("DNS domain updated via API: %s", body.get("domain"))
return _ok(None)
except BadRequest as exc:
logger.info("Set DNS domain rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to set DNS domain: %s", exc)
return _error(str(exc), 500)
@bp.route("/dns-record/<name>", methods=["DELETE"])
def remove_dns_record_bp(name):
"""DELETE /api/dhcp/dns-record/<name> — Remove a DNS record by name.
+128 -38
View File
@@ -7,13 +7,17 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_NGINX_BACKENDS_REMOVE,
DELETE_NGINX_DOMAINS_REMOVE,
GET_NGINX_BACKENDS,
GET_NGINX_CONFIG,
GET_NGINX_DOMAINS,
PATCH_NGINX_BACKENDS,
PATCH_NGINX_CONFIG,
POST_NGINX_APPLY,
POST_NGINX_BACKENDS_ADD,
POST_NGINX_CONFIG,
POST_NGINX_DOMAINS_ADD,
POST_NGINX_DOMAINS_UPDATE,
@@ -135,23 +139,16 @@ def list_domains():
@bp.route("/domains", methods=["POST"])
def add_domain_bp():
"""Add a new proxy domain.
"""Add a new proxy domain referencing a backend.
POST /api/proxy/domains
Body fields (paths mode):
Body fields:
domain: Domain name.
paths: Path-to-config map (e.g. ``{"/": {"backend": {...}}, "/app": {...}}``).
backend: Backend name to proxy through.
cert: Optional certificate type.
force_ssl: Optional SSL redirect flag (default ``true``).
Body fields (legacy mode):
domain: Domain name.
backend_host: Upstream host.
backend_port: Upstream port.
backend_proto: Protocol (``http`` or ``https``, default ``http``).
cert: Optional certificate type.
extra_headers: Optional extra headers dict.
auth: Optional domain-level auth override.
Returns:
``{"domain": ...}`` on success.
@@ -160,33 +157,20 @@ def add_domain_bp():
domain = body.get("domain", "").strip()
if not domain:
return _error("'domain' is required", 400)
backend = body.get("backend", "").strip()
if not backend:
return _error("'backend' is required", 400)
payload = {
"domain": domain,
"backend": backend,
"force_ssl": body.get("force_ssl", True),
}
if body.get("cert") is not None:
payload["cert"] = body["cert"]
if body.get("auth") is not None:
payload["auth"] = body["auth"]
paths = body.get("paths")
if paths is not None:
payload = {
"domain": domain,
"paths": paths,
"cert": body.get("cert"),
"force_ssl": body.get("force_ssl", True),
}
else:
backend_host = body.get("backend_host", "").strip()
backend_port = body.get("backend_port")
backend_proto = body.get("backend_proto", "http").strip() or "http"
cert = body.get("cert")
extra_headers = body.get("extra_headers")
if not backend_host:
return _error("'backend_host' is required", 400)
if backend_port is None:
return _error("'backend_port' is required", 400)
payload = {
"domain": domain,
"backend_host": backend_host,
"backend_port": int(backend_port),
"backend_proto": backend_proto,
"cert": cert,
"extra_headers": extra_headers,
}
try:
post(POST_NGINX_DOMAINS_ADD, payload)
logger.info("Proxy domain added via API: %s", domain)
@@ -285,3 +269,109 @@ def test_bp():
except RuntimeError as exc:
logger.error("nginx config test failed: %s", exc)
return _error(str(exc), 500)
# ---------------------------------------------------------------------------
# Backend CRUD
# ---------------------------------------------------------------------------
@bp.route("/backends", methods=["GET"])
def list_backends():
"""List all configured backends.
GET /api/proxy/backends
Returns:
Dict of backend configs with secrets stripped.
"""
try:
return _ok(get(GET_NGINX_BACKENDS))
except RuntimeError as exc:
logger.error("Failed to list backends: %s", exc)
return _error(str(exc), 500)
@bp.route("/backends", methods=["PATCH"])
def patch_backend_bp():
"""Partially update a backend entry.
PATCH /api/proxy/backends
Body fields:
name: Backend name.
label: Optional new label.
paths: Optional new paths dict.
auth: Optional new auth config.
Returns:
``{"backend": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
try:
patch(PATCH_NGINX_BACKENDS, body)
logger.info("Backend '%s' patched via API", body.get("name"))
return _ok({"backend": body.get("name")})
except BadRequest as exc:
logger.info("Backend patch rejected: %s", exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to patch backend: %s", exc)
return _error(str(exc), 500)
@bp.route("/backends", methods=["POST"])
def add_backend_bp():
"""Add a new backend.
POST /api/proxy/backends
Body fields:
name: Backend name (slug, unique).
label: Human-readable label.
paths: Path-to-config map.
auth: Optional auth config.
Returns:
``{"backend": ...}`` on success.
"""
body = request.get_json(silent=True) or {}
name = body.get("name", "").strip()
if not name:
return _error("'name' is required", 400)
try:
post(POST_NGINX_BACKENDS_ADD, body)
logger.info("Backend added via API: %s", name)
return _ok({"backend": name})
except BadRequest as exc:
logger.info("Add backend '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except RuntimeError as exc:
logger.error("Failed to add backend '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/backends/<name>", methods=["DELETE"])
def remove_backend_bp(name):
"""Remove a non-builtin backend.
DELETE /api/proxy/backends/<name>
Returns:
``{"backend": ...}`` on success.
"""
try:
delete(DELETE_NGINX_BACKENDS_REMOVE, {"name": name})
logger.info("Backend removed via API: %s", name)
return _ok({"backend": name})
except BadRequest as exc:
logger.info("Remove backend '%s' rejected: %s", name, exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Remove backend '%s' conflict: %s", name, exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to remove backend '%s': %s", name, exc)
return _error(str(exc), 500)
+109
View File
@@ -0,0 +1,109 @@
"""Aggregate status API blueprint.
Exposed at /api/status/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
import logging
from flask import Blueprint, request
from daemon.client import get, post
from daemon.iface import (
GET_STATUS_PENDING,
GET_SYSTEM_METRICS,
POST_STATUS_APPLY_ALL,
POST_STATUS_CANCEL_ALL,
POST_STATUS_REFRESH,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("status", __name__)
@bp.route("/pending", methods=["GET"])
def pending():
"""Retrieve aggregate pending changes across all subsystems.
Endpoint:
GET /api/status/pending
Returns:
JSON response with per-subsystem pending status and total change count.
"""
try:
return _ok(get(GET_STATUS_PENDING))
except RuntimeError as exc:
logger.error("Failed to get pending status: %s", exc)
return _error(str(exc), 500)
@bp.route("/apply-all", methods=["POST"])
def apply_all():
"""Apply pending changes for all subsystems in dependency order.
Endpoint:
POST /api/status/apply-all
Returns:
JSON response with applied subsystems list and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_APPLY_ALL))
except RuntimeError as exc:
logger.error("Failed to apply all pending changes: %s", exc)
return _error(str(exc), 500)
@bp.route("/cancel-all", methods=["POST"])
def cancel_all():
"""Revert pending changes for all subsystems to the last applied config.
Endpoint:
POST /api/status/cancel-all
Returns:
JSON response with the reverted subsystems, skipped subsystems
(label -> reason), and any errors encountered.
"""
try:
return _ok(post(POST_STATUS_CANCEL_ALL))
except RuntimeError as exc:
logger.error("Failed to cancel all pending changes: %s", exc)
return _error(str(exc), 500)
@bp.route("/refresh", methods=["POST"])
def refresh():
"""Re-collect state from the daemon, optionally filtered by subsystem.
Endpoint:
POST /api/status/refresh
Body:
{"subsystems": ["firewall"]} or {} for all.
"""
body = request.get_json(silent=True) or {}
try:
return _ok(post(POST_STATUS_REFRESH, body))
except RuntimeError as exc:
logger.error("Failed to refresh state: %s", exc)
return _error(str(exc), 500)
@bp.route("/system-metrics", methods=["GET"])
def system_metrics():
"""Retrieve system-wide metrics.
Endpoint:
GET /api/status/system-metrics
Returns:
JSON response with CPU load, memory usage, and network traffic stats.
"""
try:
return _ok(get(GET_SYSTEM_METRICS))
except RuntimeError as exc:
logger.error("Failed to get system metrics: %s", exc)
return _error(str(exc), 500)
+164 -1
View File
@@ -7,15 +7,23 @@ import logging
from flask import Blueprint, request
from daemon.client import BadRequest, NotFound, delete, get, patch, post
from daemon.client import BadRequest, Conflict, NotFound, delete, get, patch, post
from daemon.iface import (
DELETE_WIREGUARD_CLASSES,
DELETE_WIREGUARD_CLASSES_DOWN,
DELETE_WIREGUARD_PEERS_REMOVE,
GET_WIREGUARD_CLASS_STATUS,
GET_WIREGUARD_CLASSES,
GET_WIREGUARD_CONFIG,
GET_WIREGUARD_PEER_STATUS,
GET_WIREGUARD_PEERS,
GET_WIREGUARD_STATUS,
PATCH_WIREGUARD_CLASSES,
PATCH_WIREGUARD_CONFIG,
POST_WIREGUARD_APPLY,
POST_WIREGUARD_CLASS_INIT_KEYS,
POST_WIREGUARD_CLASSES,
POST_WIREGUARD_CLASSES_UP,
POST_WIREGUARD_CONFIG,
POST_WIREGUARD_DOWN,
POST_WIREGUARD_GENERATE_CLIENT,
@@ -229,6 +237,8 @@ def add_peer_bp():
"allowed_ips": body.get("allowed_ips", []),
"persistent_keepalive": body.get("persistent_keepalive"),
"preshared_key": body.get("preshared_key"),
"description": body.get("description"),
"access_class": body.get("access_class"),
},
)
logger.info("WireGuard peer '%s' added via API", name)
@@ -337,3 +347,156 @@ def generate_client_bp():
except RuntimeError as exc:
logger.error("Failed to generate client config for '%s': %s", name, exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["GET"])
def list_classes_bp():
"""List all access classes.
Endpoint: GET /api/wireguard/classes
"""
try:
return _ok(get(GET_WIREGUARD_CLASSES))
except RuntimeError as exc:
logger.error("Failed to list access classes: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["POST"])
def create_class_bp():
"""Create a new access class.
Endpoint: POST /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = post(POST_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Create access class rejected: %s", exc)
return _error(str(exc), 400)
except Conflict as exc:
logger.info("Create access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to create access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["PATCH"])
def update_class_bp():
"""Update an access class.
Endpoint: PATCH /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = patch(PATCH_WIREGUARD_CLASSES, body)
return _ok(result)
except BadRequest as exc:
logger.info("Update access class rejected: %s", exc)
return _error(str(exc), 400)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to update access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes", methods=["DELETE"])
def delete_class_bp():
"""Delete an access class.
Endpoint: DELETE /api/wireguard/classes
"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _error("Request body must be a JSON object", 400)
key = body.get("key", "").strip()
if not key:
return _error("'key' is required", 400)
try:
result = delete(DELETE_WIREGUARD_CLASSES, {"key": key})
logger.info("Access class '%s' deleted via API", key)
return _ok(result)
except NotFound as exc:
logger.info("Access class not found: %s", exc)
return _error(str(exc), 404)
except Conflict as exc:
logger.info("Delete access class conflict: %s", exc)
return _error(str(exc), 409)
except RuntimeError as exc:
logger.error("Failed to delete access class: %s", exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/up", methods=["POST"])
def class_up_bp(key):
"""Bring up a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/up
"""
try:
post(POST_WIREGUARD_CLASSES_UP, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought up via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring up class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/down", methods=["POST"])
def class_down_bp(key):
"""Bring down a single access class's WireGuard tunnel.
Endpoint: POST /api/wireguard/classes/<key>/down
"""
try:
delete(DELETE_WIREGUARD_CLASSES_DOWN, {"class_key": key})
logger.info("WireGuard class '%s' tunnel brought down via API", key)
return _ok(None)
except RuntimeError as exc:
logger.error("Failed to bring down class '%s': %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/<key>/status", methods=["GET"])
def class_status_bp(key):
"""Get status for a single access class's tunnel.
Endpoint: GET /api/wireguard/classes/<key>/status
"""
try:
return _ok(get(GET_WIREGUARD_CLASS_STATUS, {"class_key": key}))
except RuntimeError as exc:
logger.error("Failed to get class '%s' status: %s", key, exc)
return _error(str(exc), 500)
@bp.route("/classes/keys/<key>", methods=["POST"])
def class_init_keys_bp(key):
"""Generate key pair for a single access class.
Endpoint: POST /api/wireguard/classes/keys/<key>
"""
try:
post(POST_WIREGUARD_CLASS_INIT_KEYS, {"class_key": key})
logger.info("WireGuard class '%s' keys generated via API", key)
return _ok(None)
except NotFound as exc:
logger.info("Class '%s' not found for keys: %s", key, exc)
return _error(str(exc), 404)
except RuntimeError as exc:
logger.error("Failed to generate keys for class '%s': %s", key, exc)
return _error(str(exc), 500)
+145 -28
View File
@@ -14,18 +14,20 @@ import sys
import time
from pathlib import Path
from flask import Flask, abort, request
from flask import Flask, abort, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix
from daemon.client import get
from daemon.iface import GET_STATUS_ALL
from lib.auth import validate_token
from lib.db import get_db
from lib.logging import setup_logging
from webui.api.auth import bp as auth_bp
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.status import bp as status_bp
from webui.api.wireguard import bp as wireguard_bp
# ---------------------------------------------------------------------------
@@ -87,6 +89,9 @@ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
get_db()
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(network_bp, url_prefix="/api/network")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
@@ -94,8 +99,10 @@ app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
app.register_blueprint(wireguard_bp, url_prefix="/api/wireguard")
app.register_blueprint(logs_bp, url_prefix="/api/logs")
app.register_blueprint(status_bp, url_prefix="/api/status")
BLUEPRINTS = [
("auth", auth_bp),
("firewall", firewall_bp),
("network", network_bp),
("dhcp", dhcp_bp),
@@ -103,11 +110,125 @@ BLUEPRINTS = [
("certs", certs_bp),
("wireguard", wireguard_bp),
("logs", logs_bp),
("status", status_bp),
]
for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name)
# ── Public endpoints (no auth required) ──
_AUTH_EXEMPT = {
("GET", "/"),
("POST", "/api/auth/login"),
("POST", "/api/auth/refresh"),
("POST", "/api/auth/webauthn/authenticate-begin"),
("POST", "/api/auth/webauthn/authenticate-finish"),
}
# ── Personal auth routes (operates on own account, no subsystem permission needed) ──
# These routes require a valid JWT but do NOT require an "auth" permission entry.
# A user with only "firewall:read" can still view session, change password, logout, etc.
# Method-agnostic — covers all HTTP methods for future-proofing.
_AUTH_PERSONAL_PATHS = (
"/api/auth/session",
"/api/auth/password",
"/api/auth/logout",
)
_AUTH_PERSONAL_PREFIXES = (
"/api/auth/webauthn/register-",
"/api/auth/webauthn/credentials",
"/api/auth/webauthn/creds/",
)
def _subsystem_from_path(path: str) -> str | None:
"""Extract subsystem name from API path."""
if not path.startswith("/api/"):
return None
parts = path.split("/")
if len(parts) >= 3:
return parts[2]
return None
def _is_personal_auth(method: str, path: str) -> bool:
"""Check if route is a personal auth operation (no subsystem permission needed)."""
if path in _AUTH_PERSONAL_PATHS:
return True
return any(path.startswith(prefix) for prefix in _AUTH_PERSONAL_PREFIXES)
def _has_permission(perms: dict, subsystem: str, method: str) -> bool:
"""Check if user has permission for subsystem + method."""
level = perms.get(subsystem)
if method == "GET":
return level in ("read", "rw")
return level == "rw"
# ── JWT authentication middleware ──
@app.before_request
def _auth_middleware():
"""Validate JWT from Authorization header for API routes.
Exempts: static routes, vendor files, and public auth endpoints.
Attaches request._user_ctx with user info for downstream handlers.
"""
method = request.method
path = request.path
# Exempt specific paths
if (method, path) in _AUTH_EXEMPT:
return
if method == "GET" and path.startswith("/vendor/"):
return
if method in ("GET", "HEAD") and path.startswith("/static/"):
return
# For non-API routes, skip auth
if not path.startswith("/api/"):
return
# Extract token from Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"ok": False, "error": "unauthorized"}), 401
token_string = auth_header[7:] # strip "Bearer "
session_header = request.headers.get("X-Session-Id")
if not session_header:
return jsonify({"ok": False, "error": "unauthorized"}), 401
payload = validate_token(
token_string, token_type="access", session_id=session_header
)
if payload is None:
return jsonify({"ok": False, "error": "unauthorized"}), 401
username = payload.get("sub")
if not username:
return jsonify({"ok": False, "error": "unauthorized"}), 401
user_permissions = payload.get("permissions", {})
# Check subsystem permissions (skip personal auth routes)
subsystem = _subsystem_from_path(path)
if subsystem and not _is_personal_auth(method, path):
if subsystem not in user_permissions:
return jsonify({"ok": False, "error": "forbidden"}), 403
if not _has_permission(user_permissions, subsystem, method):
return jsonify({"ok": False, "error": "forbidden"}), 403
request._user_ctx = {
"username": username,
"permissions": user_permissions,
"jti": payload.get("jti"),
}
return
# ---------------------------------------------------------------------------
# Request logging
# ---------------------------------------------------------------------------
@@ -140,6 +261,26 @@ def _log_request_finish(response):
elapsed_ms,
)
# Content Security Policy — prevent inline script execution and XSS
# NOTE: connect-src 'self' is safe because all XHR/fetch/WS calls go through
# nginx on the same origin. If WS or API routing ever changes to use a
# different host/port directly, the CSP must be updated accordingly.
if "Content-Security-Policy" not in response.headers:
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# set X-Content-Type-Options to prevent MIME sniffing
response.headers["X-Content-Type-Options"] = "nosniff"
# Set cache headers: short in dev, long with staleness tolerance in prod
if response.content_type.startswith("text/html"):
# index.html: always short cache so browser revalidates
@@ -155,27 +296,6 @@ def _log_request_finish(response):
return response
# ---------------------------------------------------------------------------
# API proxy routes
# ---------------------------------------------------------------------------
@app.route("/api/status/all")
def api_status_all():
"""Return aggregated status from all subsystems.
Proxies the daemon's ``/status/all`` endpoint for SPA consumption.
Returns:
JSON response with state data for all subsystems.
"""
try:
return {"ok": True, "data": get(GET_STATUS_ALL)}
except Exception as exc:
logger.warning("Status all failed: %s", exc)
return {"ok": False, "error": str(exc)}, 500
# ---------------------------------------------------------------------------
# SPA entry point — serve index.html for /, 404 for everything else
# ---------------------------------------------------------------------------
@@ -187,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor"
@app.route("/")
def spa_root():
"""Serve the SPA entry point. No catch-all — client handles routing."""
scheme = "wss" if request.is_secure else "ws"
ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text()
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
return (SPA_DIR / "index.html").read_text()
@app.route("/vendor/<path:filename>")
+167 -127
View File
@@ -1,19 +1,24 @@
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=8';
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
import { SUBSYSTEMS } from '/static/hoover/schema.js';
import DashboardPage from '/static/pages/dashboard.js?v=8';
import InterfacesPage from '/static/pages/interfaces.js?v=8';
import ZonesPage from '/static/pages/zones.js?v=8';
import RulesPage from '/static/pages/rules.js?v=8';
import NatPage from '/static/pages/nat.js?v=8';
import DhcpPage from '/static/pages/dhcp.js?v=8';
import ProxyPage from '/static/pages/proxy.js?v=8';
import CertsPage from '/static/pages/certs.js?v=8';
import WireguardPage from '/static/pages/wireguard.js?v=8';
import LogsPage from '/static/pages/logs.js?v=8';
import NotFoundPage from '/static/pages/notfound.js?v=8';
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 Nav = [
const _NavBase = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/interfaces', label: 'Interfaces' },
{ path: '/zones', label: 'Zones' },
@@ -21,117 +26,73 @@ const Nav = [
{ 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: '/wireguard', label: 'WireGuard' },
{ path: '/logs', label: 'Logs' },
{ path: '/passkeys', label: 'Passkeys' },
];
/* ── Model registration ────────────────────────────────────── */
modelRegister('status', {
subsystem: 'status',
fetch: async () => {
const r = await apiFetch('/api/status/all');
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());
/* ── State-backed models ──────────────────────────────────── */
/* All state-backed models stream over the WS (snapshot on connect,
* per-subsystem deltas). The fetch below is the HTTP fallback: it hits
* POST /api/status/refresh with a subsystem filter and returns the
* subsystem state verbatim the exact shape the state store holds. */
function _stateModelFetch(subsystem) {
return async () => {
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: [subsystem] },
});
if (!r.ok) throw new Error(r.error);
return r.data;
},
});
const payload = r.data?.[subsystem];
// Collector failure: the daemon returns null for that subsystem.
// Throw instead of returning {} so modelFetch keeps the current
// data (schema defaults) and sets model.error rather than
// clobbering it with an empty object.
if (payload == null) throw new Error(subsystem + ': state not populated yet');
return payload;
};
}
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;
},
});
// Each maps to one subsystem in the state store. Model name may differ
// from subsystem name (e.g. `network` → `networkd`).
const STATE_MODELS = [
{ name: 'firewall', subsystem: 'firewall' },
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
{ name: 'nginx', subsystem: 'nginx' },
{ name: 'acme', subsystem: 'acme' },
{ name: 'wireguard', subsystem: 'wireguard' },
{ name: 'network', subsystem: 'networkd' },
{ name: 'system', subsystem: 'system' },
];
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: {} };
},
});
for (const { name, subsystem } of STATE_MODELS) {
modelRegister(name, {
subsystem,
defaultData: SUBSYSTEMS[subsystem].defaults,
fetch: _stateModelFetch(subsystem),
});
}
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', {
modelRegister('backends', {
subsystem: 'nginx',
fetch: async () => {
const r = await apiFetch('/api/proxy/domains');
const r = await apiFetch('/api/proxy/backends');
if (!r.ok) throw new Error(r.error);
return { domains: 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;
return r.data || {};
},
});
@@ -155,14 +116,26 @@ modelRegister('logs', {
},
});
/* ── Initial fetch ─────────────────────────────────────────── */
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
modelFetch(name);
/* ── Initial fetch (after auth check) ───────────────────────── */
function fetchInitialData() {
// State-backed models: first data arrives via the WS snapshot.
// If WS hasn't delivered data within 3s, fall back to HTTP.
for (const { name } of STATE_MODELS) {
setTimeout(() => {
const model = getModel(name);
if (model.loading) { // snapshot (or a prior fetch) hasn't completed
modelFetch(name);
}
}, 3000);
}
// Non-state models fetch immediately
modelFetch('backends');
modelFetch('logs', 'journal');
}
modelFetch('logs', 'journal');
/* ── Page map ──────────────────────────────────────────────── */
const Pages = {
login: LoginPage,
dashboard: DashboardPage,
interfaces: InterfacesPage,
zones: ZonesPage,
@@ -170,33 +143,62 @@ const Pages = {
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' }),
component() {
const name = this.state.path.replace(/^\//, '');
const { path } = this.state;
// Auth guard: unauthenticated users see the login page for any
// protected route (manual hash entry, back/forward, runtime
// expiry). Reactive — the auth model's data mutation re-renders
// this function, so the real page appears the instant login
// completes.
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
return hComp(page, path);
},
};
// Set once the bootstrap session check settles (and implicitly on every
// later login/logout transition — isAuthenticated flips reactively). Until
// then the hashchange clamp below must NOT force unauthenticated hashes to
// #/login: a valid-session reload arrives with its 'check' still in flight,
// and clamping early would strand the user on login.
let authChecked = false;
window.location.hash || (window.location.hash = router.state.path);
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
const raw = location.hash.slice(1) || '/dashboard';
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
router.state.path = path;
// Keep the URL in sync with the clamped path (loop-safe: the follow-up
// hashchange lands on the already-clamped '/login').
if (location.hash.slice(1) !== path) location.hash = path;
});
/* ── Sidebar render root ───────────────────────────────────── */
function Sidebar() {
// No nav when logged out — unauthenticated users get the full-bleed
// login page. Reactive: the auth model's terminal transition (logout /
// session expiry) re-renders this root back to null.
if (!isAuthenticated()) return null;
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' : '',
@@ -216,17 +218,55 @@ function MainContent() {
}
/* ── Init ──────────────────────────────────────────────────── */
export function initApp() {
export async function initApp() {
// Listen for login events to update router state after auth
window.addEventListener('auth:login', () => {
// 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 mounting the shell: an unauthenticated visitor
// must never flash the sidebar or a protected page before the redirect
// to #/login lands.
await modelFetch('auth', { action: 'check' });
authChecked = true;
if (isAuthenticated()) {
// A session restored at bootstrap (or a reload) may leave the URL on
// #/login — the auth guard renders the login form for that hash even
// when authenticated. Bounce to the default page so a valid session
// never strands the user on a stale login screen.
if (router.state.path === '/login') {
window.location.hash = '/dashboard';
}
fetchInitialData();
setTimeout(connect, 0);
} else if (router.state.path !== '/login') {
// No valid session — redirect to login before the first paint.
window.location.hash = '/login';
}
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);
}
if (document.readyState === 'loading') {
+126 -45
View File
@@ -1,19 +1,41 @@
/**
* Hoover api.js
*
* JSON-friendly fetch wrapper with automatic header management.
* JSON-friendly fetch wrapper with automatic header management (JWT headers
* injected from the auth model) and 401 re-authentication.
* Toast notification system with auto-dismiss.
* ToastContainer component for rendering queued toasts.
* Modal processing guard for async form submissions.
*/
import { h } from './vdom.js?v=7';
import { modelFetch } from './model.js?v=7';
import { modelFetch } from './model.js';
import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
import { requestUpdate } from './reactivity.js';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js';
/**
* Public auth endpoints that may legitimately 401 (bad credentials) while a
* valid session exists elsewhere. 401 recovery (refresh retry logout)
* is skipped for these so a failed login doesn't tear down a live session.
*/
const _PUBLIC_AUTH_URLS = new Set([
'/api/auth/login',
'/api/auth/webauthn/authenticate-begin',
'/api/auth/webauthn/authenticate-finish',
]);
function _isPublicAuthUrl(url) {
return _PUBLIC_AUTH_URLS.has(String(url).split('?')[0]);
}
/**
* JSON-friendly fetch wrapper.
*
* Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }.
* Injects ``Authorization: Bearer`` and ``X-Session-Id`` headers when a
* token is present (read from the auth model). On 401 it runs the auth
* model's refresh once and retries; a still-401 retry drives the model to
* the terminal logout state (clears storage, redirects to login).
*
* @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, )
@@ -21,7 +43,16 @@ import { modelFetch } from './model.js?v=7';
*/
export async function apiFetch(url, options = {}) {
const { method = 'GET', body, ...opts } = options;
const headers = { 'Accept': 'application/json', ...opts.headers };
// Drop the caller's raw headers so the merged object (with the injected
// Authorization / X-Session-Id) always wins when spread into the fetch options.
const { headers: _callerHeaders, ...safeOpts } = opts;
const headers = { 'Accept': 'application/json', ..._callerHeaders };
const token = getAuthToken();
if (token) {
headers['Authorization'] = 'Bearer ' + token;
const auth = getAuthData();
if (auth?.session_id) headers['X-Session-Id'] = auth.session_id;
}
if (body && typeof body === 'object' && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
@@ -29,12 +60,33 @@ export async function apiFetch(url, options = {}) {
}
try {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
if (opts.signal?.aborted) {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts });
if (safeOpts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 };
}
if (res.status === 401) {
window.location.reload();
if (res.status === 401 && token && !_isPublicAuthUrl(url)) {
await refreshAuth();
const auth = getAuthData();
if (auth?.token) {
headers['Authorization'] = 'Bearer ' + auth.token;
headers['X-Session-Id'] = auth.session_id; // rotated — re-read from model
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...safeOpts });
if (retryRes.ok) {
const json = await retryRes.json().catch(() => null);
return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status };
}
if (retryRes.status === 401) {
// Refresh succeeded but the retry is still 401 — the session is
// dead. Drive the model to the terminal logout state; its
// onSuccess clears storage and redirects to #/login.
modelFetch('auth', { action: 'logout' });
return { ok: false, data: null, error: 'Session expired', status: 401 };
}
const json = await retryRes.json().catch(() => null);
return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status };
}
// No token after the refresh — onSuccess already cleared storage
// and redirected to #/login.
return { ok: false, data: null, error: 'Session expired', status: 401 };
}
const json = await res.json();
@@ -65,6 +117,7 @@ const _toastIds = { next: 1 };
export function toast(message, type = 'info', duration = 4000) {
const id = _toastIds.next++;
_toasts.push({ id, message, type, createdAt: Date.now(), duration });
requestUpdate();
if (duration > 0) setTimeout(() => dismissToast(id), duration);
return id;
@@ -76,26 +129,7 @@ export function toast(message, type = 'info', duration = 4000) {
export function dismissToast(id) {
const idx = _toasts.findIndex(t => t.id === id);
if (idx !== -1) _toasts.splice(idx, 1);
}
/**
* Render the queued toast notifications.
*
* @returns {VNode} Toast container (empty text node when no toasts)
*/
export function ToastContainer() {
if (!_toasts.length) return h('#text', '');
const clsMap = { info: 'toast-info', success: 'toast-success', error: 'toast-error', warning: 'toast-warning' };
return h('div', { class: 'toast-container' },
..._toasts.map(t =>
h('div', { class: `toast ${clsMap[t.type] || clsMap.info}`, 'on:click': () => dismissToast(t.id) },
h('span', null, t.message),
h('button', { class: 'toast-close', 'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); } }, '\u00d7'),
),
),
);
requestUpdate();
}
/**
@@ -197,6 +231,32 @@ export async function poll(opts) {
}, interval);
}
/**
* Centralized handler wrapper that encapsulates processing guard,
* processing state, error handling, and modal re-render.
*
* Used by any handler not using `apiSubmit`. The async function receives
* no arguments and should perform validation (via `throw`), API calls,
* success/error toasting, modal closing, and data refreshing.
*
* @param {function} fn Async handler function
* @returns {function} Wrapped handler
*/
export function formAction(fn) {
return async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
await fn();
} catch (e) {
toast(e.message || 'Failed', 'error');
} finally {
setModalProcessing(false);
refreshModals();
}
};
}
/**
* Generate action button descriptors for modal form submission.
*
@@ -208,8 +268,11 @@ export async function poll(opts) {
* @param {string} [opts.method] - HTTP method (default: 'POST')
* @param {function} [opts.body] - () => object, body builder
* @param {function} [opts.validate] - (body) => string|null, validation function
* @param {function} [opts.confirm] - (body) => string|null; if a message is
* returned, a native confirm() dialog gates
* the submit; on approval the body gains
* force=true (server-side guard override)
* @param {string} [opts.successMsg] - Success toast message
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
* @returns {object[]} Array of action descriptors
*/
@@ -219,8 +282,8 @@ export function apiSubmit(opts) {
method = 'POST',
body,
validate,
confirm,
successMsg = 'Saved',
refresh,
submitText = 'Submit',
closeModal,
} = opts;
@@ -230,22 +293,40 @@ export function apiSubmit(opts) {
label: submitText,
cls: 'btn-primary',
action: 's',
processing: true,
handler: async () => {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
toast(successMsg, 'success');
if (closeModal) closeModal();
if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh];
await Promise.all(models.map(m => modelFetch(m)));
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
} else {
toast(res.error || 'Failed', 'error');
if (confirm) {
const msg = confirm(b);
if (msg) {
if (!window.confirm(msg)) return;
b.force = true;
}
}
refreshModals();
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
const synced = res.data?.synced;
let msg = successMsg;
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
}
toast(msg, 'success');
if (closeModal) closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(res.error || 'Failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
},
},
+275
View File
@@ -0,0 +1,275 @@
/**
* Hoover auth_model.js
*
* First-class Hoover model for the token/session lifecycle. Single source
* of truth for:
* - token storage and retrieval (sessionStorage via internal helpers)
* - refresh scheduling (TTL timer) and execution
* - session validation ('check' action)
* - login/logout state transitions
* - WS reconnection coordination (refreshAuth for websocket.js)
*
* Registered at app bootstrap: modelRegister('auth', createAuthModel()).
*
* Invariants:
* - subsystem topic 'auth' is silent the daemon only broadcasts topics
* for its collectors and never emits 'auth', so refreshByTopic() cannot
* touch this model. Refresh is timer/401/WS-fail driven only.
* - fetch() uses vanilla fetch() never apiFetch preventing recursion
* (apiFetch 401 refreshAuth auth fetch apiFetch 401 ).
* - This module never imports api.js or websocket.js (would cycle:
* websocket.js imports the auth model's exports).
* - modelFetch() never rejects consumers branch on model state
* (getAuthToken / isAuthenticated), never on promise rejection.
*/
import { modelFetch, getModel } from './model.js';
/** Refresh timer handle. Not reactive — only set/cleared in lifecycle hooks. */
let _refreshTimer = null;
/** In-flight refresh guard — prevents concurrent refresh attempts (timer path). */
let _refreshing = false;
/** All-nulls data shape returned by the 'logout' action and used as the canonical
* "not authenticated" state. */
const EMPTY = { token: null, refresh: null, session_id: null, user: null, permissions: null, ttl: null };
/**
* Return the model definition object for `modelRegister('auth', ...)`.
* @returns {object} Hoover model definition
*/
export function createAuthModel() {
return {
// Silent topic — the daemon never broadcasts 'auth', so refreshByTopic()
// will never fetch this model. Refresh is timer/401/WS-fail driven only.
subsystem: 'auth',
defaultData: { ...EMPTY },
async fetch(signal, param) {
// Param-less calls are treated as 'check' (defensive; refreshByTopic
// never reaches this model thanks to subsystem: 'auth').
const action = param?.action || 'check';
if (action === 'check') {
const stored = readStorage();
if (!stored.access) return null; // no stored session
const r = await fetch('/api/auth/session', {
headers: {
'Accept': 'application/json',
'Authorization': 'Bearer ' + stored.access,
'X-Session-Id': stored.session_id || '',
},
credentials: 'same-origin',
});
if (!r.ok && stored.refresh) {
// Stale access token (e.g. page reload/restore: the in-memory
// TTL timer is gone and the token may have expired server-side,
// while the 7-day refresh token is still in sessionStorage) —
// attempt exactly one refresh before treating the session
// as dead. A failed refresh falls through to the terminal path.
return _doRefresh();
}
if (!r.ok) return null;
const json = await r.json();
if (!json.ok || !json.data?.user) return null;
// Server returns ONLY { user, permissions } — merge verified identity
// onto the stored token state.
return {
token: stored.access,
refresh: stored.refresh,
session_id: stored.session_id,
user: json.data.user,
permissions: json.data.permissions,
ttl: stored.ttl || 900 * 1000,
};
}
if (action === 'refresh') {
return _doRefresh();
}
if (action === 'login') {
const payload = param.payload;
// Defensive: successful logins always carry tokens — a malformed payload
// is treated as terminal (null → clear storage + redirect).
if (!payload?.tokens?.access_token) return null;
return {
token: payload.tokens.access_token,
refresh: payload.tokens.refresh_token,
session_id: payload.tokens.session_id,
user: payload.user,
permissions: payload.permissions,
ttl: (payload.access_ttl || 900) * 1000,
};
}
if (action === 'logout') {
// Intentional logout: fresh all-nulls object so model.data ends up in
// the canonical "not authenticated" state (isAuthenticated() → false).
return { ...EMPTY };
}
return null;
},
onSuccess(name, data, param) {
if (!data || !data.token) {
// Logout, failed check, or failed refresh — all terminal:
// clear storage, cancel timer, redirect if needed.
clearStorage();
if (_refreshTimer) { clearTimeout(_refreshTimer); _refreshTimer = null; }
// location.hash includes the '#' — compare against '#/login', not '/login'.
if (name === 'auth' && document.location.hash !== '#/login') {
document.location.hash = '/login';
}
// Terminal transition — notify the app to tear down session-scoped resources
// (the WS socket; the daemon validates it only at handshake, so it would
// otherwise stay open and be reused by a same-tab relogin). The model never
// imports websocket.js (would cycle), so teardown is event-driven — the
// listener lives in app.js (see Phases 4 and 6).
window.dispatchEvent(new CustomEvent('auth:logout'));
return;
}
writeStorage(data);
scheduleRefresh(data.ttl);
// Transition event — fires ONLY for the 'login' action. Rationale:
// * 'check' (bootstrap): initApp() already calls fetchInitialData()
// and connect() on the authenticated branch — firing the event too
// would double the work.
// * 'refresh' (TTL timer, apiFetch 401, WS fail×3): the session is
// already established; re-firing would re-run fetchInitialData()
// on every ~14-minute silent refresh.
// The app.js listener defers its path check to a macrotask (see
// Phase 6), so it runs after doLogin's hashchange has landed.
if (param?.action === 'login') {
window.dispatchEvent(new CustomEvent('auth:login', {
detail: { permissions: data.permissions },
}));
}
},
onFailure(name, error) {
// Silent — data stays as-is. Only reachable on a real throw (network error
// inside fetch()). The no-token branch above covers the normal failure path.
},
};
}
/**
* Rotate the token pair via POST /api/auth/refresh using the stored refresh
* token. Shared by the 'refresh' action and the 'check' 401 fallback.
* @returns {Promise<{token, refresh, session_id, user, permissions, ttl}|null>}
* The rotated token state, or null when the refresh token is missing,
* invalid, expired, or blacklisted.
*/
async function _doRefresh() {
const stored = readStorage();
if (!stored.refresh) return null;
const r = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
refresh_token: stored.refresh,
session_id: stored.session_id,
}),
});
if (!r.ok) return null;
const json = await r.json();
if (!json.ok || !json.data?.tokens) return null;
const t = json.data.tokens;
const prev = getModel('auth').data; // fallback for any field the server omits
// NOTE: the server mints a NEW session_id on every refresh — the rotated
// binding must win over `prev`.
return {
token: t.access_token,
refresh: t.refresh_token,
session_id: t.session_id,
user: json.data.user ?? prev?.user,
permissions: json.data.permissions ?? prev?.permissions,
ttl: json.data.access_ttl ? json.data.access_ttl * 1000 : (prev?.ttl || 900 * 1000),
};
}
/**
* Read stored token state from sessionStorage.
* @returns {{access: string|null, refresh: string|null, session_id: string|null, ttl: number|null}}
*/
function readStorage() {
return {
access: sessionStorage.getItem('vw:access'),
refresh: sessionStorage.getItem('vw:refresh'),
session_id: sessionStorage.getItem('vw:session_id'),
ttl: parseInt(sessionStorage.getItem('vw:access_ttl'), 10) || null,
};
}
/** Persist token state to sessionStorage. @param {object} data - auth model data */
function writeStorage(data) {
sessionStorage.setItem('vw:access', data.token);
sessionStorage.setItem('vw:refresh', data.refresh);
sessionStorage.setItem('vw:session_id', data.session_id);
sessionStorage.setItem('vw:access_ttl', String(data.ttl));
sessionStorage.setItem('vw:user', JSON.stringify(data.user));
sessionStorage.setItem('vw:permissions', JSON.stringify(data.permissions));
}
/** Remove all stored token state. */
function clearStorage() {
sessionStorage.removeItem('vw:access');
sessionStorage.removeItem('vw:refresh');
sessionStorage.removeItem('vw:session_id');
sessionStorage.removeItem('vw:access_ttl');
sessionStorage.removeItem('vw:user');
sessionStorage.removeItem('vw:permissions');
}
/**
* Schedule a silent refresh TTL 60s out (min 30s). Resets any pending timer.
* @param {number} [ttl] - access token TTL in ms
*/
function scheduleRefresh(ttl) {
if (_refreshTimer) clearTimeout(_refreshTimer);
const delay = Math.max((ttl || 900 * 1000) - 60000, 30000);
_refreshTimer = setTimeout(() => {
if (!_refreshing) {
_refreshing = true;
modelFetch('auth', { action: 'refresh' }).finally(() => { _refreshing = false; });
}
}, delay);
}
/**
* Current access token from the auth model.
* @returns {string|undefined}
*/
export function getAuthToken() {
return getModel('auth').data?.token;
}
/**
* Authenticated only when the model carries both a token and a user.
* @returns {boolean}
*/
export function isAuthenticated() {
const d = getModel('auth').data;
return !!(d && d.token && d.user);
}
/**
* Trigger a token refresh via the model.
* Resolves (never rejects) callers branch on getAuthToken() afterwards.
* modelFetch() returns undefined for unregistered models normalize to a
* resolved promise so the result is always a thenable (defensive: the auth
* model is registered at app bootstrap before any consumer can run).
* @returns {Promise<void>}
*/
export function refreshAuth() {
return Promise.resolve(modelFetch('auth', { action: 'refresh' }));
}
/**
* Whole auth model data object.
* @returns {{token, refresh, session_id, user, permissions, ttl}|null}
*/
export function getAuthData() {
return getModel('auth').data;
}
+16 -11
View File
@@ -15,9 +15,9 @@
* });
*/
import { reactive } from './reactivity.js?v=7';
import { h } from './vdom.js?v=7';
import { _compExpandedCache } from './render.js?v=7';
import { reactive } from './reactivity.js';
import { h } from './vdom.js';
import { _compExpandedCache } from './render.js';
/** Registry of mounted components: key → { state } */
const _mounted = new Map();
@@ -70,17 +70,19 @@ export function mountComponent(key, renderer) {
if (entry) {
// Re-mount: component already exists with its state.
// Don't re-run load — that re-render was triggered by a reactive update.
return;
// Abort previous in-flight load and re-run.
if (entry.abortController) entry.abortController.abort();
entry.abortController = null;
} else {
entry = { state: pd.state };
_mounted.set(key, entry);
pd.state.error = null;
}
entry = { state: pd.state };
_mounted.set(key, entry);
pd.state.error = null;
if (pd.load) {
Promise.resolve().then(() => pd.load(pd.state));
const abortController = new AbortController();
entry.abortController = abortController;
Promise.resolve().then(() => pd.load(pd.state, abortController));
}
}
@@ -94,6 +96,9 @@ export function unmountComponent(key, renderer) {
const pd = renderer._pageDef;
// Abort in-flight load requests so they don't mutate unmounted state
if (entry.abortController) entry.abortController.abort();
if (pd.onUnmount) {
try { pd.onUnmount(entry.state); } catch (_) {}
}
@@ -0,0 +1,230 @@
/**
* Hoover components/applyconfirm.js
*
* Apply button with cross-subsystem confirmation modal.
* Fetches pending changes from /api/status/pending, shows them in an
* expandable modal, then applies all via /api/status/apply-all.
*/
import { h } from '../vdom.js';
import { html } from '../html.js';
import { reactive } from '../reactivity.js';
import { apiFetch, toast } from '../api.js';
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
export const SUBSYSTEM_LIST = [
{ key: 'firewall', label: 'Firewall' },
{ key: 'dnsmasq', label: 'DHCP/DNS' },
{ key: 'nginx', label: 'Nginx' },
{ key: 'wireguard', label: 'WireGuard' },
{ key: 'networkd', label: 'Network' },
];
/**
* Extract pending state from a subsystem result.
* Handles firewall's `needs_apply` vs hash subsystems' `pending_changes`.
*/
export function isPending(ss) {
return (ss.needs_apply || ss.pending_changes || false);
}
/**
* Build the VNode array for modal rows given pending data and expanded state.
*/
export function buildRows(pendingData, expanded) {
const vnodeList = [];
for (const sub of SUBSYSTEM_LIST) {
const ss = pendingData[sub.key] || {};
const changes = ss.changes || [];
const hasPending = isPending(ss) && changes.length > 0;
const isExpanded = !!expanded[sub.key];
vnodeList.push(html`<div class="apply-subsystem-row${hasPending ? ' pending' : ''}">
<span class="apply-subsystem-name">${sub.label}</span>
<span class="apply-subsystem-status${hasPending ? ' pending' : ''}">${hasPending ? changes.length + ' pending changes' : 'Up to date'}</span>
${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : ''}">\u25B6</span>` : ''}
</div>`);
if (hasPending && isExpanded) {
vnodeList.push(html`<div class="apply-detail-section">${changes.map(c => html`<div class="apply-detail-item">${c.summary || c.detail || c}</div>`)}</div>`);
}
}
return vnodeList;
}
/**
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta no explicit refresh.
*/
async function doApply(successMsg) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/apply-all', { method: 'POST' });
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(resp.error || 'Apply failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
}
/**
* Fetch pending state, then open the confirmation modal.
*/
async function openApplyModal(successMsg) {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
return;
}
const pendingData = pendingResp.data || {};
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
openModal((inner) => {
const rows = buildRows(pendingData, expanded);
if (totalChanges === 0) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="apply-no-changes">No pending changes to apply.</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button></div>
</div>`);
return;
}
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
</div>`);
});
}
/**
* Apply button with cross-subsystem confirmation modal.
*
* @param {object} props
* @param {boolean} props.pending - Whether any subsystem has pending changes
* @param {string} [props.label] - Apply button text (default: 'Apply')
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
*/
export function ApplyConfirm(props = {}) {
const label = props.label || 'Apply';
const syncedLabel = props.syncedLabel || 'Synced';
const successMsg = props.successMsg || 'All changes applied';
return h('button', {
class: props.cls !== undefined
? props.cls
: (props.pending ? 'btn btn-primary' : 'btn btn-outline'),
'on:click': () => {
if (!props.pending) {
toast(successMsg || 'All synced', 'info');
return;
}
openApplyModal(successMsg);
},
}, props.pending ? label : syncedLabel);
}
/**
* POST cancel-all, toast result, close modal. State-store models update from
* the daemon's WS delta no explicit refresh.
*/
async function doCancelAll() {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
const resp = await apiFetch('/api/status/cancel-all', { method: 'POST' });
if (resp.ok) {
const data = resp.data || {};
let msg = 'Pending changes cancelled';
const nSkipped = Object.keys(data.skipped || {}).length;
if (nSkipped) {
msg += ` (${nSkipped} skipped: ` +
Object.entries(data.skipped).map(([k, v]) => `${k}${v}`).join('; ') + ')';
}
toast(msg, nSkipped ? 'warning' : 'success', nSkipped ? 8000 : undefined);
const errs = data.errors || {};
const nErrs = Object.keys(errs).length;
if (nErrs) {
toast('Cancel failed for: ' +
Object.entries(errs).map(([k, v]) => `${k}${v}`).join('; '),
'error', 8000);
}
closeModal();
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(resp.error || 'Cancel failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
}
/**
* Fetch pending state, then open the cancel confirmation modal.
*/
async function openCancelModal() {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
return;
}
const pendingData = pendingResp.data || {};
const totalChanges = pendingData.total_changes || 0;
const expanded = reactive({});
openModal((inner) => {
if (totalChanges === 0) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
<div class="apply-no-changes">No pending changes to cancel.</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Close</button></div>
</div>`);
return;
}
const rows = buildRows(pendingData, expanded);
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Cancel All Changes</h2>
<p>Restores the listed subsystems to their last applied configuration, discarding changes saved since the last apply.</p>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Keep Changes</button><button class="btn btn-danger" onClick="${() => doCancelAll()}">Cancel All Changes</button></div>
</div>`);
});
}
/**
* Cancel button with cross-subsystem confirmation modal.
*
* @param {object} props
* @param {string} [props.label] - Button text (default: 'Cancel All Changes')
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-danger')
*/
export function CancelConfirm(props = {}) {
const label = props.label || 'Cancel All Changes';
return h('button', {
class: props.cls !== undefined ? props.cls : 'btn btn-danger',
'on:click': () => openCancelModal(),
}, label);
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Hoover auth.js (components)
*
* Thin ceremony layer over the auth model (hoover/auth_model.js), which is
* the single source of truth for token storage, refresh scheduling, session
* validation, and login/logout state transitions.
*
* This file only contains:
* - logout() POST /api/auth/logout then drive the model to terminal
* - doLogin() drive the model through the 'login' action, then navigate
* - WebAuthn (passkey) ceremony helpers not state management
*/
import { modelFetch } from '../model.js';
import { getAuthData } from '../auth_model.js';
/**
* Logout: blacklist the current tokens server-side, then drive the model to
* the terminal all-nulls state. The model's onSuccess clears storage,
* redirects to #/login, and dispatches auth:logout (app.js closes the WS
* socket via disconnect() no explicit WS close here). The server reads
* jti/username from the request context; refresh_token is sent in the body
* per the blueprint contract (webui/api/auth.py:75).
*/
export async function logout() {
const auth = getAuthData();
if (auth?.token) {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer ' + auth.token,
'X-Session-Id': auth.session_id || '',
},
credentials: 'same-origin',
body: JSON.stringify({ refresh_token: auth.refresh || '' }),
});
} catch { /* ignore — we're clearing everything anyway */ }
}
modelFetch('auth', { action: 'logout' });
}
/**
* Handle a successful login: drive the auth model through the 'login'
* action (onSuccess persists the session, schedules the TTL refresh, and
* fires auth:login), then navigate.
*
* @param {object} data login response data ({ tokens, user, permissions, access_ttl })
* @param {string} [redirectPath] where to navigate after login
*/
export function doLogin(data, redirectPath = '/dashboard') {
modelFetch('auth', { action: 'login', payload: data });
window.location.hash = redirectPath;
}
/**
* Check if WebAuthn (passkeys) is supported in this browser.
*
* @returns {boolean}
*/
export function webauthnSupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
}
/* ─── Base64url helpers ──────────────────────────────────────────────── */
/**
* Convert base64url string to ArrayBuffer.
* @param {string} b64url
* @returns {ArrayBuffer}
*/
function b64urlToArrayBuffer(b64url) {
const bin = atob(b64url.replace(/-/g, '+').replace(/_/g, '/'));
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
arr[i] = bin.charCodeAt(i);
}
return arr.buffer;
}
/**
* Convert ArrayBuffer to base64url string.
* @param {ArrayBuffer} buffer
* @returns {string}
*/
function arrayBufferToB64url(buffer) {
const bytes = new Uint8Array(buffer);
const chunks = [];
for (let i = 0; i < bytes.length; i += 0x8000) {
chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 0x8000)));
}
const bin = chunks.join('');
return btoa(bin)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/* ─── WebAuthn navigator wrappers ────────────────────────────────────── */
/**
* Start a WebAuthn registration ceremony.
*
* Calls ``navigator.credentials.create()`` with the provided options,
* then returns the credential response as a JSON-serializable dict
* suitable for sending to the server.
*
* @param {object} registrationOptions options from /webauthn/register-begin
* @returns {Promise<object>} credential response (id, rawId, type, response)
*/
export async function startRegistration(registrationOptions) {
if (!webauthnSupported()) {
throw new Error('WebAuthn is not supported in this browser');
}
const publicKey = {
challenge: b64urlToArrayBuffer(registrationOptions.challenge),
rp: registrationOptions.rp,
user: {
id: b64urlToArrayBuffer(registrationOptions.user.id),
name: registrationOptions.user.name,
displayName: registrationOptions.user.displayName,
},
pubKeyCredParams: registrationOptions.pubKeyCredParams,
timeout: registrationOptions.timeout,
};
if (registrationOptions.excludeCredentials) {
publicKey.excludeCredentials = registrationOptions.excludeCredentials.map(c => ({
...c,
id: b64urlToArrayBuffer(c.id),
}));
}
if (registrationOptions.authenticatorSelection) {
publicKey.authenticatorSelection = registrationOptions.authenticatorSelection;
}
const credential = await navigator.credentials.create({ publicKey });
const { id, rawId, type, response } = credential;
return {
id: arrayBufferToB64url(rawId),
rawId: arrayBufferToB64url(rawId),
type,
response: {
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
attestationObject: arrayBufferToB64url(response.attestationObject),
transports: response.getTransports ? response.getTransports() : [],
},
};
}
/**
* Start a WebAuthn authentication ceremony.
*
* Calls ``navigator.credentials.get()`` with the provided options,
* then returns the assertion response as a JSON-serializable dict.
*
* @param {object} authenticationOptions options from /webauthn/authenticate-begin
* @returns {Promise<object>} assertion response (id, rawId, type, response)
*/
export async function startAuthentication(authenticationOptions) {
if (!webauthnSupported()) {
throw new Error('WebAuthn is not supported in this browser');
}
const publicKey = {
challenge: b64urlToArrayBuffer(authenticationOptions.challenge),
timeout: authenticationOptions.timeout,
userVerification: authenticationOptions.userVerification || 'preferred',
};
if (authenticationOptions.allowCredentials) {
publicKey.allowCredentials = authenticationOptions.allowCredentials.map(c => ({
...c,
id: b64urlToArrayBuffer(c.id),
}));
}
const credential = await navigator.credentials.get({ publicKey });
const { id, rawId, type, response } = credential;
return {
id: arrayBufferToB64url(rawId),
rawId: arrayBufferToB64url(rawId),
type,
response: {
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
authenticatorData: arrayBufferToB64url(response.authenticatorData),
signature: arrayBufferToB64url(response.signature),
userHandle: response.userHandle ? arrayBufferToB64url(response.userHandle) : null,
},
};
}
+94 -35
View File
@@ -4,10 +4,15 @@
* Data display components: Badge, StatusDot, Empty, Card.
*/
import { h } from '../vdom.js?v=7';
import { esc } from '../helpers.js?v=7';
import { apiFetch, toast } from '../api.js?v=7';
import { modelFetch } from '../model.js?v=7';
import { h } from '../vdom.js';
import { esc } from '../helpers.js';
import { apiFetch, toast } from '../api.js';
import { requestUpdate } from '../reactivity.js';
const _actionPending = new Map();
const _confirmPending = new Map();
export const _deleting = new Set();
/**
* Colored badge/span.
@@ -63,38 +68,72 @@ export function Card(props = {}) {
}
/**
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
* A Remove button that confirms, deletes via API, and toasts. State-store
* models update from the daemon's WS delta no explicit refresh. When the
* response includes a ``synced`` array (list of subsystem names that were
* auto-updated), appends them to the success toast.
*
* @param {object} props
* @param {string} props.url - API DELETE URL
* @param {string} props.message - Confirmation prompt text
* @param {string} [props.success] - Success toast message (default: 'Removed')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
* @param {function} [props.onComplete] - Callback after successful deletion
*/
export function ConfirmDelete(props = {}) {
const opts = { method: 'DELETE' };
if (props.body) opts.body = props.body;
return h('button', { class: 'btn btn-sm btn-danger',
const deleteKey = props.url + (props.body ? '::' + JSON.stringify(props.body) : '');
const pending = _confirmPending.get(deleteKey) || false;
return h('button', {
class: 'btn btn-sm btn-danger',
disabled: pending,
'on:click': async () => {
if (!confirm(props.message)) return;
const r = await apiFetch(props.url, opts);
if (r.ok) {
toast(props.success || 'Removed', 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
_confirmPending.set(deleteKey, true);
requestUpdate();
try {
const r = await apiFetch(props.url, opts);
if (r.ok) {
const synced = r.data?.synced;
let msg = props.success || 'Removed';
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
}
toast(msg, 'success');
if (props.deleteKey) {
_deleting.add(props.deleteKey);
// The WS delta (~50ms) removes the deleted item from
// model.data and re-renders the row away. This timeout
// purges _deleting if the delta is slow or the row was
// already unmounted.
setTimeout(() => _deleting.delete(props.deleteKey), 2000);
}
if (props.onComplete) props.onComplete();
// No modelFetch — WS delta updates state store models.
} else {
toast(r.error || 'Failed', 'error');
}
} else {
toast(r.error || 'Failed', 'error');
} finally {
_confirmPending.delete(deleteKey);
requestUpdate();
}
}}, props.label || 'Remove');
}
}, pending ? h('span', { class: 'btn-spinner' }) : (props.label || 'Remove'));
}
/**
* An action button that POSTs to an API endpoint, toasts on result,
* and optionally refreshes models. Supports toggle labels for on/off buttons.
* An action button that POSTs to an API endpoint and toasts on result.
* Supports toggle labels for on/off buttons. State-store models update from
* the daemon's WS delta no explicit refresh. When the response includes a
* ``synced`` array (list of subsystem names that were auto-updated), appends
* them to the success toast.
*
* @param {object} props
* @param {string} props.url - API URL
@@ -106,7 +145,8 @@ export function ConfirmDelete(props = {}) {
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
* @param {string} [props.successMsg] - Success toast message
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {function} [props.onSuccess] - Callback after the success toast
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
* @param {boolean} [props.disabled] - Disabled state
*/
@@ -116,25 +156,39 @@ export function ActionButton(props = {}) {
? (props.condition ? props.labelOn : props.labelOff)
: 'Action');
const cls = props.cls || 'btn btn-outline';
const pending = _actionPending.get(props.url) || false;
return h('button', {
class: cls,
disabled: props.disabled,
disabled: !!props.disabled || pending,
'on:click': async () => {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
if (props.successMsg) toast(props.successMsg, 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
if (pending) return;
_actionPending.set(props.url, true);
requestUpdate();
try {
const body = props.body ? props.body() : undefined;
const opts = { method: props.method || 'POST' };
if (body !== undefined) opts.body = body;
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
const synced = resp.data?.synced;
let msg = props.successMsg || '';
if (synced && synced.length) {
if (msg) msg += ' ';
msg += '(auto-synced: ' + synced.join(', ') + ')';
}
if (msg) toast(msg, 'success');
if (props.onSuccess) props.onSuccess();
// No modelFetch — WS delta updates state store models.
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
} finally {
_actionPending.delete(props.url);
requestUpdate();
}
}
}, label);
}, pending ? h('span', { class: 'btn-spinner' }) : label);
}
/**
@@ -256,18 +310,22 @@ export function ServiceStatus(props = {}) {
* @param {string} props.removeUrl - API DELETE URL
* @param {string} props.removeMessage - Confirmation prompt text
* @param {string} [props.removeSuccess] - Success toast message
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
* @param {string|string[]} [props.removeRefresh] - Legacy, ignored (accepted for backward compat)
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
* @param {boolean} [props.busy] - When true the action button is disabled (in-flight operation)
* @param {string} [props.busyLabel] - Label shown while busy (default: editLabel + '…')
* @param {string} [props.deleteKey] - Unique ID forwarded to ConfirmDelete for pending-delete styling
*/
export function ActionCell(props = {}) {
return h('td', null,
h('button', {
class: props.editCls || 'btn btn-sm btn-outline',
style: 'margin-right:4px;',
'on:click': props.editClick,
}, props.editLabel),
disabled: !!props.busy,
'on:click': props.busy ? undefined : props.editClick,
}, props.busy ? (props.busyLabel || (props.editLabel + '…')) : props.editLabel),
ConfirmDelete({
url: props.removeUrl,
message: props.removeMessage,
@@ -275,6 +333,7 @@ export function ActionCell(props = {}) {
refresh: props.removeRefresh,
label: props.removeLabel || 'Remove',
body: props.removeBody,
deleteKey: props.deleteKey,
}),
);
}
+3 -3
View File
@@ -4,9 +4,9 @@
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js?v=7';
import { Table } from './data.js?v=7';
import { collectLoadingModels } from '../model.js?v=7';
import { h } from '../vdom.js';
import { Table } from './data.js';
import { collectLoadingModels } from '../model.js';
/**
* Page header with title, optional subtitle, and action buttons.

Some files were not shown because too many files have changed in this diff Show More