Files
vacuum-wall/docs/state-model.md
T
mteehan faa076370d refactor: daemon collectors, thin webui proxies, pure config reads
- move state collectors from lib/state.py to daemon/collectors/ (7
  modules, registration side-effect; daemon/server.py imports the
  package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
  collapses the try/except daemon-proxy boilerplate in all 8
  blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
  truth for zone interfaces (absent key = empty, no hands-off
  zones); pure validate_coverage() enforced at save (400) and apply
  (409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
  new lib/bootstrap.py creates runtime dirs and persists the
  one-shot nginx legacy migration at daemon start, after
  system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
  /status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
  log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
  HARDEN.md dropped (plan implemented); apply-confirm force wording

Tests: 917 passed; ruff check + format clean.
2026-09-03 00:40:56 +00:00

7.5 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}, 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. Apply-all and cancel-all both decide from this last-poll state (an edit saved within the last poll interval may not yet be flagged), and 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, 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, 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
  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.

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.