feat: add networkd subsystem and fix code review issues
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
This commit is contained in:
+18
-24
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
@@ -141,17 +140,9 @@ def _find_acme_bin() -> str:
|
||||
|
||||
def _get_acme_email() -> str:
|
||||
"""Read registered contact email from ACME account config."""
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip().strip("'\"")
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
from lib.acme import _read_acme_email
|
||||
|
||||
return _read_acme_email()
|
||||
|
||||
|
||||
def _get_state() -> dict[str, Any] | None:
|
||||
@@ -564,6 +555,16 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not email:
|
||||
raise ValueError("'email' is required")
|
||||
_run_acme(["--register-account", "-m", email])
|
||||
# Persist to declarative ACME config
|
||||
acme_cfg = PROJECT_DIR / "config" / "acme" / "config.json"
|
||||
acme_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
|
||||
_acme_data: dict[str, str] = {}
|
||||
if acme_cfg.is_file():
|
||||
_acme_data = _json.loads(acme_cfg.read_text())
|
||||
_acme_data["email"] = email
|
||||
acme_cfg.write_text(_json.dumps(_acme_data, indent=4) + "\n")
|
||||
logger.info("ACME email set to %s", email)
|
||||
refresh_state(["acme"])
|
||||
return {"email": email}
|
||||
@@ -573,19 +574,12 @@ def set_email(_request: Any, body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
def get_email(_request: Any, _body: Any) -> dict[str, Any]:
|
||||
"""GET /acme/email — return the currently configured ACME contact email."""
|
||||
ac = _get_acme_state()
|
||||
email = ""
|
||||
if ac:
|
||||
return {"email": ac.get("email", "")}
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||
account_conf = acme_home / "account.conf"
|
||||
if account_conf.is_file():
|
||||
text = account_conf.read_text()
|
||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||
if match:
|
||||
return {"email": match.group(1).strip().strip("'\"")}
|
||||
except OSError:
|
||||
pass
|
||||
return {"email": ""}
|
||||
email = ac.get("email", "")
|
||||
if not email:
|
||||
email = _get_acme_email()
|
||||
return {"email": email}
|
||||
|
||||
|
||||
@registry.register("GET", "/acme/paths")
|
||||
|
||||
+45
-17
@@ -14,6 +14,7 @@ from daemon.server import NotFoundError, refresh_state, registry
|
||||
from lib.common import load_json, run, save_json
|
||||
from lib.firewall import (
|
||||
_normalize_target,
|
||||
_parse_active_zones,
|
||||
_parse_zone_output,
|
||||
)
|
||||
from lib.firewall import (
|
||||
@@ -409,24 +410,31 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
raise ValueError("'zone' is required")
|
||||
if zone not in run(["firewall-cmd", "--get-zones"], sudo=True).split():
|
||||
raise NotFoundError(f"Zone '{zone}' does not exist")
|
||||
try:
|
||||
current = _parse_zone_output(
|
||||
zone, run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
|
||||
).get("interfaces", [])
|
||||
except Exception:
|
||||
current = []
|
||||
for iface in current:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={zone}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Determine old zone for each interface being reassigned
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
active = _parse_active_zones(active_raw)
|
||||
|
||||
for iface in interfaces:
|
||||
# Find which zone currently owns this interface
|
||||
old_zone = None
|
||||
for az, az_ifaces in active.items():
|
||||
if iface in az_ifaces:
|
||||
old_zone = az
|
||||
break
|
||||
# Remove from old zone (if different from target)
|
||||
if old_zone and old_zone != zone:
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
f"--zone={old_zone}",
|
||||
"--remove-interface=" + iface,
|
||||
"--permanent",
|
||||
],
|
||||
sudo=True,
|
||||
check=False,
|
||||
)
|
||||
# Add to target zone
|
||||
run(
|
||||
[
|
||||
"firewall-cmd",
|
||||
@@ -436,7 +444,27 @@ def set_zone_interfaces(_request: Any, body: dict[str, Any] | None) -> dict[str,
|
||||
],
|
||||
sudo=True,
|
||||
)
|
||||
|
||||
_reload()
|
||||
|
||||
# Update config
|
||||
cfg = _get_config()
|
||||
cfg.setdefault("zones", {})
|
||||
cfg["zones"].setdefault(zone, {})
|
||||
cfg["zones"][zone]["interfaces"] = list(interfaces)
|
||||
# Remove interface from any old zone in config
|
||||
for old_zone_name, old_zone_cfg in cfg["zones"].items():
|
||||
if old_zone_name == zone:
|
||||
continue
|
||||
old_ifaces = old_zone_cfg.get("interfaces", [])
|
||||
new_ifaces = [i for i in old_ifaces if i not in interfaces]
|
||||
if len(new_ifaces) < len(old_ifaces):
|
||||
if new_ifaces:
|
||||
old_zone_cfg["interfaces"] = new_ifaces
|
||||
elif "interfaces" in old_zone_cfg:
|
||||
del old_zone_cfg["interfaces"]
|
||||
_save_config(cfg)
|
||||
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
refresh_state(["firewall"])
|
||||
return {"zone": zone, "interfaces": interfaces}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""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}
|
||||
+52
-11
@@ -83,6 +83,23 @@ class Registry:
|
||||
"""
|
||||
return self._routes.get((method.upper(), path))
|
||||
|
||||
def match(self, method: str, path: str):
|
||||
"""Match *path* against registered patterns, returning handler + params.
|
||||
|
||||
Patterns may contain ``<param>`` segments (e.g. ``/foo/<name>``).
|
||||
Matching segments are captured into a dict and merged into *body*.
|
||||
|
||||
Returns:
|
||||
Tuple of (handler_fn, params_dict) or (None, None) if no match.
|
||||
"""
|
||||
for (reg_method, reg_path), fn in self._routes.items():
|
||||
if reg_method != method.upper():
|
||||
continue
|
||||
pat_params = _match_path(reg_path, path)
|
||||
if pat_params is not None:
|
||||
return fn, pat_params
|
||||
return None, None
|
||||
|
||||
|
||||
registry = Registry()
|
||||
|
||||
@@ -127,6 +144,29 @@ def error(msg: str, code: int = 400) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": msg}, status=code)
|
||||
|
||||
|
||||
def _match_path(pattern: str, path: str) -> dict[str, str] | None:
|
||||
"""Match *path* against a URL pattern containing ``<param>`` segments.
|
||||
|
||||
Args:
|
||||
pattern: URL pattern like ``/network/interfaces/<name>``.
|
||||
path: Actual request path like ``/network/interfaces/eth1``.
|
||||
|
||||
Returns:
|
||||
Dict mapping param names to their matched values, or ``None`` if no match.
|
||||
"""
|
||||
p_parts = pattern.strip("/").split("/")
|
||||
r_parts = path.strip("/").split("/")
|
||||
if len(p_parts) != len(r_parts):
|
||||
return None
|
||||
params: dict[str, str] = {}
|
||||
for p_seg, r_seg in zip(p_parts, r_parts, strict=True):
|
||||
if p_seg.startswith("<") and p_seg.endswith(">"):
|
||||
params[p_seg[1:-1]] = r_seg
|
||||
elif p_seg != r_seg:
|
||||
return None
|
||||
return params
|
||||
|
||||
|
||||
async def _handle_request(request: web.Request) -> web.Response:
|
||||
"""Dispatch a request to the appropriate handler.
|
||||
|
||||
@@ -136,27 +176,25 @@ async def _handle_request(request: web.Request) -> web.Response:
|
||||
Returns:
|
||||
The handler's response.
|
||||
"""
|
||||
handler_fn = registry.get(request.method, request.path)
|
||||
handler_fn, pat_params = registry.match(request.method, request.path)
|
||||
if handler_fn is None:
|
||||
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
||||
|
||||
# Build body from JSON and merge query params. GET requests send params
|
||||
# as URL query string, so they need to be treated as body for handlers.
|
||||
body: dict[str, Any] | None = None
|
||||
# Build body — merge order (highest wins): path params > JSON body > query params.
|
||||
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
|
||||
# be overridable by body or query parameters.
|
||||
body: dict[str, Any] | None = pat_params if pat_params else None
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
body = await request.json()
|
||||
json_body = await request.json()
|
||||
body = {**json_body, **body} if body is not None else json_body
|
||||
except json.JSONDecodeError:
|
||||
return error("Invalid JSON body", 400)
|
||||
|
||||
query_dict = dict(request.query)
|
||||
if query_dict:
|
||||
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
||||
if body is not None:
|
||||
merged = {**query_body, **body}
|
||||
body = merged
|
||||
else:
|
||||
body = query_body
|
||||
body = {**query_body, **body} if body is not None else query_body
|
||||
|
||||
try:
|
||||
if body is not None:
|
||||
@@ -216,7 +254,7 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
|
||||
continue
|
||||
|
||||
handler_fn = registry.get(method, path)
|
||||
handler_fn, pat_params = registry.match(method, path)
|
||||
if handler_fn is None:
|
||||
results[op_id] = {
|
||||
"ok": False,
|
||||
@@ -225,6 +263,8 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
continue
|
||||
|
||||
op_body = op.get("body")
|
||||
if pat_params:
|
||||
op_body = {**(op_body or {}), **pat_params}
|
||||
|
||||
try:
|
||||
result = handler_fn(None, op_body)
|
||||
@@ -320,6 +360,7 @@ def _register_routes() -> None:
|
||||
dnsmasq, # noqa: F401
|
||||
firewall, # noqa: F401
|
||||
logs, # noqa: F401
|
||||
network, # noqa: F401
|
||||
nginx, # noqa: F401
|
||||
wireguard, # noqa: F401
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user