Files
vacuum-wall/lib/schema.py
T
mteehan ac52918df5 firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync
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
2026-08-28 23:38:21 +00:00

515 lines
15 KiB
Python

"""TypedDict schemas for every state collector's return value.
Single source of truth for the state-store data shapes. The Markdown
reference is ``docs/state-model.md``.
"""
from typing import Any, TypedDict
__all__ = [
"AcmeAccount",
"AcmeCert",
"AcmeState",
"CpuLoad",
"DnsmasqDhcpLease",
"DnsmasqState",
"DnsmasqStatus",
"FirewallInterface",
"FirewallState",
"FirewallZone",
"MemoryStats",
"NetworkdInterface",
"NetworkdState",
"NginxDomain",
"NginxState",
"SwapStats",
"SystemState",
"TrafficStats",
"WgClassStatus",
"WgPeer",
"WgState",
"WgStatus",
"WgStatusPeer",
]
# ── 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}`.
# ── Firewall ──────────────────────────────────────────────────
class FirewallInterface(TypedDict):
"""A network interface as parsed from `ip link` / `ip addr`.
Attributes:
name: Interface name (e.g. "eth0").
mac: MAC address, or ``None`` if unavailable.
state: Link state from `ip link` ("UP", "DOWN", "UNKNOWN", ...).
mtu: MTU value, or ``None`` if unavailable.
ips: IPv4 addresses as "ip/prefix" strings.
ipv6: IPv6 addresses as "ip/prefix" strings.
zone: Assigned firewalld zone name, or ``None``.
"""
name: str
mac: str | None
state: str
mtu: int | None
ips: list[str]
ipv6: list[str]
zone: str | None
class FirewallZone(TypedDict, total=False):
"""A firewalld zone as parsed from `--list-all-zones`.
The zone dict carries the HYPHENATED key "rich-rules" (see
``lib.firewall._parse_all_zones_output``), which TypedDict fields
cannot express. Additional firewalld keys may also appear:
"sources", "ports", "protocols", "forward-ports", "ics",
"icmp-blocks", "module", "rich-rules".
"""
target: str
interfaces: list[str]
services: list[str]
masquerade: bool
# snake_case `rich_rules` exists only at the top-level FirewallState
# (collector re-derivation, lib/state.py); the zone dict itself uses
# the hyphenated "rich-rules" key.
rich_rules: list[str]
class FirewallState(TypedDict):
"""Complete firewalld state (collector: `_collect_firewall`).
Attributes:
config: Contents of config/firewall/config.json.
active_zones: Zone name → assigned interfaces.
default_zone: firewalld default zone name ("--get-default-zone");
catch-all zone for interfaces with no explicit assignment.
interfaces: All system interfaces (see FirewallInterface).
available_services: firewalld service catalog ("--get-services").
service_descriptions: Service name to one-line description, parsed
from the firewalld service XML definitions
(``lib.firewall.get_service_descriptions``).
uncovered_interfaces: Network-config interfaces (excluding ``lo``/``wg*``) not in any live zone (advisory coverage warning, always present).
zones: All zones as runtime dicts (see FirewallZone).
rich_rules: Zone name → raw firewalld rich-rule strings.
pending: config_pending() result:
``{pending: [...], needs_apply: bool,
unmanaged_zones: {zone: {interfaces: [...]}}}``.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
active_zones: dict[str, list[str]]
default_zone: str
interfaces: list[FirewallInterface]
available_services: list[str]
service_descriptions: dict[str, str]
uncovered_interfaces: list[str]
zones: dict[str, FirewallZone]
rich_rules: dict[str, list[str]]
pending: dict[str, Any]
timestamp: str
# ── Dnsmasq ───────────────────────────────────────────────────
class DnsmasqDhcpLease(TypedDict):
"""A single dnsmasq DHCP lease from the lease file.
Attributes:
expires: ISO-8601 expiry timestamp, or "" if unparseable.
mac: Client MAC address.
ip: Leased IP address.
hostname: Client hostname (may be "").
interface: Interface the lease was granted on (may be "").
"""
expires: str
mac: str
ip: str
hostname: str
interface: str
class PendingChange(TypedDict):
"""One field-level difference between the applied config and the current
saved config (see `lib.common.deep_diff`).
Attributes:
path: Dotted (or indexed) path to the changed field.
action: "added", "removed", or "changed".
old: Value in the last applied config (None when added).
new: Value in the current config (None when removed).
"""
path: str
action: str
old: Any
new: Any
class DnsmasqStatus(TypedDict):
"""Dnsmasq service status snapshot.
Attributes:
service_active: Whether the dnsmasq systemd unit is active.
config_file_exists: Whether the rendered .conf is on disk.
active_leases: Count of currently active leases.
pending_changes: Whether the config is dirty vs the applied state.
pending_diff: Field-level changes since the last apply (empty when
up to date or when no applied snapshot is recorded).
"""
service_active: bool
config_file_exists: bool
active_leases: int
pending_changes: bool
pending_diff: list[PendingChange]
class DnsmasqState(TypedDict):
"""Dnsmasq state (collector: `_collect_dnsmasq`).
Attributes:
config: config/dnsmasq/config.json, deep-merged with defaults.
status: Service/config status (see DnsmasqStatus).
leases: Active DHCP leases.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
status: DnsmasqStatus
leases: list[DnsmasqDhcpLease]
timestamp: str
# ── Nginx ─────────────────────────────────────────────────────
class NginxDomain(TypedDict, total=False):
"""One flattened domain+path entry (see `_resolve_paths`).
Attributes:
domain: Domain name (config key).
path: Path prefix for this entry.
backend: Resolved backend config dict.
online: Whether a site .conf exists on disk.
force_ssl: Redirect-to-HTTPS flag.
backend_name: Backend config key this domain points at.
cert: Certificate reference (e.g. "acme", "selfsigned", ...).
is_management: Management UI path marker.
is_websocket: WebSocket-capable path marker.
"""
domain: str
path: str
backend: dict[str, Any]
online: bool
force_ssl: bool
backend_name: str
cert: str | None
is_management: bool
is_websocket: bool
class NginxState(TypedDict):
"""Nginx state (collector: `_collect_nginx`).
Attributes:
config: config/nginx/config.json.
domains: Flattened domain entries (one per domain+path).
status: ``{"pending_changes": bool}``.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
domains: list[NginxDomain]
status: dict[str, Any]
timestamp: str
# ── ACME ──────────────────────────────────────────────────────
class AcmeAccount(TypedDict):
"""ACME account status (see `_parse_account_conf`).
Attributes:
registered: Whether an account exists.
email: Registered email ("" when not registered).
ca: Human-readable CA name ("" when not registered).
key_length: Account key size, or ``None``.
"""
registered: bool
email: str
ca: str
key_length: int | None
class AcmeCert(TypedDict, total=False):
"""One certificate entry from `list_certs()` output.
Attributes:
domain: Certificate domain name.
expiry: Expiry date string.
renewed: Last renewal date string.
status: "valid" | "expired" | "active" | ...
days_remaining: Days until expiry.
Additional keys from `lib.acme.list_certs()` output may appear.
"""
domain: str
expiry: str
renewed: str
status: str
days_remaining: int
class AcmeState(TypedDict):
"""ACME state (collector: `_collect_acme`).
Attributes:
certs: Certificate list.
email: Registered ACME email.
account: Account status (see AcmeAccount).
timestamp: ISO-8601 collection time.
"""
certs: list[AcmeCert]
email: str
account: AcmeAccount
timestamp: str
# ── WireGuard ─────────────────────────────────────────────────
# The collector tracks BOTH a legacy single interface (wg0) and one
# wg-<class> interface per access class.
class WgStatusPeer(TypedDict):
"""A runtime peer parsed from `wg show` output.
Attributes:
public_key: Peer public key.
endpoint: Last-seen endpoint, or ``None``.
allowed_ips: Allowed IP/CIDR list.
latest_handshake: Human-readable last handshake time, or ``None``.
transfer_received: Received-bytes string from the transfer line.
transfer_sent: Sent-bytes string from the transfer line.
persistent_keepalive: Keepalive seconds, or ``None``.
"""
public_key: str
endpoint: str | None
allowed_ips: list[str]
latest_handshake: str | None
transfer_received: str
transfer_sent: str
persistent_keepalive: int | None
class WgClassStatus(TypedDict):
"""Runtime status for one wg-<class> interface.
Attributes:
up: Whether the class interface is up.
interface: ``{public_key, listen_port}`` (empty when down).
peers: Runtime peers of this class interface.
"""
up: bool
interface: dict[str, Any]
peers: list[WgStatusPeer]
class WgStatus(TypedDict):
"""Aggregated WireGuard runtime status.
Attributes:
up: True when any managed interface is up.
interface: Legacy single-interface info (public_key, listen_port).
peers: Legacy single-interface runtime peers.
classes: Per-access-class runtime status (keyed by class name).
pending_changes: Whether the config is dirty vs the applied state.
pending_diff: Field-level changes since the last apply (empty when
up to date or when no applied snapshot is recorded).
"""
up: bool
interface: dict[str, Any]
peers: list[WgStatusPeer]
classes: dict[str, WgClassStatus]
pending_changes: bool
pending_diff: list[PendingChange]
class WgPeer(TypedDict, total=False):
"""A config-file peer (``private_key`` stripped).
Attributes:
name: Peer config key.
public_key: Peer public key.
endpoint: Configured endpoint, or ``None``.
allowed_ips: Allowed IP/CIDR list.
persistent_keepalive: Keepalive seconds, or ``None``.
preshared_key: Preshared key, or ``None``.
"""
name: str
public_key: str
endpoint: str | None
allowed_ips: list[str]
persistent_keepalive: int | None
preshared_key: str | None
class WgState(TypedDict):
"""WireGuard state (collector: `_collect_wireguard`).
Attributes:
config: config/wireguard/config.json; ``private_key`` stripped
from the interface AND from every access class.
status: Runtime status (see WgStatus).
peers: Config peers, private keys stripped.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
status: WgStatus
peers: list[WgPeer]
timestamp: str
# ── Networkd ──────────────────────────────────────────────────
# Matches parse_networkctl_status() output exactly (lib/network.py).
# There is a single combined `addresses` list — no separate
# `ipv6_addresses` or `routes` keys.
class NetworkdInterface(TypedDict):
"""One networkd interface as parsed by `parse_networkctl_status`.
Attributes:
addresses: "ip/prefix" entries, IPv4+IPv6 combined.
gateway: Default-route gateway, or ``None``.
dns: Configured DNS server list.
mac: MAC address, or ``None``.
state: OperationalState (e.g. "routable", "degraded", "off").
link: Link type (e.g. "ether", "loopback", ...).
"""
addresses: list[str]
gateway: str | None
dns: list[str]
mac: str | None
state: str
link: str
class NetworkdState(TypedDict):
"""Networkd state (collector: `_collect_networkd`).
Attributes:
config: config/network/config.json.
interfaces: Runtime state keyed by interface name.
status: ``{"pending_changes": bool}``.
timestamp: ISO-8601 collection time.
"""
config: dict[str, Any]
interfaces: dict[str, NetworkdInterface]
status: dict[str, Any]
timestamp: str
# ── System ────────────────────────────────────────────────────
class CpuLoad(TypedDict):
"""CPU load averages (see /proc/loadavg).
Attributes:
load1: 1-minute load average.
load5: 5-minute load average.
load15: 15-minute load average.
"""
load1: float
load5: float
load15: float
class MemoryStats(TypedDict):
"""Memory usage (see /proc/meminfo).
Attributes:
total: Total memory in bytes.
available: Available memory in bytes.
used: Used memory in bytes.
used_pct: Used percentage (0-100), rounded to 0.1.
"""
total: int
available: int
used: int
used_pct: float
class SwapStats(TypedDict):
"""Swap usage (see /proc/meminfo).
Attributes:
total: Total swap in bytes.
used: Used swap in bytes.
used_pct: Used percentage (0-100), rounded to 0.1.
"""
total: int
used: int
used_pct: float
class TrafficStats(TypedDict):
"""Per-interface traffic counters (see /sys/class/net/<iface>/statistics).
Attributes:
rx_bytes: Total bytes received.
tx_bytes: Total bytes transmitted.
rx_packets: Total packets received.
tx_packets: Total packets transmitted.
"""
rx_bytes: int
tx_bytes: int
rx_packets: int
tx_packets: int
class SystemState(TypedDict):
"""System-wide metrics (collector: `_collect_system`).
Attributes:
load: CPU load averages (see CpuLoad).
memory: Memory usage (see MemoryStats).
swap: Swap usage (see SwapStats).
traffic: Per-interface counters keyed by interface name.
timestamp: ISO-8601 collection time.
"""
load: CpuLoad
memory: MemoryStats
swap: SwapStats
traffic: dict[str, TrafficStats]
timestamp: str