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
+8
View File
@@ -20,10 +20,12 @@ from flask import Flask, render_template, request
from daemon.client import get
from lib.logging import setup_logging
from lib.network import get_config
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
from webui.api.logs import bp as logs_bp
from webui.api.network import bp as network_bp
from webui.api.proxy import bp as proxy_bp
from webui.api.wireguard import bp as wireguard_bp
@@ -78,6 +80,7 @@ app = Flask(__name__)
app.config["SECRET_KEY"] = os.urandom(32).hex()
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(network_bp, url_prefix="/api/network")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
app.register_blueprint(certs_bp, url_prefix="/api/certs")
@@ -86,6 +89,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
BLUEPRINTS = [
("firewall", firewall_bp),
("network", network_bp),
("dhcp", dhcp_bp),
("proxy", proxy_bp),
("certs", certs_bp),
@@ -353,9 +357,11 @@ def interfaces_page():
"""
all_status = _safely(_load_status_all, {})
fw_state = all_status.get("firewall", {}) or {}
network_config = _safely(get_config, {})
return render_template(
"interfaces.html",
interfaces=fw_state.get("interfaces", []),
network_config=network_config,
zones=fw_state.get("active_zones", {}).keys() or [],
firewall_config=_safely(_fw_config_get, {}),
firewall_pending=fw_state.get("pending", {}),
@@ -433,11 +439,13 @@ def dhcp_page():
"""
all_status = _safely(_load_status_all, {})
dm_state = all_status.get("dnsmasq", {}) or {}
fw_state = all_status.get("firewall", {}) or {}
return render_template(
"dhcp.html",
config=dm_state.get("config", {}),
status=dm_state.get("status", {}),
leases=dm_state.get("leases", []),
interfaces=fw_state.get("interfaces", []),
)