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:
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user