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:
2026-06-01 03:15:50 +00:00
parent 2f215793e9
commit bc72db903c
26 changed files with 3294 additions and 121 deletions
+297
View File
@@ -0,0 +1,297 @@
"""Tests for daemon/handlers/network.py — handler endpoint logic."""
from pathlib import Path
from unittest.mock import patch
import pytest
from daemon.handlers.network import (
apply_all,
get_infer_dhcp_ranges,
get_infer_zones,
get_interface,
get_interfaces,
reload_interface,
save_interface,
)
from lib import network as _net
@pytest.fixture
def tmp_network(tmp_path):
orig_config = _net.CONFIG_FILE
orig_data = _net.DATA_DIR
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
_net.DATA_DIR = tmp_path / "data" / "networkd"
yield tmp_path
_net.CONFIG_FILE = orig_config
_net.DATA_DIR = orig_data
# =================================================================
# TF-12: Handler tests
# =================================================================
class TestSaveInterface:
def test_save_interface_saves_config(self, tmp_network):
with (
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd"
),
):
mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n"
save_interface(
None,
{"name": "eth0", "addresses": ["10.0.0.1/24"], "gateway": "10.0.0.254"},
)
cfg = _net.get_config()
assert "eth0" in cfg["interfaces"]
assert cfg["interfaces"]["eth0"]["addresses"] == ["10.0.0.1/24"]
def test_save_interface_renders_file(self, tmp_network):
with (
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.DATA_DIR", tmp_network / "data" / "networkd"
),
):
mock_run.return_value = "1: eth0 ethernet routable\n State: routable\n"
save_interface(
None,
{"name": "eth0", "addresses": ["10.0.0.1/24"]},
)
data_dir = tmp_network / "data" / "networkd"
assert (data_dir / "50-eth0.network").exists()
content = (data_dir / "50-eth0.network").read_text()
assert "Name=eth0" in content
assert "Address=10.0.0.1/24" in content
def test_save_interface_requires_name(self, tmp_network):
with pytest.raises(ValueError, match="name"):
save_interface(None, {"addresses": ["10.0.0.1/24"]})
def test_save_interface_requires_body(self):
with pytest.raises(ValueError, match="body"):
save_interface(None, None)
class TestReloadInterface:
def test_reload_interface(self):
with patch("daemon.handlers.network.run") as mock_run:
mock_run.return_value = "reloaded"
result = reload_interface(None, {"name": "eth0"})
assert result["name"] == "eth0"
assert result["reloaded"] is True
mock_run.assert_called_with(
["networkctl", "reconfigure", "eth0"], sudo=True
)
def test_reload_interface_requires_name(self):
with pytest.raises(ValueError, match="name"):
reload_interface(None, None)
def test_reload_interface_missing_name(self):
with pytest.raises(ValueError, match="name"):
reload_interface(None, {})
class TestApplyAll:
def test_apply_all_generates_files(self, tmp_network):
_net.save_config(
{
"interfaces": {
"eth0": {"addresses": ["10.0.0.1/24"]},
"eth1": {"addresses": ["192.168.1.1/24"]},
}
}
)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "50-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
result = apply_all(None, None)
assert result["applied"] == 1
assert len(result["files"]) == 1
def test_apply_all_syncs_dns(self, tmp_network):
_net.save_config(
{
"interfaces": {
"eth0": {
"dhcp": "ipv4",
"dns": ["8.8.8.8", "192.168.1.1"],
}
}
}
)
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.set_upstreams") as mock_set_upstreams,
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "50-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
mock_collect.return_value = ["8.8.8.8"]
apply_all(None, None)
mock_set_upstreams.assert_called_once_with(["8.8.8.8"])
def test_apply_all_handles_dns_sync_failure(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"dns": ["8.8.8.8"]}}})
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch(
"daemon.handlers.network.set_upstreams",
side_effect=RuntimeError("fail"),
),
patch("daemon.handlers.network.collect_upstream_dns") as mock_collect,
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "50-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
mock_collect.return_value = ["8.8.8.8"]
result = apply_all(None, None)
assert "applied" in result
def test_apply_all_removes_stale_system_files(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
sys_dir = tmp_network / "etc" / "systemd" / "network"
sys_dir.mkdir(parents=True)
(sys_dir / "stale-file.network").write_text("[Match]\nName=old\n")
with (
patch("daemon.handlers.network.generate_network_files") as mock_gen,
patch("daemon.handlers.network.run") as mock_run,
patch("daemon.handlers.network.collect_upstream_dns", return_value=[]),
):
mock_gen.return_value = {
"generated": [_net.DATA_DIR / "50-eth0.network"],
"cleaned": [],
}
mock_run.return_value = ""
class FakePath:
def __init__(self, p="/etc/systemd/network") -> None:
self._p = sys_dir if p == "/etc/systemd/network" else Path(p)
def exists(self):
return True
def iterdir(self):
return iter(self._p.iterdir())
def __truediv__(self, other):
return self._p / other
def mkdir(self, *args, **kwargs) -> None:
self._p.mkdir(parents=True, exist_ok=True)
with patch("daemon.handlers.network.Path", FakePath):
apply_all(None, None)
assert (sys_dir / "stale-file.network").exists()
class TestGetInterfaces:
def test_get_interfaces_returns_merged_data(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
with patch("daemon.handlers.network.run") as mock_run:
mock_run.return_value = (
"1: eth0 ethernet 10.0.0.0/24 routable\n"
" State: routable\n"
" Addresses: 10.0.0.1/24,\n"
)
result = get_interfaces(None, None)
assert "interfaces" in result
assert "eth0" in result["interfaces"]
assert "config" in result["interfaces"]["eth0"]
assert "runtime" in result["interfaces"]["eth0"]
def test_get_interfaces_handles_networkctl_failure(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
with patch(
"daemon.handlers.network.run", side_effect=RuntimeError("no networkctl")
):
result = get_interfaces(None, None)
assert "interfaces" in result
assert "eth0" in result["interfaces"]
class TestGetInterface:
def test_get_single_interface(self, tmp_network):
_net.save_config({"interfaces": {"eth0": {"addresses": ["10.0.0.1/24"]}}})
with patch("daemon.handlers.network.run") as mock_run:
mock_run.return_value = "1: eth0 ethernet\n State: routable\n"
result = get_interface(None, {"name": "eth0"})
assert result["name"] == "eth0"
assert "config" in result
assert result["config"]["addresses"] == ["10.0.0.1/24"]
def test_get_interface_not_found(self, tmp_network):
_net.save_config({"interfaces": {}})
with pytest.raises(Exception, match="not found"):
get_interface(None, {"name": "eth0"})
def test_get_interface_requires_name(self):
with pytest.raises(ValueError, match="required"):
get_interface(None, None)
class TestInferEndpoints:
def test_infer_dhcp_ranges_endpoint(self, tmp_network):
_net.save_config(
{
"interfaces": {
"eth0": {"addresses": [{"address": "192.168.1.1/24"}]},
}
}
)
result = get_infer_dhcp_ranges(None, None)
assert "ranges" in result
assert "eth0" in result["ranges"]
def test_infer_zones_endpoint(self, tmp_network):
_net.save_config(
{
"interfaces": {
"wg0": {},
"eth0": {"addresses": [{"address": "192.168.1.1/24"}]},
}
}
)
result = get_infer_zones(None, None)
assert "zones" in result
assert result["zones"]["wg0"] == "wan"
assert result["zones"]["eth0"] == "lan"