# 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, "pending_diff": [...]}`, **except firewall**, which uses `pending: {config_pending() result}` (a separate live-drift mechanism, see Firewall below). - `pending_diff` lists the field-level changes since the last apply; each entry has the shape: ``` {path: str, action: "added"|"removed"|"changed", old: |null, new: |null} ``` `path` is a dotted key path; lists of equal length are compared element-by-element with `[i]` indexes, while any other difference (including a length change) is reported as a single `changed` entry. `old` is `null` for added fields, `new` is `null` for removed ones. Apply-bookkeeping keys (`_last_applied_*`) are ignored. The list is empty when up to date or when no applied snapshot is recorded. - A subsystem whose collection failed holds `null`/`None` in the state store. Null handling differs per push layer: - **snapshot** is NOT filtered server-side — `get_snapshot()` is sent verbatim, including `null` entries; the client skips `null` payloads so a failed collector never overwrites good client data. - **versions** deltas ARE filtered server-side — the daemon skips the broadcast when the subsystem data is `null`. - **tick** has no `None` guard (it cannot be `null` in practice: a tick is only broadcast after a successful poll). - A **poll failure** does NOT set state to `null` — the stale value is retained and no broadcast is sent. Only `populate()` (startup and mutation-triggered refreshes) clears a subsystem to `null` when its collection fails. - 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 — **or when no hash is recorded at all** (the config was never applied). 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. Apply-all and cancel-all operate on freshly re-collected state, not last-poll state: every mutation ends with `emit_and_refresh()` → a synchronous `refresh_state()` re-collection, so a saved edit is already reflected by the time either endpoint runs; only out-of-band changes (e.g. manual edits) can lag the poll interval. Cancel reverts only the declarative config file — live drift (e.g. manual `firewall-cmd`) survives a cancel. ## State shape summary `state_store.get()` returns: | Subsystem | Poll | Volatile fields | Top-level keys | |---|---|---|---| | `firewall` | 30s | `interfaces[].ips`, `interfaces[].ipv6` | `config`, `active_zones`, `default_zone`, `interfaces`, `available_services`, `service_descriptions`, `uncovered_interfaces`, `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`, `status`, `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 default_zone: str, // firewall-cmd --get-default-zone; // catch-all zone for interfaces with // no explicit assignment interfaces: [ // ip link/addr parsing {name, mac, state, mtu, ips, ipv6, zone} ], available_services: [str], // firewall-cmd --get-services service_descriptions: {svc: str}, // one-line description from the // firewalld service XML definitions // (lib/firewall.py get_service_descriptions, // cached per process) uncovered_interfaces: [str], // network-config interfaces (excluding // lo/wg*) not in any LIVE zone — a // live-drift advisory (config may still // cover them); distinct from the // config-based interface-coverage // invariant (docs/config.md); NOT // counted in pending 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. - `pending.pending[]` change dicts have one of two shapes (see "Firewall pending summary" below): `{zone, type, config, live}` or `{zone, type, config_count, live_count}`. ## Dnsmasq ``` { config: {}, // config/dnsmasq/config.json, deep-merged status: { service_active: bool, config_file_exists: bool, active_leases: int, pending_changes: bool, pending_diff: [pending_change] // see Shared notes }, 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, pending_diff: [pending_change]}, timestamp: str, } ``` ## ACME ``` { certs: [ // list_certs(); extra keys possible {domain, expiry, renewed, status, days_remaining, ...} ], email: str, account: {registered, email, ca, key_length}, status: {error: str|null}, // null on success; the cert-collection // failure message otherwise (certs is // then []) timestamp: str, } ``` Notes: - `status.error` is the one failure signal: cert collection failed (e.g. unreadable `account.conf` after an ownership flip). `certs` is `[]` while the rest of the state is still collected, so a broken acme.sh does not blank the whole dashboard; the poll diff detects the recovery when the error clears. - Before listing, the collector runs a cheap no-sudo **self-heal probe**: it walks `ACME_HOME` for files that lost their group-read bit (acme.sh re-hardens its tree to `chmod 600` on every run) and, only when one is found, re-runs the sudo permission normalization. The steady-state poll therefore makes no sudo call. - When the failure text contains an unreadable `account.conf` (`Permission denied`), the error is rewritten into an actionable remediation: `sudo chown : /account.conf && sudo chmod 0640 /account.conf`, then restart `vacuum-walld`. ## 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- pending_changes: bool, pending_diff: [pending_change] // entries whose path contains // "private_key" are dropped, so the // diff never exposes key material }, 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, pending_diff: [pending_change]}, 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 (`load`, `memory`, `swap`, and the whole `traffic` dict) are volatile, and `timestamp` is excluded from both diff layers — so a **structural diff can never fire for `system`**. After the first populate (which always counts as structural and broadcasts a `versions` envelope), every change is a `tick`. ## Firewall pending summary `GET /api/firewall/config/pending` returns the state's `pending` dict plus `pending_summary` — a list of human-readable strings, one per pending change. Each firewall pending change has one of two shapes: ``` {zone: str, type: str, config: , live: } // type ∈ {interfaces, services, masquerade, target} {zone: str, type: str, config_count: int, live_count: int} // type ∈ {rich_rules, forward_ports} ``` ## Apply-all / cancel-all API All endpoints are proxied to the daemon (`daemon/handlers/status.py`). Subsystems are processed in dependency order `SYS_ORDER = ["networkd", "firewall", "wireguard", "dnsmasq", "nginx"]`. - `GET /api/status/pending` — aggregated pending state: ``` { firewall: { needs_apply: bool, change_count: int, changes: [{summary: str, detail: ""}], uncovered_interfaces: [str], // advisory — never counted coverage_warnings: [str] // advisory — never counted }, dnsmasq: {pending_changes: bool, summary: str, changes: [{summary, detail}]}, nginx: {…same…}, wireguard: {…same…}, networkd: {…same…}, total_changes: int, } ``` - `POST /api/status/apply-all` — applies **only the pending** subsystems, in `SYS_ORDER`. Body `{"force": true}` is forwarded to the firewall apply only (it overrides the firewall's management-lockout and interface-coverage guards; other subsystems ignore it). Response: `{applied: [subsystem], errors: {label: msg}}`. - `POST /api/status/cancel-all` — reverts **only the pending** subsystems' config files to their last-applied snapshot (no live-system commands run). Response: `{cancelled: [subsystem], skipped: {label: reason}, errors: {label: msg}}` — `skipped` covers e.g. "No baseline recorded (never applied)". - `POST /api/status/refresh` — re-collect state and return the snapshot for the target subsystems; optional body `{"subsystems": [name, …]}` filter (all when omitted). **No version bump** — versions advance on structural poll diffs and mutation-triggered refreshes only. ## Diff & push mechanics Envelope shapes (daemon → client): ``` {"type": "snapshot", "data": {subsystem: state|null, …}} // on connect {"type": "versions", "subsystem": str, "data": state} // structural {"type": "tick", "subsystem": str, "data": state} // volatile ``` - **First poll**: when the previous state is `null` (not yet populated), the poll counts as structural — the first broadcast after startup is a `versions` envelope. - **Two-layer diff** (`lib.state._diff_layers`): - structural layer — volatile fields zeroed out, `timestamp` removed; - volatile layer — full data minus `timestamp`, computed only when the structural layer is unchanged. - When the structural layer changes, the volatile signal is **suppressed** (reported unchanged): the `versions` envelope already carries the full new data, so a tick would be redundant. - `timestamp` is excluded from **both** layers — a timestamp-only change never triggers either envelope. - **Version bumps**: structural polls and mutation-triggered refreshes (`refresh_state`, default `bump=True`) bump the subsystem version counter; `tick` broadcasts and `POST /api/status/refresh` never bump. The counter is not sent over the wire — the envelope itself is the signal. - **Poll failure** = no broadcast (stale state retained; see Shared notes). - **Client mapping**: `networkd` maps to the `network` model (`_SUBSYSTEM_TO_MODEL` in `websocket.js`); unknown or retired message types are ignored. - **HTTP fallback**: if the WS snapshot has not populated a model within 3 s of page load, the client fetches over HTTP instead — `POST /api/status/refresh` with `{"subsystems": [name]}` returns the subsystem state verbatim; a `null` payload fails the fetch and the model keeps its schema defaults. - **Interval overrides**: `VACUUM_WALL_POLL_INTERVALS` (`subsystem:seconds,subsystem:seconds`) is parsed at daemon startup; entries whose value is `<= 0` or not an integer are skipped with a logged warning (the subsystem keeps its default interval). ## Frontend schema defaults (stale — follow-up) `webui/static/hoover/schema.js` holds hand-maintained `defaults` for every state model (placeholder data before the first WS snapshot / HTTP fetch). They are currently **stale copies** of this reference: no firewall `default_zone`, no acme `status`, no `pending_diff` keys. Since they only seed initial model data and are replaced verbatim by the first real payload, this is a cosmetic gap — flagged for follow-up (a code change, not a doc change).