16 KiB
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 usespending: {config_pending() result}(a separate live-drift mechanism, see Firewall below). -
pending_difflists the field-level changes since the last apply; each entry has the shape:{path: str, action: "added"|"removed"|"changed", old: <value>|null, new: <value>|null}pathis 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 singlechangedentry.oldisnullfor added fields,newisnullfor 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/Nonein the state store. Null handling differs per push layer:- snapshot is NOT filtered server-side —
get_snapshot()is sent verbatim, includingnullentries; the client skipsnullpayloads 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
Noneguard (it cannot benullin 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. Onlypopulate()(startup and mutation-triggered refreshes) clears a subsystem tonullwhen its collection fails.
- snapshot is NOT filtered server-side —
-
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'sstatus.pending_changesis 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 firewallconfig_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 withemit_and_refresh()→ a synchronousrefresh_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. manualfirewall-cmd) survives a cancel.
State shape summary
state_store.get(<subsystem>) 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[].ipv6hold"ip/prefix"strings (IPv6 list is separate).- The zone dict's rich-rules key is HYPHENATED (
"rich-rules");state.rich_rulesis 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.erroris the one failure signal: cert collection failed (e.g. unreadableaccount.confafter an ownership flip).certsis[]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_HOMEfor files that lost their group-read bit (acme.sh re-hardens its tree tochmod 600on 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 <daemon-user>:<group> <ACME_HOME>/account.conf && sudo chmod 0640 <ACME_HOME>/account.conf, then restartvacuum-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-<class>
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: <value>, live: <value>}
// 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, inSYS_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}}—skippedcovers 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 aversionsenvelope. - Two-layer diff (
lib.state._diff_layers):- structural layer — volatile fields zeroed out,
timestampremoved; - 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
versionsenvelope already carries the full new data, so a tick would be redundant. timestampis excluded from both layers — a timestamp-only change never triggers either envelope.
- structural layer — volatile fields zeroed out,
- Version bumps: structural polls and mutation-triggered refreshes
(
refresh_state, defaultbump=True) bump the subsystem version counter;tickbroadcasts andPOST /api/status/refreshnever 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:
networkdmaps to thenetworkmodel (_SUBSYSTEM_TO_MODELinwebsocket.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/refreshwith{"subsystems": [name]}returns the subsystem state verbatim; anullpayload 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<= 0or 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).