Post-DHCP-incident hardening per HARDEN.md.
- apply guard: refuse (ConflictError, `force` overrides) when a
network-managed interface would end up in no zone; absent
`interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
+ advisory in /api/status/pending; zones.js banner + interfaces-picker
last-zone confirm
- target drift (Option A): absent or default-normalizing target is
unmanaged: not diffed, never re-set by apply; create_zone runs
--new-zone first and sets non-default targets only; importer omits
the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
({timestamp, default_zone, zones, config}); drop the empty post-apply
skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
(get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
867 passing
7.0 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 usespending: {config_pending() result}. - A subsystem whose collection failed holds
null/Nonein the state store — WS snapshots and deltas skipnullpayloads 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'sstatus.pending_changesis true when the current (merged) config hash differs from the recorded hash;status.pending_difflists the field changes since that snapshot. 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.
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 —
// advisory coverage warning; empty =
// fully covered; 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.
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.