faa076370d
- 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.
201 lines
7.5 KiB
Markdown
201 lines
7.5 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}`, **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.
|