refactor: overhaul daemon server, client, and handlers

This commit is contained in:
2026-06-16 03:35:58 +00:00
parent c5813d68b3
commit 4fc0fb3f72
10 changed files with 683 additions and 195 deletions
+117 -21
View File
@@ -6,13 +6,25 @@ 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
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,
@@ -31,15 +43,48 @@ 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"
"""Copy generated 99-<name>.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"50-{iface_name}.network"
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)
@@ -50,7 +95,7 @@ def _full_reload() -> None:
# ---------------------------------------------------------------------------
@registry.register("GET", "/network/interfaces")
@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()
@@ -68,15 +113,17 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]:
"runtime": runtime.get(name, {}),
}
return {"interfaces": merged, "timestamp": ""}
from lib.state import _now_iso
return {"interfaces": merged, "timestamp": _now_iso()}
@registry.register("GET", "/network/interfaces/<name>")
@registry.register(GET_NETWORK_INTERFACE_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"]
name = validate_interface_name(body["name"])
cfg = get_config()
ifaces = cfg.get("interfaces", {})
if name not in ifaces:
@@ -94,17 +141,24 @@ def get_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
}
@registry.register("POST", "/network/interfaces/<name>")
@registry.register(POST_NETWORK_INTERFACE_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")
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)
@@ -122,7 +176,7 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
content = render_network_file(name, iface_cfg)
DATA_DIR.mkdir(parents=True, exist_ok=True)
(DATA_DIR / f"50-{name}.network").write_text(content)
(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.
@@ -142,12 +196,12 @@ def save_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]
return {"name": name, "applied": deployed}
@registry.register("POST", "/network/interfaces/<name>/reload")
@registry.register(POST_NETWORK_INTERFACE_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"]
name = validate_interface_name(body["name"])
with contextlib.suppress(Exception):
run(["networkctl", "reconfigure", name], sudo=True)
@@ -156,7 +210,7 @@ def reload_interface(_request: Any, body: dict[str, Any] | None) -> dict[str, An
return {"name": name, "reloaded": True}
@registry.register("POST", "/network/apply")
@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()
@@ -164,14 +218,21 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
generated = result.get("generated", [])
cleaned = result.get("cleaned", [])
# Remove stale files from system dir that aren't in config
# 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:
with contextlib.suppress(Exception):
run(["rm", str(f)], sudo=True)
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
@@ -201,7 +262,7 @@ def apply_all(_request: Any, _body: Any) -> dict[str, Any]:
}
@registry.register("GET", "/network/infer-dhcp-ranges")
@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()
@@ -209,9 +270,44 @@ def get_infer_dhcp_ranges(_request: Any, _body: Any) -> dict[str, Any]:
return {"ranges": ranges}
@registry.register("GET", "/network/infer-zones")
@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}