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
298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""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"
|