state: applied-config snapshots + per-field pending diffs

- lib/common: stamp_applied() now records a _last_applied_config
  snapshot alongside the hash; strip_apply_meta() centralizes
  bookkeeping-key stripping; deep_diff() reports field-level changes
- state collectors (dnsmasq/nginx/wireguard/networkd) expose
  pending_diff so the dashboard can show exactly which fields
  changed since the last apply (wireguard diff excludes
  private_key paths)
- dashboard pending-changes card renders per-change lines with a
  generic fallback when no snapshot is recorded
- firewall: firewalld built-in zones no longer flagged as
  unmanaged; public-zone masquerade skipped in pending changes
  since apply drives it via nftables propagation
- schema: PendingChange TypedDict; pending_diff on DnsmasqStatus /
  WgStatus; tests in test_common.py, test_firewall.py, test_state.py
This commit is contained in:
2026-08-21 00:59:19 +00:00
parent a77cee821b
commit 30b51ad7d3
14 changed files with 525 additions and 62 deletions
+73 -3
View File
@@ -16,21 +16,87 @@ from typing import Any
from passlib.hash import sha256_crypt
_APPLY_HASH_KEY = "_last_applied_hash"
_LAST_APPLIED_CONFIG_KEY = "_last_applied_config"
_APPLY_META_KEYS = (_APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY)
def strip_apply_meta(cfg: dict[str, Any]) -> dict[str, Any]:
"""Return *cfg* without any apply bookkeeping keys.
Strips both the last-applied hash and the last-applied config snapshot
so the returned dict reflects only real configuration.
"""
return {k: v for k, v in cfg.items() if k not in _APPLY_META_KEYS}
def config_hash(cfg: dict[str, Any]) -> str:
"""Compute a SHA-256 hash of *cfg* excluding the ``_last_applied_hash`` key.
"""Compute a SHA-256 hash of *cfg*, ignoring apply bookkeeping keys.
Args:
cfg: Config dict, possibly containing ``_last_applied_hash``.
cfg: Config dict, possibly containing ``_last_applied_hash`` and
``_last_applied_config``.
Returns:
Hex digest of the stripped config JSON.
"""
clean = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
clean = strip_apply_meta(cfg)
return hashlib.sha256(json.dumps(clean, sort_keys=True).encode()).hexdigest()
def stamp_applied(cfg: dict[str, Any]) -> dict[str, Any]:
"""Record that *cfg* is the last applied configuration.
Writes both the content snapshot (``_last_applied_config``) and its hash
(``_last_applied_hash``) so a later pending check can detect drift and a
diff can report exactly which fields changed.
"""
cfg[_APPLY_HASH_KEY] = config_hash(cfg)
cfg[_LAST_APPLIED_CONFIG_KEY] = strip_apply_meta(cfg)
return cfg
def deep_diff(old: Any, new: Any, prefix: str = "") -> list[dict[str, Any]]:
"""Return a list of field-level changes between two configurations.
Each entry is ``{"path", "action", "old", "new"}`` where *action* is one
of ``"added"``, ``"removed"`` or ``"changed"``. Dicts are recursed with
dotted paths; lists of equal length are compared element-by-element,
while any other value that differs is reported as a single change.
Apply bookkeeping keys are ignored.
"""
if isinstance(old, dict):
old = strip_apply_meta(old)
if isinstance(new, dict):
new = strip_apply_meta(new)
out: list[dict[str, Any]] = []
_diff_nodes(old, new, prefix, out)
return out
def _diff_nodes(old: Any, new: Any, path: str, out: list[dict[str, Any]]) -> None:
"""Recursively collect field-level changes from *old* into *new*."""
if isinstance(old, dict) and isinstance(new, dict):
for key in sorted(set(old) | set(new)):
child = f"{path}.{key}" if path else str(key)
if key in old and key in new:
_diff_nodes(old[key], new[key], child, out)
elif key in old:
out.append(
{"path": child, "action": "removed", "old": old[key], "new": None}
)
else:
out.append(
{"path": child, "action": "added", "old": None, "new": new[key]}
)
return
if isinstance(old, list) and isinstance(new, list) and len(old) == len(new):
for i, (o, n) in enumerate(zip(old, new, strict=True)):
_diff_nodes(o, n, f"{path}[{i}]", out)
return
if old != new:
out.append({"path": path, "action": "changed", "old": old, "new": new})
def validate_interface_name(name: str) -> str:
"""Validate a Linux network interface name.
@@ -210,8 +276,10 @@ def get_interface_ip(iface: str) -> str | None:
__all__ = [
"_APPLY_HASH_KEY",
"_LAST_APPLIED_CONFIG_KEY",
"_hash_password",
"config_hash",
"deep_diff",
"deep_merge",
"ensure_dirs",
"get_interface_ip",
@@ -219,5 +287,7 @@ __all__ = [
"run",
"run_proc",
"save_json",
"stamp_applied",
"strip_apply_meta",
"validate_interface_name",
]
+34 -12
View File
@@ -22,6 +22,22 @@ CONFIG_FILE: Path = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# Zones firewalld ships by default. They are always present live and are
# never meaningful to flag as "unmanaged (not in config)".
FIREWALLD_BUILTIN_ZONES: frozenset[str] = frozenset(
{
"block",
"dmz",
"drop",
"external",
"home",
"host",
"internal",
"public",
"trusted",
}
)
# ---------------------------------------------------------------------------
# Internal helpers
@@ -313,17 +329,22 @@ def _compute_pending_changes(
}
)
cfg_mq = zone_cfg.get("masquerade", False)
live_mq = live_zone.get("masquerade", False)
if cfg_mq != live_mq:
changes.append(
{
"zone": zone_name,
"type": "masquerade",
"config": cfg_mq,
"live": live_mq,
}
)
# public zone masquerade is not reconciled by apply (it is driven by
# the nftables propagation step in daemon/handlers/firewall.py), so
# reporting it as pending here would advertise a change that never
# happens. Skip it to keep the diff consistent with apply.
if zone_name != "public":
cfg_mq = zone_cfg.get("masquerade", False)
live_mq = live_zone.get("masquerade", False)
if cfg_mq != live_mq:
changes.append(
{
"zone": zone_name,
"type": "masquerade",
"config": cfg_mq,
"live": live_mq,
}
)
cfg_rules = {r.get("rule") for r in zone_cfg.get("rich_rules", [])}
live_rules = set(live_zone.get("rich-rules", []))
@@ -356,7 +377,7 @@ def _compute_pending_changes(
)
for zone_name in live_zones:
if zone_name not in cfg_zones:
if zone_name not in cfg_zones and zone_name not in FIREWALLD_BUILTIN_ZONES:
unknown_live[zone_name] = {
"interfaces": live_zones[zone_name].get("interfaces", []),
}
@@ -415,6 +436,7 @@ __all__ = [
"CONFIG_FILE",
"DATA_DIR",
"DEFAULT_CONFIG",
"FIREWALLD_BUILTIN_ZONES",
"RULES_FILE",
"_compute_pending_changes",
"_ensure_config_file",
+23
View File
@@ -134,6 +134,23 @@ class DnsmasqDhcpLease(TypedDict):
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.
@@ -142,12 +159,15 @@ class DnsmasqStatus(TypedDict):
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):
@@ -316,6 +336,8 @@ class WgStatus(TypedDict):
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
@@ -323,6 +345,7 @@ class WgStatus(TypedDict):
peers: list[WgStatusPeer]
classes: dict[str, WgClassStatus]
pending_changes: bool
pending_diff: list[PendingChange]
class WgPeer(TypedDict, total=False):
+51 -9
View File
@@ -13,7 +13,16 @@ from pathlib import Path
from typing import Any, ClassVar
from lib import schema
from lib.common import _APPLY_HASH_KEY, config_hash, load_json, run, run_proc
from lib.common import (
_APPLY_HASH_KEY,
_LAST_APPLIED_CONFIG_KEY,
config_hash,
deep_diff,
load_json,
run,
run_proc,
strip_apply_meta,
)
from lib.firewall import (
_parse_active_zones,
_parse_all_zones_output,
@@ -637,7 +646,13 @@ def _collect_dnsmasq() -> schema.DnsmasqState:
cfg
)
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
safe_cfg = strip_apply_meta(cfg)
pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
pending_diff = deep_diff(snap, safe_cfg)
return {
"config": safe_cfg,
"status": {
@@ -645,6 +660,7 @@ def _collect_dnsmasq() -> schema.DnsmasqState:
"config_file_exists": conf_exists,
"active_leases": len(leases),
"pending_changes": pending_changes,
"pending_diff": pending_diff,
},
"leases": leases,
"timestamp": _now_iso(),
@@ -732,11 +748,20 @@ def _collect_nginx() -> schema.NginxState:
cfg
)
safe_cfg = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
safe_cfg = strip_apply_meta(cfg)
nginx_pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
nginx_pending_diff = deep_diff(snap, safe_cfg)
return {
"config": safe_cfg,
"domains": domains,
"status": {"pending_changes": pending_changes},
"status": {
"pending_changes": pending_changes,
"pending_diff": nginx_pending_diff,
},
"timestamp": _now_iso(),
}
@@ -939,7 +964,7 @@ def _collect_wireguard() -> schema.WgState:
)
# Safe config (strip private keys from interface and access classes)
safe = {k: v for k, v in cfg.items() if k != _APPLY_HASH_KEY}
safe = strip_apply_meta(cfg)
if "interface" in safe:
safe["interface"] = dict(safe["interface"])
safe["interface"].pop("private_key", None)
@@ -1116,6 +1141,16 @@ def _collect_wireguard() -> schema.WgState:
status["up"] = True
status["pending_changes"] = pending_changes
pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
# `safe` has private keys stripped; drop any private-key paths so
# the pending summary never exposes key material.
pending_diff = [
d for d in deep_diff(snap, safe) if "private_key" not in d["path"]
]
status["pending_diff"] = pending_diff
return {
"config": safe,
"status": status,
@@ -1164,7 +1199,14 @@ def _collect_networkd() -> schema.NetworkdState:
] != config_hash(net_cfg)
result: dict[str, dict[str, Any]] = {}
safe_net_cfg = {k: v for k, v in net_cfg.items() if k != _APPLY_HASH_KEY}
safe_net_cfg = strip_apply_meta(net_cfg)
net_status: dict[str, Any] = {"pending_changes": pending_changes}
net_pending_diff: list[dict[str, Any]] = []
if pending_changes:
snap = net_cfg.get(_LAST_APPLIED_CONFIG_KEY)
if isinstance(snap, dict):
net_pending_diff = deep_diff(snap, safe_net_cfg)
net_status["pending_diff"] = net_pending_diff
try:
raw = run(["networkctl", "status", "--json=short", "--all"], sudo=True)
@@ -1173,21 +1215,21 @@ def _collect_networkd() -> schema.NetworkdState:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": {"pending_changes": pending_changes},
"status": net_status,
"timestamp": _now_iso(),
}
except Exception:
return {
"interfaces": {},
"config": safe_net_cfg,
"status": {"pending_changes": pending_changes},
"status": net_status,
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"config": safe_net_cfg,
"status": {"pending_changes": pending_changes},
"status": net_status,
"timestamp": _now_iso(),
}
+4 -4
View File
@@ -949,7 +949,7 @@ def import_firewall() -> bool:
def _cfgs_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
"""Compare two configs ignoring _last_applied_hash."""
a_clean = {k: v for k, v in a.items() if k != "_last_applied_hash"}
b_clean = {k: v for k, v in b.items() if k != "_last_applied_hash"}
return a_clean == b_clean
"""Compare two configs ignoring apply bookkeeping keys."""
from lib.common import strip_apply_meta
return strip_apply_meta(a) == strip_apply_meta(b)