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
+42 -20
View File
@@ -7,7 +7,6 @@ state instead of invoking subprocesses on every request.
import contextlib
import logging
import os
import re
import shutil
import subprocess
from copy import deepcopy
@@ -23,6 +22,7 @@ from lib.firewall import (
from lib.firewall import (
config_pending as _config_pending,
)
from lib.network import parse_networkctl_status
logger = logging.getLogger(__name__)
@@ -52,6 +52,7 @@ class State:
"nginx",
"acme",
"wireguard",
"networkd",
]
def __init__(self) -> None:
@@ -184,7 +185,7 @@ def _collect_firewall() -> dict[str, Any]:
parts = line.split()
if len(parts) < 2:
continue
raw_name = parts[1].rstrip(":")
raw_name = parts[1].rstrip(":").split("@")[0]
iface_state = "UNKNOWN"
mtu = None
mac = None
@@ -197,7 +198,6 @@ def _collect_firewall() -> dict[str, Any]:
mac = parts[i + 1]
iface_map[raw_name] = {
"name": raw_name,
"display_name": raw_name.partition("@")[0],
"mac": mac,
"state": iface_state,
"mtu": mtu,
@@ -215,15 +215,14 @@ def _collect_firewall() -> dict[str, Any]:
addr_name = parts[1]
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
for entry in iface_map.values():
if entry["display_name"] == addr_name:
if entry["name"] == addr_name:
entry[addr_key].append(parts[3])
break
for zone_name, ifaces in active.items():
for raw_if in ifaces:
clean = raw_if.partition("@")[0]
for entry in iface_map.values():
if entry["display_name"] == clean or entry["name"] == raw_if:
if entry["name"] == raw_if:
entry["zone"] = zone_name
break
@@ -571,21 +570,12 @@ def _has_auto_renew(domain: str) -> bool:
def _get_acme_email() -> str:
"""Read the ACME ``acme.sh`` email from the account config file.
Returns:
Email string, or empty string if not found.
Falls back to the declarative ACME config (config/acme/config.json)
if acme.sh account has not been registered yet.
"""
acme_home_default = str(PROJECT_DIR / "data" / "acme")
try:
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
account_conf = acme_home / "account.conf"
if account_conf.is_file():
text = account_conf.read_text()
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip().strip("'\"")
except OSError:
pass
return ""
from lib.acme import _read_acme_email
return _read_acme_email()
def _collect_acme() -> dict[str, Any]:
@@ -770,6 +760,38 @@ def _collect_wireguard() -> dict[str, Any]:
register_collector("wireguard", _collect_wireguard)
# ---------------------------------------------------------------------------
# Networkd collector
# ---------------------------------------------------------------------------
def _collect_networkd() -> dict[str, Any]:
"""Collect networkd interface state from networkctl.
Returns:
Dict with interface runtime state parsed from networkctl output.
Returns empty data if networkctl is not available.
"""
result: dict[str, dict[str, Any]] = {}
try:
raw = run(["networkctl", "status", "--all"], sudo=True)
result = parse_networkctl_status(raw)
if not result:
return {"interfaces": {}, "timestamp": _now_iso()}
except Exception:
return {
"interfaces": {},
"timestamp": _now_iso(),
}
return {
"interfaces": result,
"timestamp": _now_iso(),
}
register_collector("networkd", _collect_networkd)
__all__ = [
"State",