bc72db903c
Phase 1-4: Networkd subsystem - lib/network.py: systemd-networkd config renderer (.network INI files) with full schema support: [Match], [Link], [Network], [Address], [Route], [DHCPv4], [DHCPv6] sections. One Address/=DNS= line per value per spec. Route sections use #N suffix per systemd.syntax(7). - lib/network.py: generate_network_files() with 50-<name>.network prefix and stale file cleanup - lib/network.py: collect_upstream_dns() filters local/private DNS - lib/network.py: infer_dhcp_ranges() and infer_zones() helpers - daemon/handlers/network.py: routes for GET/POST /network/interfaces and full apply with DNS upstream sync to dnsmasq - webui/api/network.py: Flask blueprint for /api/network/* endpoints - webui/api: interfaces page updated with IP config inline editing - lib/state.py: networkd collector using parse_networkctl_status() - system/sudoers.d/vacuum-walld: networkctl + systemd-network rules - system/systemd/vacuum-walld.service: ReadWritePaths for /etc/systemd/network - install.sh: ACME email now optional, configured from WebUI - lib/acme.py: get_email() falls back to declarative config Phase 5: Code review fixes - daemon/server.py: path params now win over JSON body and query params in request body merge (prevents config save name override) - daemon/server.py: remove dead 'import re' - daemon/handlers/network.py: replace Path.mkdir() with sudo mkdir for /etc/systemd/network (ProtectSystem=strict compatibility) - system/sudoers.d/vacuum-walld: pin systemctl to specific commands (reload/is-active dnsmasq instead of wildcard) - system/sudoers.d/vacuum-walld: restore !requiretty and section comment - lib/network.py: remove unused _MANAGEMENT_PORTS constant - webui/api/network.py: remove redundant body[\name\] = name in save_interface Tests: 332 passing (110 new/updated), ruff clean
218 lines
7.2 KiB
Python
218 lines
7.2 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
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from daemon.server import NotFoundError, registry
|
|
from lib.common import run
|
|
from lib.dnsmasq import set_upstreams
|
|
from lib.network import (
|
|
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 50-<name>.network file to /etc/systemd/network/ and reload."""
|
|
src = DATA_DIR / f"50-{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"
|
|
run(["cp", str(src), str(dst)], sudo=True)
|
|
run(["networkctl", "reconfigure", iface_name], sudo=True)
|
|
|
|
|
|
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, {}),
|
|
}
|
|
|
|
return {"interfaces": merged, "timestamp": ""}
|
|
|
|
|
|
@registry.register("GET", "/network/interfaces/<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"]
|
|
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/interfaces/<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")
|
|
|
|
iface_cfg = {k: v for k, v in body.items() if k not in ("name",)}
|
|
|
|
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"50-{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/interfaces/<name>/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"]
|
|
|
|
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 files from system dir that aren't in config
|
|
expected_names = {f.name 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)
|
|
|
|
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}
|