feat: add networkd subsystem and fix code review issues

Phase 1-4: Networkd subsystem
- lib/network.py: systemd-networkd config renderer (.network INI files)
  with full schema support: [Match], [Link], [Network], [Address], [Route],
  [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec.
  Route sections use #N suffix per systemd.syntax(7).
- lib/network.py: generate_network_files() with 50-<name>.network prefix
  and stale file cleanup
- lib/network.py: collect_upstream_dns() filters local/private DNS
- lib/network.py: infer_dhcp_ranges() and infer_zones() helpers
- daemon/handlers/network.py: routes for GET/POST /network/interfaces
  and full apply with DNS upstream sync to dnsmasq
- webui/api/network.py: Flask blueprint for /api/network/* endpoints
- webui/api: interfaces page updated with IP config inline editing
- lib/state.py: networkd collector using parse_networkctl_status()
- system/sudoers.d/vacuum-walld: networkctl + systemd-network rules
- system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network
- install.sh: ACME email now optional, configured from WebUI
- lib/acme.py: get_email() falls back to declarative config

Phase 5: Code review fixes
- daemon/server.py: path params now win over JSON body and query params
  in request body merge (prevents config save name override)
- daemon/server.py: remove dead 'import re'
- daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir
  for /etc/systemd/network (ProtectSystem=strict compatibility)
- system/sudoers.d/vacuum-walld: pin systemctl to specific commands
  (reload/is-active dnsmasq instead of wildcard)
- system/sudoers.d/vacuum-walld: restore !requiretty and section comment
- lib/network.py: remove unused _MANAGEMENT_PORTS constant
- webui/api/network.py: remove redundant body[\name\] = name in save_interface

Tests: 332 passing (110 new/updated), ruff clean
This commit is contained in:
2026-06-01 03:15:50 +00:00
parent 2f215793e9
commit bc72db903c
26 changed files with 3294 additions and 121 deletions
+45 -17
View File
@@ -14,6 +14,7 @@ from daemon.server import NotFoundError, refresh_state, registry
from lib.common import load_json, run, save_json
from lib.firewall import (
_normalize_target,
_parse_active_zones,
_parse_zone_output,
)
from lib.firewall import (
@@ -409,24 +410,31 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
raise ValueError("'zone' is required")
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
raise NotFoundError(f"Zone '{zone}' does not exist")
try:
current = _parse_zone_output(
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
).get("interfaces", [])
except Exception:
current = []
for iface in current:
run(
[
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
# Determine old zone for each interface being reassigned
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw)
for iface in interfaces:
# Find which zone currently owns this interface
old_zone = None
for az, az_ifaces in active.items():
if iface in az_ifaces:
old_zone = az
break
# Remove from old zone (if different from target)
if old_zone and old_zone != zone:
run(
[
"firewall-cmd",
f"--zone={old_zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
# Add to target zone
run(
[
"firewall-cmd",
@@ -436,7 +444,27 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
],
sudo=True,
)
_reload()
# Update config
cfg = _get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone]["interfaces"] = list(interfaces)
# Remove interface from any old zone in config
for old_zone_name, old_zone_cfg in cfg["zones"].items():
if old_zone_name == zone:
continue
old_ifaces = old_zone_cfg.get("interfaces", [])
new_ifaces = [i for i in old_ifaces if i not in interfaces]
if len(new_ifaces) < len(old_ifaces):
if new_ifaces:
old_zone_cfg["interfaces"] = new_ifaces
elif "interfaces" in old_zone_cfg:
del old_zone_cfg["interfaces"]
_save_config(cfg)
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
refresh_state(["firewall"])
return {"zone": zone, "interfaces": interfaces}