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
+20 -1
View File
@@ -135,7 +135,16 @@ def set_email(email: str) -> None:
def get_email() -> str:
"""Return the ACME contact email, or '' if none is configured."""
"""Return the ACME contact email, or '' if none is configured.
Checks account.conf first (acme.sh registered account), then falls
back to the declarative acme config.
"""
return _read_acme_email()
def _read_acme_email() -> str:
"""Read ACME email from account.conf, falling back to declarative config."""
try:
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
account_conf = acme_home / "account.conf"
@@ -146,6 +155,16 @@ def get_email() -> str:
return match.group(1).strip().strip("'\"")
except OSError as exc:
logger.warning("Could not read account.conf: %s", exc)
# Fallback: read from declarative ACME config
try:
from lib.common import load_json
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
conf = load_json(acme_cfg)
if conf and "email" in conf:
return conf["email"]
except (OSError, ValueError, KeyError):
pass
return ""