Files
vacuum-wall/daemon/handlers/network.py
T
mteehan 9c9f92ad04 fix: daemon /run spawn hardening, auth guard before first paint, WS refresh cap, interfaces runtime state
systemd: pre-create volatile /run paths so vacuum-walld's ProtectSystem=strict namespace setup cannot fail with 226/NAMESPACE — RuntimeDirectory=vacuum-wall nginx plus a tmpfiles.d spec (installed to /etc/tmpfiles.d/) covering /run/firewalld and /run/nginx.pid. Drop /run/sudo from ReadWritePaths: NOPASSWD children never need it, and its absence crash-looped restarts after sudo removed /run/sudo.

webui: run the auth session check before mounting the shell so logged-out visitors never flash the sidebar or a protected page; router guard and sidebar now react to auth state, and the login page renders full-bleed.

ws: cap refresh->reconnect episodes at 2 consecutive failures; if the WS path stays dead after a token refresh, abandon reconnection instead of looping refreshAuth forever (UI keeps working via REST until reload).

api: GET /api/network/interfaces now includes loopback and returns per-interface {config, runtime}; dashboard reads runtime.state (carrier counts as up) and the interfaces page filters lo client-side.

daemon: re-collect nginx state after lazy config migration (cached list went stale when the on-disk format changed under it), skip system_import.nginx when config.json already exists (re-parsing vacuum-wall's own generated sites is lossy), and poll nginx (60s) / acme (300s) state so file drift self-heals.
2026-08-19 15:32:36 +00:00

373 lines
13 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"
RUNTIME_DIR = Path("/run/vacuum-wall")
_ALLOWED_SYSCTL_KEYS: set[str] = {
"net.ipv4.ip_forward",
"net.ipv4.conf.all.forwarding",
"net.ipv4.conf.all.accept_redirects",
"net.ipv4.conf.default.accept_redirects",
"net.ipv4.conf.all.send_redirects",
"net.ipv4.conf.default.send_redirects",
"net.ipv4.conf.all.rp_filter",
"net.ipv4.icmp_echo_ignore_all",
"net.ipv4.tcp_syncookies",
}
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"
runtime_src = RUNTIME_DIR / f"99-{iface_name}.network"
dst_dir = Path("/etc/systemd/network")
RUNTIME_DIR.mkdir(exist_ok=True)
runtime_src.write_text(src.read_text())
run(["mkdir", "-p", str(dst_dir)], sudo=True)
dst = dst_dir / f"99-{iface_name}.network"
run(["cp", "--", str(runtime_src), str(dst)], sudo=True)
runtime_src.unlink(missing_ok=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())
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)
# Always stamp the hash so pending-changes detection stays current
# even when deployment fails (e.g. in containerized environments).
# The hash represents the JSON config state, not the system state.
cfg_after = get_config()
cfg_after[_APPLY_HASH_KEY] = config_hash(cfg_after)
save_config(cfg_after)
sync_result = bus.emit(
SyncEvent(
"networkd", "config_saved", {"action": "interface_saved", "interface": name}
)
)
refresh_state(["networkd", *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:
tmp = RUNTIME_DIR / f.name
run(["cp", "--", str(f), str(tmp)], sudo=False)
dst = sys_dir / f.name
run(["mkdir", "-p", str(sys_dir)], sudo=True)
run(["cp", "--", str(tmp), str(dst)], sudo=True)
tmp.unlink(missing_ok=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("networkd", "config_saved", {"action": "config_applied"})
)
refresh_state(["networkd", *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")
if name not in _ALLOWED_SYSCTL_KEYS:
raise ValueError("'name' is not a permitted 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("networkd", "config_saved", {"action": "sysctl_set", "name": name})
)
refresh_state(["networkd", *sync_result.affected_subsystems])
return {"name": name, "value": value}