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:
+42
-20
@@ -7,7 +7,6 @@ state instead of invoking subprocesses on every request.
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
@@ -23,6 +22,7 @@ from lib.firewall import (
|
||||
from lib.firewall import (
|
||||
config_pending as _config_pending,
|
||||
)
|
||||
from lib.network import parse_networkctl_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +52,7 @@ class State:
|
||||
"nginx",
|
||||
"acme",
|
||||
"wireguard",
|
||||
"networkd",
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -184,7 +185,7 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
raw_name = parts[1].rstrip(":")
|
||||
raw_name = parts[1].rstrip(":").split("@")[0]
|
||||
iface_state = "UNKNOWN"
|
||||
mtu = None
|
||||
mac = None
|
||||
@@ -197,7 +198,6 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
mac = parts[i + 1]
|
||||
iface_map[raw_name] = {
|
||||
"name": raw_name,
|
||||
"display_name": raw_name.partition("@")[0],
|
||||
"mac": mac,
|
||||
"state": iface_state,
|
||||
"mtu": mtu,
|
||||
@@ -215,15 +215,14 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
addr_name = parts[1]
|
||||
addr_key = "ipv6" if parts[2] == "inet6" else "ips"
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == addr_name:
|
||||
if entry["name"] == addr_name:
|
||||
entry[addr_key].append(parts[3])
|
||||
break
|
||||
|
||||
for zone_name, ifaces in active.items():
|
||||
for raw_if in ifaces:
|
||||
clean = raw_if.partition("@")[0]
|
||||
for entry in iface_map.values():
|
||||
if entry["display_name"] == clean or entry["name"] == raw_if:
|
||||
if entry["name"] == raw_if:
|
||||
entry["zone"] = zone_name
|
||||
break
|
||||
|
||||
@@ -571,21 +570,12 @@ def _has_auto_renew(domain: str) -> bool:
|
||||
def _get_acme_email() -> str:
|
||||
"""Read the ACME ``acme.sh`` email from the account config file.
|
||||
|
||||
Returns:
|
||||
Email string, or empty string if not found.
|
||||
Falls back to the declarative ACME config (config/acme/config.json)
|
||||
if acme.sh account has not been registered yet.
|
||||
"""
|
||||
acme_home_default = str(PROJECT_DIR / "data" / "acme")
|
||||
try:
|
||||
acme_home = Path(os.environ.get("ACME_HOME", acme_home_default))
|
||||
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 _collect_acme() -> dict[str, Any]:
|
||||
@@ -770,6 +760,38 @@ def _collect_wireguard() -> dict[str, Any]:
|
||||
|
||||
register_collector("wireguard", _collect_wireguard)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Networkd collector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_networkd() -> dict[str, Any]:
|
||||
"""Collect networkd interface state from networkctl.
|
||||
|
||||
Returns:
|
||||
Dict with interface runtime state parsed from networkctl output.
|
||||
Returns empty data if networkctl is not available.
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
try:
|
||||
raw = run(["networkctl", "status", "--all"], sudo=True)
|
||||
result = parse_networkctl_status(raw)
|
||||
if not result:
|
||||
return {"interfaces": {}, "timestamp": _now_iso()}
|
||||
except Exception:
|
||||
return {
|
||||
"interfaces": {},
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
return {
|
||||
"interfaces": result,
|
||||
"timestamp": _now_iso(),
|
||||
}
|
||||
|
||||
|
||||
register_collector("networkd", _collect_networkd)
|
||||
|
||||
__all__ = [
|
||||
"State",
|
||||
|
||||
Reference in New Issue
Block a user