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")
+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}
+217
View File
@@ -0,0 +1,217 @@
"""Networkd daemon handler.
Registers routes for managing systemd-networkd interface configuration
via config/network/config.json and generated .network files.
"""
import contextlib
import logging
from pathlib import Path
from typing import Any
from daemon.server import NotFoundError, registry
from lib.common import run
from lib.dnsmasq import set_upstreams
from lib.network import (
collect_upstream_dns,
generate_network_files,
get_config,
infer_dhcp_ranges,
infer_zones,
parse_networkctl_status,
render_network_file,
save_config,
)
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "network"
DATA_DIR = PROJECT_DIR / "data" / "networkd"
def _copy_and_reload(iface_name: str) -> None:
"""Copy generated 50-<name>.network file to /etc/systemd/network/ and reload."""
src = DATA_DIR / f"50-{iface_name}.network"
dst_dir = Path("/etc/systemd/network")
run(["mkdir", "-p", str(dst_dir)], sudo=True)
dst = dst_dir / f"50-{iface_name}.network"
run(["cp", str(src), str(dst)], sudo=True)
run(["networkctl", "reconfigure", iface_name], sudo=True)
def _full_reload() -> None:
"""Reload networkd for all interfaces."""
run(["networkctl", "reload"], sudo=True)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@registry.register("GET", "/network/interfaces")
def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/interfaces — return all interface config + runtime state."""
cfg = get_config()
ifaces_cfg = cfg.get("interfaces", {})
runtime: dict[str, Any] = {}
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
merged: dict[str, Any] = {}
for name, config_entry in ifaces_cfg.items():
merged[name] = {
"config": config_entry,
"runtime": runtime.get(name, {}),
}
return {"interfaces": merged, "timestamp": ""}
@registry.register("GET", "/network/interfaces/<name>")
def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""GET /network/interfaces/<name> — return config for one interface."""
if not body or "name" not in body:
raise ValueError("Interface name is required")
name = body["name"]
cfg = get_config()
ifaces = cfg.get("interfaces", {})
if name not in ifaces:
raise NotFoundError(f"Interface '{name}' not found in config")
runtime: dict[str, Any] = {}
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
return {
"name": name,
"config": ifaces[name],
"runtime": runtime.get(name, {}),
}
@registry.register("POST", "/network/interfaces/<name>")
def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/<name> — save config, render, apply."""
if not body:
raise ValueError("Request body required")
name = body.get("name", "").strip()
if not name:
raise ValueError("'name' is required")
iface_cfg = {k: v for k, v in body.items() if k not in ("name",)}
with contextlib.suppress(Exception):
raw = run(["networkctl", "status", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
if name not in runtime:
logger.warning(
"Interface '%s' not found in networkctl "
"(config saved but networkd will ignore it)",
name,
)
cfg = get_config()
cfg.setdefault("interfaces", {})
cfg["interfaces"][name] = iface_cfg
save_config(cfg)
content = render_network_file(name, iface_cfg)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / f"50-{name}.network").write_text(content)
# Deploy to system. In containerized environments this may fail
# (e.g. read-only /run/sudo timestamps) — don't let that block the save.
deployed = True
try:
_copy_and_reload(name)
except Exception:
deployed = False
logger.warning(
"Interface '%s' config saved but failed to deploy to "
"systemd-networkd (sudo/system unavailable)",
name,
exc_info=True,
)
logger.info("Interface '%s' config saved (applied=%s)", name, deployed)
return {"name": name, "applied": deployed}
@registry.register("POST", "/network/interfaces/<name>/reload")
def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
"""POST /network/interfaces/<name>/reload — reload networkd for interface."""
if not body or "name" not in body:
raise ValueError("'name' is required in request body")
name = body["name"]
with contextlib.suppress(Exception):
run(["networkctl", "reconfigure", name], sudo=True)
logger.info("Interface '%s' reloaded", name)
return {"name": name, "reloaded": True}
@registry.register("POST", "/network/apply")
def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
"""POST /network/apply — apply ALL interfaces (full sync)."""
cfg = get_config()
result = generate_network_files(cfg)
generated = result.get("generated", [])
cleaned = result.get("cleaned", [])
# Remove stale files from system dir that aren't in config
expected_names = {f.name for f in generated}
sys_dir = Path("/etc/systemd/network")
if sys_dir.exists():
for f in sys_dir.iterdir():
if f.name.endswith(".network") and f.name not in expected_names:
with contextlib.suppress(Exception):
run(["rm", str(f)], sudo=True)
for f in generated:
dst = sys_dir / f.name
run(["mkdir", "-p", str(sys_dir)], sudo=True)
run(["cp", str(f), str(dst)], sudo=True)
_full_reload()
# TF-8: sync DNS upstreams to dnsmasq
try:
upstreams = collect_upstream_dns(cfg)
if upstreams:
set_upstreams(upstreams)
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
except Exception:
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
logger.info(
"Network config applied: %d interfaces, %d stale cleaned",
len(generated),
len(cleaned),
)
return {
"applied": len(generated),
"files": [str(p) for p in generated],
"cleaned": [str(p) for p in cleaned],
}
@registry.register("GET", "/network/infer-dhcp-ranges")
def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-dhcp-ranges — suggest DHCP ranges from static IPs."""
cfg = get_config()
ranges = infer_dhcp_ranges(cfg)
return {"ranges": ranges}
@registry.register("GET", "/network/infer-zones")
def get_infer_zones(_request: Any, _body: Any) -> dict[str, Any]:
"""GET /network/infer-zones — suggest firewalld zones from interface config."""
cfg = get_config()
zones = infer_zones(cfg)
return {"zones": zones}
+52 -11
View File
@@ -83,6 +83,23 @@ class Registry:
"""
return self._routes.get((method.upper(), path))
def match(self, method: str, path: str):
"""Match *path* against registered patterns, returning handler + params.
Patterns may contain ``<param>`` segments (e.g. ``/foo/<name>``).
Matching segments are captured into a dict and merged into *body*.
Returns:
Tuple of (handler_fn, params_dict) or (None, None) if no match.
"""
for (reg_method, reg_path), fn in self._routes.items():
if reg_method != method.upper():
continue
pat_params = _match_path(reg_path, path)
if pat_params is not None:
return fn, pat_params
return None, None
registry = Registry()
@@ -127,6 +144,29 @@ def error(msg: str, code: int = 400) -> web.Response:
return web.json_response({"ok": False, "error": msg}, status=code)
def _match_path(pattern: str, path: str) -> dict[str, str] | None:
"""Match *path* against a URL pattern containing ``<param>`` segments.
Args:
pattern: URL pattern like ``/network/interfaces/<name>``.
path: Actual request path like ``/network/interfaces/eth1``.
Returns:
Dict mapping param names to their matched values, or ``None`` if no match.
"""
p_parts = pattern.strip("/").split("/")
r_parts = path.strip("/").split("/")
if len(p_parts) != len(r_parts):
return None
params: dict[str, str] = {}
for p_seg, r_seg in zip(p_parts, r_parts, strict=True):
if p_seg.startswith("<") and p_seg.endswith(">"):
params[p_seg[1:-1]] = r_seg
elif p_seg != r_seg:
return None
return params
async def _handle_request(request: web.Request) -> web.Response:
"""Dispatch a request to the appropriate handler.
@@ -136,27 +176,25 @@ async def _handle_request(request: web.Request) -> web.Response:
Returns:
The handler's response.
"""
handler_fn = registry.get(request.method, request.path)
handler_fn, pat_params = registry.match(request.method, request.path)
if handler_fn is None:
return error(f"Method {request.method} not allowed for {request.path}", 404)
# Build body from JSON and merge query params. GET requests send params
# as URL query string, so they need to be treated as body for handlers.
body: dict[str, Any] | None = None
# Build body — merge order (highest wins): path params > JSON body > query params.
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
# be overridable by body or query parameters.
body: dict[str, Any] | None = pat_params if pat_params else None
if request.content_type == "application/json":
try:
body = await request.json()
json_body = await request.json()
body = {**json_body, **body} if body is not None else json_body
except json.JSONDecodeError:
return error("Invalid JSON body", 400)
query_dict = dict(request.query)
if query_dict:
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
if body is not None:
merged = {**query_body, **body}
body = merged
else:
body = query_body
body = {**query_body, **body} if body is not None else query_body
try:
if body is not None:
@@ -216,7 +254,7 @@ async def _handle_batch(request: web.Request) -> web.Response:
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
continue
handler_fn = registry.get(method, path)
handler_fn, pat_params = registry.match(method, path)
if handler_fn is None:
results[op_id] = {
"ok": False,
@@ -225,6 +263,8 @@ async def _handle_batch(request: web.Request) -> web.Response:
continue
op_body = op.get("body")
if pat_params:
op_body = {**(op_body or {}), **pat_params}
try:
result = handler_fn(None, op_body)
@@ -320,6 +360,7 @@ def _register_routes() -> None:
dnsmasq, # noqa: F401
firewall, # noqa: F401
logs, # noqa: F401
network, # noqa: F401
nginx, # noqa: F401
wireguard, # noqa: F401
)