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
+18 -24
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
import os
import re
import socket
import subprocess
from contextlib import suppress
@@ -141,17 +140,9 @@ def _find_acme_bin() -> str:
def _get_acme_email() -> str:
"""Read registered contact email from ACME account config."""
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
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 _get_state() -> dict[str, Any] | None:
@@ -564,6 +555,16 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
if not email:
raise ValueError("'email' is required")
_run_acme(["--register-account", "-m", email])
# Persist to declarative ACME config
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
import json as _json
_acme_data: dict[str, str] = {}
if acme_cfg.is_file():
_acme_data = _json.loads(acme_cfg.read_text())
_acme_data["email"] = email
acme_cfg.write_text(_json.dumps(_acme_data, indent=4) + "\n")
logger.info("ACME email set to %s", email)
refresh_state(["acme"])
return {"email": email}
@@ -573,19 +574,12 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /acme/email — return the currently configured ACME contact email."""
ac = _get_acme_state()
email = ""
if ac:
return {"email": ac.get("email", "")}
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
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 {"email": match.group(1).strip().strip("'\"")}
except OSError:
pass
return {"email": ""}
email = ac.get("email", "")
if not email:
email = _get_acme_email()
return {"email": email}
@registry.register("GET", "/acme/paths")