"""Networkd daemon handler. Registers routes for managing systemd-networkd interface configuration via config/network/config.json and generated .network files. """ import contextlib import logging import re from pathlib import Path from typing import Any from daemon.iface import ( GET_NETWORK_INFER_DHCP_RANGES, GET_NETWORK_INFER_ZONES, GET_NETWORK_INTERFACE_NAME, GET_NETWORK_INTERFACES, POST_NETWORK_APPLY, POST_NETWORK_INTERFACE_NAME, POST_NETWORK_INTERFACE_RELOAD, POST_NETWORK_SYSCTL_SET, ) from daemon.server import NotFoundError, registry from lib.common import run, validate_interface_name from lib.dnsmasq import set_upstreams from lib.network import ( KNOWN_INTERFACE_KEYS, 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 99-.network file to /etc/systemd/network/ and reload.""" validate_interface_name(iface_name) src = DATA_DIR / f"99-{iface_name}.network" dst_dir = Path("/etc/systemd/network") run(["mkdir", "-p", str(dst_dir)], sudo=True) dst = dst_dir / f"99-{iface_name}.network" run(["cp", str(src), str(dst)], sudo=True) # Remove lower-priority .network files that match this interface # (they would override our config due to higher systemd priority) if dst_dir.exists(): for f in dst_dir.iterdir(): if ( f.name.endswith(".network") and f.name != dst.name and _matches_interface(f.name, iface_name) ): with contextlib.suppress(Exception): run(["rm", str(f)], sudo=True) logger.info("Removed conflicting file: %s", f.name) run(["networkctl", "reload"], sudo=True) run(["networkctl", "reconfigure", iface_name], sudo=True) def _matches_interface(filename: str, iface_name: str) -> bool: """Check if a .network filename would match the given interface.""" base = filename.replace(".network", "") # Strip numeric priority prefix (e.g. "50-eth1" → "eth1") if "-" in base and base.split("-", 1)[0].isdigit(): base = base.split("-", 1)[1] return base == iface_name def _extract_iface_from_filename(filename: str) -> str | None: """Extract interface name from a .network filename (e.g. '50-eth1.network' → 'eth1').""" base = filename.replace(".network", "") if "-" in base and base.split("-", 1)[0].isdigit(): return base.split("-", 1)[1] return base if base else None 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, {}), } from lib.state import _now_iso return {"interfaces": merged, "timestamp": _now_iso()} @registry.register(GET_NETWORK_INTERFACE_NAME) 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 = validate_interface_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_INTERFACE_NAME) 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 = validate_interface_name(body.get("name", "")) iface_cfg = {k: v for k, v in body.items() if k not in ("name",)} unknown = set(iface_cfg.keys()) - KNOWN_INTERFACE_KEYS if unknown: logger.warning( "Interface '%s': unexpected config keys %s — these will be " "saved but not rendered to .network files", name, sorted(unknown), ) 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"99-{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_INTERFACE_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 = validate_interface_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/conflicting files from system dir expected_names = {f.name for f in generated} managed_ifaces = { f.name.replace("99-", "").replace(".network", "") 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: iface_from_file = _extract_iface_from_filename(f.name) if iface_from_file and iface_from_file in managed_ifaces: # Remove conflicting external configs for managed interfaces with contextlib.suppress(Exception): run(["rm", str(f)], sudo=True) cleaned.append(f) 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} @registry.register(POST_NETWORK_SYSCTL_SET) def set_sysctl(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]: """POST /sysctl/set — set a sysctl kernel parameter value. Writes the value via `sysctl -w`, then verifies by reading it back. Raises: ValueError: When name or value is missing. """ if not body: raise ValueError("Request body required") name = body.get("name", "").strip() if not name: raise ValueError("'name' is required") if not re.match(r"^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$", name): raise ValueError("'name' is not a valid sysctl key") value = str(body.get("value", "")).strip() if not value: raise ValueError("'value' is required") run(["sysctl", "-w", f"{name}={value}"], sudo=True) # Verify by reading back via /proc/sys (no sudo needed for reads, avoid # triggering sudoers for read-only sysctl which is not whitelisted) proc_path = Path(f"/proc/sys/{name.replace('.', '/')}") read_value = proc_path.read_text().strip() if read_value != value: raise RuntimeError( f"sysctl verify failed: set {name}={value} but read back {read_value}" ) logger.info("sysctl %s set to %s", name, value) return {"name": name, "value": value}