Files
vacuum-wall/daemon/handlers/network.py
T
mteehan 8c13ad55ce Add update-vendor.sh symlink support, unify install.sh vendor flow
- update-vendor.sh now creates webui/vendor symlinks (htm.js)
- install.sh calls update-vendor.sh after package install
- Add vendor/.empty and webui/vendor/.empty as directory placeholders in git
2026-07-01 00:55:03 +00:00

349 lines
12 KiB
Python

"""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, refresh_state, registry
from lib.common import _APPLY_HASH_KEY, config_hash, run, validate_interface_name
from lib.dnsmasq import get_config as _get_dm_cfg
from lib.dnsmasq import save_config as _save_dm_cfg
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,
)
from lib.sync import SyncEvent, bus
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-<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"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", "--json=short", "--all"], sudo=True)
runtime = parse_networkctl_status(raw)
merged: dict[str, Any] = {}
all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"}
for name in sorted(all_names):
merged[name] = {
"config": ifaces_cfg.get(name, {}),
"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/<name> — 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", "--json=short", "--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/<name> — 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", "--json=short", "--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)
if deployed:
cfg_after = get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"network", "config_saved", {"action": "interface_saved", "interface": name}
)
)
refresh_state(["network", *sync_result.affected_subsystems])
return {
"name": name,
"applied": deployed,
"synced": sync_result.affected_subsystems,
}
@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 = 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)
# Update dnsmasq apply hash so pending-changes detection stays correct
dm_cfg = _get_dm_cfg()
dm_cfg[_APPLY_HASH_KEY] = config_hash(dm_cfg)
_save_dm_cfg(dm_cfg)
logger.info("Synced %d DNS upstreams to dnsmasq", len(upstreams))
except Exception:
logger.warning("Failed to sync DNS upstreams to dnsmasq", exc_info=True)
cfg_after = get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent("network", "config_saved", {"action": "config_applied"})
)
refresh_state(["network", *sync_result.affected_subsystems])
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],
"synced": sync_result.affected_subsystems,
}
@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)
sync_result = bus.emit(
SyncEvent("network", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["network", *sync_result.affected_subsystems])
return {"name": name, "value": value}