docs: add comprehensive docstrings and inline comments

Add docstrings to all handler functions in daemon/handlers/firewall.py, covering
params, return values, and raised exceptions. Add inline comments to
_config_apply() reconciliation steps and the request body merge order.

Add docstrings across lib/ modules for emit helpers (_emit_str, _emit_int, etc.),
volatile stripping logic, two-layer diff strategy, sync event dispatch, and all
cross-subsystem sync subscribers (DnsToFirewall, WgToFirewall, FirewallToDhcp,
NetworkToAllSync).

Document WireGuard/networkd config parsers and key-value mappers in
system_import.py. Add docstrings to _ep(), Registry.decorator,
setup_logging, and _replace helper across daemon/ and lib/.
This commit is contained in:
2026-07-13 17:26:45 +00:00
parent 2e49dec633
commit c21639b7f1
10 changed files with 510 additions and 17 deletions
+9
View File
@@ -76,6 +76,15 @@ def setup_logging(level: str | None = None) -> None:
"""
def _open(self):
"""Override to open log file with group-write permissions (0664).
Temporarily clears umask to ensure the file is writable by both the
WebUI process (vacuum-wall user) and daemon process (vacuum-walld user)
when they share a group. On existing stale files, chmod's to 0664.
Returns:
Open file object in append mode.
"""
# Ensure group-write on an existing stale file (e.g. left by the
# other process with a stricter umask at creation time).
with contextlib.suppress(OSError):
+55
View File
@@ -103,30 +103,73 @@ def save_config(cfg: dict[str, Any]) -> None:
def _emit_str(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=Value`` line to *lines* if *py_key* has a non-None value in *d*.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_int(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=Value`` line to *lines* for integer values.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={v}")
def _emit_bool(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=yes/no`` line to *lines* if *py_key* has a non-None value in *d*.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_bool_opt(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Identical to :func:`_emit_bool` — kept for API compatibility.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
lines.append(f"{key}={'yes' if v else 'no'}")
def _emit_any(lines: list[str], key: str, py_key: str, d: dict[str, Any]) -> None:
"""Append a ``Key=Value`` line handling both bool and non-bool types.
Boolean values are rendered as ``yes``/``no``; all other types are
stringified directly.
Args:
lines: Target list to append rendered line to.
key: INI key name for the output line.
py_key: Python dict key to look up in *d*.
d: Config entry dict to extract value from.
"""
v = d.get(py_key)
if v is not None:
if isinstance(v, bool):
@@ -570,6 +613,18 @@ _IS_LOCAL = [
def _is_local_dns(addr: str) -> bool:
"""Return ``True`` if *addr* falls within a local/private IP range.
Checks loopback, RFC 1918 (10/8, 172.16/12, 192.168/16), link-local
(169.254/16), and their IPv6 equivalents (fc00::/7, fe80::/10).
Args:
addr: IP address string to test.
Returns:
``True`` if the address is local/private, ``False`` otherwise.
Invalid addresses are treated as non-local.
"""
try:
ip = ipaddress.ip_address(addr)
for net in _IS_LOCAL:
+14 -2
View File
@@ -130,14 +130,23 @@ def _ensure_webui_backend(raw: dict[str, Any]) -> None:
def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
"""Migrate legacy management domains to backend references."""
"""Migrate legacy management domains to backend references.
Legacy format: management domains had inline paths pointing to
127.0.0.1:9090 (Flask) and 127.0.0.1:9091 (WebSocket).
New format: domains reference the "webui" backend by name.
Detection heuristic: if both "/" path points to 127.0.0.1:9090
(is_management) and "/ws" path points to 127.0.0.1:9091
(is_websocket), the domain is a management domain and gets migrated.
"""
backends = raw.get("backends", {})
if not backends.get("webui", {}).get("_migrated"):
return
domains = raw.setdefault("domains", {})
for _name, dom in list(domains.items()):
if dom.get("backend") == "webui":
continue
continue # Already migrated
if dom.get("application") == "webui":
del dom["application"]
paths = dom.get("paths", {})
@@ -145,12 +154,15 @@ def _migrate_mgmt_domains(raw: dict[str, Any]) -> None:
ws = paths.get("/ws", {})
root_backend = root.get("backend", {})
ws_backend = ws.get("backend", {})
# Check if root path points to Flask management backend
is_mgmt_root = root.get("is_management") or (
root_backend.get("host") == "127.0.0.1" and root_backend.get("port") == 9090
)
# Check if WS path points to WebSocket management backend
is_mgmt_ws = ws.get("is_websocket") or (
ws_backend.get("host") == "127.0.0.1" and ws_backend.get("port") == 9091
)
# If both match, migrate: set backend reference, remove inline paths/auth
if is_mgmt_root and is_mgmt_ws:
dom["backend"] = "webui"
dom.pop("paths", None)
+27 -11
View File
@@ -277,16 +277,21 @@ def _strip_volatile(
for k in pop_keys:
stripped.pop(k, None)
for vpath in volatile:
# Determine if this is a list-of-dicts pattern
# Determine if this path uses list-of-dicts pattern (e.g. "peers[].transfer").
# The [] marker signals that the parent key holds a list of dicts, and we
# must strip the volatile sub-key from each dict in the list.
list_marker = vpath.index("[]") if "[]" in vpath else -1
if list_marker != -1:
# Split into prefix (before []), item keys (after [])
# Split into prefix (path before []), item keys (path after []).
# e.g. "status.peers[].transfer_received" → prefix=["status","peers"],
# item_keys=["transfer_received"]
prefix = vpath[:list_marker].split(".")
item_keys = (
vpath[list_marker + 3 :].split(".")
if list_marker + 3 < len(vpath)
else []
)
# Navigate to the list container via the prefix path
parent = stripped
for seg in prefix:
if isinstance(parent, dict) and seg in parent:
@@ -305,6 +310,7 @@ def _strip_volatile(
continue
for item in items:
# parent should now be a list; iterate each dict and strip sub-keys
if isinstance(item, dict):
curr = item
for i, ik in enumerate(item_keys):
@@ -316,7 +322,7 @@ def _strip_volatile(
else:
break
else:
# Scalar/dict path
# Scalar/dict path: navigate via segments and set final key to None
segments = vpath.split(".")
parent = stripped
for i, seg in enumerate(segments):
@@ -338,26 +344,36 @@ def _diff_layers(
) -> tuple[bool, bool]:
"""Compare *old* and *new* state using two-layer diff.
Strips ``timestamp`` from both before comparing.
The two-layer strategy distinguishes between:
1. Structural changes (config, topology) → triggers full client re-fetch
2. Volatile changes (byte counters, timestamps) → triggers lightweight tick
If structural data changed, volatile is suppressed (False) because the
structural change already triggers a full re-fetch, making the volatile
signal redundant.
Args:
old: Previous state data, or ``None`` if not yet populated.
new: New state data from collector.
volatile: Frozenset of volatile field paths.
Returns:
``(structural_changed, volatile_changed)``
``True`` means that layer differs between old and new.
If structural data changed, volatile is always ``False``
(the structural change already triggers a full re-fetch, so
the volatile signal is suppressed).
``(structural_changed, volatile_changed)``.
"""
if old is None:
return (True, True)
# Structural diff: compare with volatile/timestamp fields zeroed
# Structural diff: compare with volatile fields zeroed out, plus timestamp
# removed. If these differ, the configuration or topology has changed.
pop_keys = frozenset(("timestamp",))
old_struct = _strip_volatile(old, volatile, pop_keys)
new_struct = _strip_volatile(new, volatile, pop_keys)
structural = old_struct != new_struct
# Volatile diff: compare without timestamp
# Volatile diff: only relevant if structural is unchanged. Compare full
# data (minus timestamp). If this differs, only volatile fields changed
# (e.g. WireGuard transfer counters), and a lightweight tick suffices.
volatile_changed = False
if not structural:
old_no_ts = {k: v for k, v in old.items() if k != "timestamp"}
+77
View File
@@ -147,10 +147,14 @@ class EventBus:
"""Core dispatch logic (called within try/finally of _dispatch)."""
result = SyncResult()
# Iterate subscribers for this (subsystem, action) pair.
# Each subscriber is called in registration order.
for handler in self._subscribers.get((event.subsystem, event.action), []):
try:
sub_result = handler(event)
except Exception:
# Error containment: subscriber failures are logged, never abort
# the originating handler or other subscribers.
logger.warning(
"Sync subscriber %s failed for %s.%s",
_safe_name(handler),
@@ -163,10 +167,14 @@ class EventBus:
if sub_result is None:
continue
# Accumulate results from this subscriber into the aggregate.
result.affected_subsystems.extend(sub_result.affected_subsystems)
result.changes.extend(sub_result.changes)
result.applied = result.applied or sub_result.applied
# Cascade: for each affected subsystem (that isn't the source),
# emit a new event so downstream subscribers react. The cascade
# event carries _cascade=source so subscribers can detect loops.
for affected in sub_result.affected_subsystems:
if affected == event.subsystem:
continue
@@ -180,6 +188,8 @@ class EventBus:
result.changes.extend(cascaded.changes)
result.applied = result.applied or cascaded.applied
# Dedupe affected list before returning (cascade events may cause
# the same subsystem to appear multiple times).
result.affected_subsystems = _dedupe(result.affected_subsystems)
return result
@@ -247,6 +257,25 @@ class DnsToFirewallSync:
@classmethod
def on_dnsmasq_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: dnsmasq config_saved → update firewall zone services.
When DHCP ranges are added/removed on interfaces, this subscriber
ensures the corresponding firewall zones have ``dhcp`` and ``dns``
services enabled/disabled to match. Back-propagates: ensures
DHCP ranges carry the gateway (interface IP) so clients get
their default route.
Skips processing if event originated as a cascade from ``firewall``
to prevent infinite loops.
Args:
event: Sync event with ``config_saved`` action from dnsmasq.
Returns:
SyncResult listing firewall and dnsmasq as affected subsystems,
with human-readable change descriptions. ``None`` if skipped
due to cascade guard.
"""
if event.payload.get("_cascade") == "firewall":
return None
@@ -438,6 +467,23 @@ class WgToFirewallSync:
@classmethod
def on_wireguard_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: wireguard config_saved → update firewall config.
When WireGuard is active (has peers and interface), this subscriber
ensures the ``vpn`` zone exists with the WG interface assigned,
masquerade enabled, UDP 51820 accept rule, and inter-zone rich rules
for peer allowed_ips subnets. When WireGuard becomes inactive,
cleans up WireGuard-created entries from the vpn zone.
Skips processing if event originated as a cascade from ``firewall``.
Args:
event: Sync event with ``config_saved`` action from wireguard.
Returns:
SyncResult listing firewall as affected subsystem with change
descriptions. ``None`` if skipped due to cascade guard.
"""
if event.payload.get("_cascade") == "firewall":
return None
@@ -556,6 +602,23 @@ class FirewallToDhcpSync:
@classmethod
def on_firewall_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: firewall config_saved → sync dnsmasq DHCP ranges.
Removes DHCP ranges whose interface no longer belongs to any firewall
zone. When masquerade is enabled on a zone, ensures DHCP ranges on
that zone's interfaces carry the gateway (interface IP). Logs warnings
for zones with dhcp service but no range.
Skips processing if event originated as a cascade from ``dnsmasq``.
Args:
event: Sync event with ``config_saved`` action from firewall.
Returns:
SyncResult listing dnsmasq as affected subsystem when ranges were
modified, with change descriptions. ``None`` if skipped due to
cascade guard.
"""
if event.payload.get("_cascade") == "dnsmasq":
return None
@@ -665,6 +728,20 @@ class NetworkToAllSync:
@classmethod
def on_network_config_saved(cls, event: SyncEvent) -> SyncResult | None:
"""Sync subscriber: network config_saved → update firewall zone interfaces.
Suggests DHCP ranges for static-IP interfaces without ranges.
Removes interfaces from firewall zones that are no longer present
in the network config. Logs warnings for interfaces not assigned
to any zone.
Args:
event: Sync event with ``config_saved`` action from network.
Returns:
SyncResult listing firewall as affected subsystem when zone
interfaces were modified, with change descriptions.
"""
try:
from lib.dnsmasq import get_config as _get_dnsmasq_cfg
from lib.firewall import get_config as _get_fw_cfg
+44 -2
View File
@@ -253,7 +253,12 @@ def import_wireguard() -> bool:
def _parse_wireguard_conf(text: str) -> dict[str, Any]:
"""Parse wg-quick INI format into JSON config dict."""
"""Parse wg-quick INI format into JSON config dict.
Uses a simple state machine: [Interface] section populates the interface
dict; each [Peer] section accumulates into current_peer until the next
section header triggers _flush_peer() to commit it.
"""
interface: dict[str, Any] = {
"name": "wg0",
"listen_port": 51820,
@@ -270,6 +275,15 @@ def _parse_wireguard_conf(text: str) -> dict[str, Any]:
current_peer: dict[str, Any] | None = None
def _flush_peer() -> None:
"""Flush the current peer dict into the peers map if it has a public key.
Resets ``current_peer`` and ``current_peer_name`` to ``None``,
preparing for the next [Peer] section.
Note:
Only peers with a ``public_key`` are stored; sections without
a key (malformed or incomplete) are silently skipped.
"""
nonlocal current_peer, current_peer_name
if (
current_peer is not None
@@ -416,7 +430,13 @@ def import_networkd() -> bool:
def _parse_network_file(path: Path) -> dict[str, Any] | None:
"""Parse a .network INI file into interface config dict."""
"""Parse a .network INI file into interface config dict.
State machine: [Match] section is skipped; [Link] keys go to iface["link"];
[Network] keys go directly on iface. Numbered sections ([Address#N], [Route#N])
accumulate into cur_addr / cur_route dicts until a section boundary triggers
_flush() to commit them into the corresponding list.
"""
text = path.read_text()
iface: dict[str, Any] = {}
cur_section: str | None = None
@@ -424,6 +444,7 @@ def _parse_network_file(path: Path) -> dict[str, Any] | None:
cur_route: dict[str, Any] | None = None
def _flush() -> None:
"""Commit accumulated address/route dicts into the iface lists."""
nonlocal cur_addr, cur_route
if cur_addr is not None:
if "address" in cur_addr and len(cur_addr) == 1:
@@ -541,6 +562,16 @@ def _parse_network_section(
def _set_link_key(link: dict[str, Any], key: str, val: str) -> None:
"""Parse a [Link] section key-value pair and set the corresponding config field.
Maps systemd-networkd Link INI keys to snake_case config keys.
Boolean keys (ARP, Multicast, etc.) are auto-converted via ``_parse_bool``.
Args:
link: Link config dict to populate.
key: INI key name from the .network file.
val: Value string from the .network file.
"""
if key == "MTUBytes":
parsed = _safe_int(val)
if isinstance(parsed, int):
@@ -563,6 +594,17 @@ def _set_link_key(link: dict[str, Any], key: str, val: str) -> None:
def _set_network_key(iface: dict[str, Any], key: str, val: str) -> None:
"""Parse a [Network] section key-value pair and set the corresponding config field.
Maps systemd-networkd Network INI keys to snake_case config dict keys.
Comma-separated values (DNS, Domains, etc.) are split into lists.
Boolean and integer keys are auto-converted.
Args:
iface: Interface config dict to populate.
key: INI key name from the .network file.
val: Value string from the .network file.
"""
if key == "DHCP":
iface["dhcp"] = val
elif key == "Gateway":