369 lines
16 KiB
Markdown
369 lines
16 KiB
Markdown
# 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).
|