refactor: overhaul daemon server, client, and handlers
This commit is contained in:
+117
-21
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user