Files

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 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: <value>|null, new: <value>|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(<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[].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 <daemon-user>:<group> <ACME_HOME>/account.conf && sudo chmod 0640 <ACME_HOME>/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-<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, 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).