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:
+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}
|
||||
|
||||
Reference in New Issue
Block a user