"""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-.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/") def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """GET /network/interfaces/ — 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/") def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """POST /network/interfaces/ — 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//reload") def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """POST /network/interfaces//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}