75b86fd60d
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
broke the two-user model: a tree left owner-only by one user made
every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
status.error (AcmeState.status) so the certs page can surface it
firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport
status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm
system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes
nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)
install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership
daemon/server: loop.get_exception_handler() (aiohttp API fix)
tests: 888 pytest + 24 node passing; ruff clean
428 lines
15 KiB
Python
428 lines
15 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,
|
|
set_sysctl,
|
|
)
|
|
from lib import dnsmasq as _dm
|
|
from lib import firewall as _fw
|
|
from lib import network as _net
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_network(tmp_path):
|
|
# Handler endpoints emit "networkd" sync events; the subscribers
|
|
# (lib.sync.NetworkToAllSync) read/write the firewall and dnsmasq
|
|
# configs, and apply_all re-stamps the dnsmasq config. Point all of
|
|
# those paths at tmp so tests never touch the real config files.
|
|
orig_net = (_net.CONFIG_FILE, _net.DATA_DIR)
|
|
orig_dm = (_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR)
|
|
orig_fw = (_fw.CONFIG_DIR, _fw.CONFIG_FILE)
|
|
_net.CONFIG_FILE = tmp_path / "config" / "network" / "config.json"
|
|
_net.DATA_DIR = tmp_path / "data" / "networkd"
|
|
_dm.CONFIG_DIR = tmp_path / "config" / "dnsmasq"
|
|
_dm.DATA_DIR = tmp_path / "data" / "dnsmasq"
|
|
_dm.CONFIG_PATH = _dm.CONFIG_DIR / "config.json"
|
|
_dm.FRAGMENTS_DIR = _dm.DATA_DIR / "fragments"
|
|
_dm.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
_dm.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
_dm.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
_fw.CONFIG_DIR = tmp_path / "config" / "firewall"
|
|
_fw.CONFIG_FILE = _fw.CONFIG_DIR / "config.json"
|
|
_fw.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
yield tmp_path
|
|
_net.CONFIG_FILE, _net.DATA_DIR = orig_net
|
|
_dm.CONFIG_DIR, _dm.DATA_DIR, _dm.CONFIG_PATH, _dm.FRAGMENTS_DIR = orig_dm
|
|
_fw.CONFIG_DIR, _fw.CONFIG_FILE = orig_fw
|
|
|
|
|
|
# =================================================================
|
|
# 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 / "99-eth0.network").exists()
|
|
content = (data_dir / "99-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)
|
|
|
|
def test_save_interface_rejects_invalid_name(self, tmp_network):
|
|
invalid_names = [
|
|
"../../etc/passwd",
|
|
"eth 0",
|
|
"",
|
|
"eth/0",
|
|
"eth..0",
|
|
]
|
|
for invalid in invalid_names:
|
|
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"
|
|
)
|
|
with pytest.raises(ValueError, match="name"):
|
|
save_interface(None, {"name": invalid})
|
|
|
|
|
|
class TestReloadInterfaceValidation:
|
|
def test_reload_interface_rejects_invalid_name(self):
|
|
invalid_names = [
|
|
"../../etc/passwd",
|
|
"eth 0",
|
|
"",
|
|
"eth/0",
|
|
]
|
|
for invalid in invalid_names:
|
|
with pytest.raises(ValueError, match="name"):
|
|
reload_interface(None, {"name": invalid})
|
|
|
|
|
|
class TestGetInterfaceValidation:
|
|
def test_get_interface_rejects_invalid_name(self, tmp_network):
|
|
invalid_names = [
|
|
"../../etc/passwd",
|
|
"eth 0",
|
|
"",
|
|
"eth/0",
|
|
]
|
|
for invalid in invalid_names:
|
|
with pytest.raises(ValueError, match="name"):
|
|
get_interface(None, {"name": invalid})
|
|
|
|
|
|
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"]},
|
|
}
|
|
}
|
|
)
|
|
|
|
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
|
runtime_dir.mkdir(parents=True)
|
|
|
|
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=[]),
|
|
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
|
):
|
|
mock_gen.return_value = {
|
|
"generated": [_net.DATA_DIR / "99-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"],
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
|
runtime_dir.mkdir(parents=True)
|
|
|
|
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,
|
|
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
|
):
|
|
mock_gen.return_value = {
|
|
"generated": [_net.DATA_DIR / "99-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"]}}})
|
|
|
|
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
|
runtime_dir.mkdir(parents=True)
|
|
|
|
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,
|
|
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
|
):
|
|
mock_gen.return_value = {
|
|
"generated": [_net.DATA_DIR / "99-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")
|
|
|
|
runtime_dir = tmp_network / "run" / "vacuum-wall"
|
|
runtime_dir.mkdir(parents=True)
|
|
|
|
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=[]),
|
|
patch("daemon.handlers.network.RUNTIME_DIR", runtime_dir),
|
|
):
|
|
mock_gen.return_value = {
|
|
"generated": [_net.DATA_DIR / "99-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"
|
|
|
|
|
|
class TestSetSysctl:
|
|
def test_set_sysctl_success(self, tmp_network):
|
|
with (
|
|
patch("daemon.handlers.network.run") as mock_run,
|
|
patch.object(Path, "read_text", return_value="1"),
|
|
):
|
|
mock_run.return_value = "" # sysctl -w call
|
|
result = set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})
|
|
|
|
assert result["name"] == "net.ipv4.ip_forward"
|
|
assert result["value"] == "1"
|
|
assert mock_run.call_count == 1
|
|
assert mock_run.call_args_list[0].args == (
|
|
["sysctl", "-w", "net.ipv4.ip_forward=1"],
|
|
)
|
|
assert mock_run.call_args_list[0].kwargs == {"sudo": True}
|
|
|
|
def test_set_sysctl_rejects_slash_in_name(self):
|
|
with pytest.raises(ValueError, match="valid sysctl key"):
|
|
set_sysctl(None, {"name": "net.ipv4/ip_forward", "value": "1"})
|
|
|
|
def test_set_sysctl_rejects_double_dot(self):
|
|
with pytest.raises(ValueError, match="valid sysctl key"):
|
|
set_sysctl(None, {"name": "net..ipv4", "value": "1"})
|
|
|
|
def test_set_sysctl_requires_name(self):
|
|
with pytest.raises(ValueError, match="name"):
|
|
set_sysctl(None, {"value": "1"})
|
|
|
|
def test_set_sysctl_requires_value(self):
|
|
with pytest.raises(ValueError, match="value"):
|
|
set_sysctl(None, {"name": "net.ipv4.ip_forward"})
|
|
|
|
def test_set_sysctl_requires_body(self):
|
|
with pytest.raises(ValueError, match="body"):
|
|
set_sysctl(None, None)
|
|
|
|
def test_set_sysctl_verify_failure(self):
|
|
with (
|
|
patch("daemon.handlers.network.run") as mock_run,
|
|
patch.object(Path, "read_text", return_value="0"),
|
|
):
|
|
mock_run.return_value = "" # sysctl -w call succeeds
|
|
with pytest.raises(RuntimeError, match="verify failed"):
|
|
set_sysctl(None, {"name": "net.ipv4.ip_forward", "value": "1"})
|