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:
@@ -35,6 +35,11 @@ def _wg(func, **kw):
|
||||
return _patch(f"webui.api.wireguard.{func}", **kw)
|
||||
|
||||
|
||||
def _ne(func, **kw):
|
||||
"""Patch daemon.client.{func} in the network blueprint namespace."""
|
||||
return _patch(f"webui.api.network.{func}", **kw)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
from flask import Flask
|
||||
@@ -42,11 +47,13 @@ def client():
|
||||
from webui.api.certs import bp as certs_bp
|
||||
from webui.api.dhcp import bp as dhcp_bp
|
||||
from webui.api.firewall import bp as firewall_bp
|
||||
from webui.api.network import bp as network_bp
|
||||
from webui.api.proxy import bp as proxy_bp
|
||||
from webui.api.wireguard import bp as wg_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
|
||||
app.register_blueprint(network_bp, url_prefix="/api/network")
|
||||
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
|
||||
app.register_blueprint(proxy_bp, url_prefix="/api/proxy")
|
||||
app.register_blueprint(certs_bp, url_prefix="/api/certs")
|
||||
@@ -788,3 +795,106 @@ class TestProxyDomainUpdate:
|
||||
json={"backend_host": "10.0.0.2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Network
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestNetworkListInterfaces:
|
||||
@_ne("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = [
|
||||
{"name": "eth0", "config": {"addresses": ["10.0.0.1/24"]}}
|
||||
]
|
||||
resp = client.get("/api/network/interfaces")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
@_ne("get")
|
||||
def test_runtime_error(self, mock_get, client):
|
||||
mock_get.side_effect = RuntimeError("networkctl not found")
|
||||
resp = client.get("/api/network/interfaces")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestNetworkGetInterface:
|
||||
@_ne("get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = {
|
||||
"name": "eth0",
|
||||
"config": {"addresses": ["10.0.0.1/24"]},
|
||||
}
|
||||
resp = client.get("/api/network/interfaces/eth0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
@_ne("get")
|
||||
def test_not_found(self, mock_get, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_get.side_effect = NotFound("interface not found")
|
||||
resp = client.get("/api/network/interfaces/nonexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestNetworkSaveInterface:
|
||||
@_ne("post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = {"name": "eth0", "applied": True}
|
||||
resp = client.post(
|
||||
"/api/network/interfaces/eth0",
|
||||
json={
|
||||
"addresses": ["10.0.0.1/24"],
|
||||
"gateway": "10.0.0.254",
|
||||
"dns": ["8.8.8.8"],
|
||||
"routes": [],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
@_ne("post")
|
||||
def test_not_found(self, mock_post, client):
|
||||
from daemon.client import NotFound
|
||||
|
||||
mock_post.side_effect = NotFound("interface not found")
|
||||
resp = client.post("/api/network/interfaces/missing", json={})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestNetworkReloadInterface:
|
||||
@_ne("post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = {"name": "eth0", "reloaded": True}
|
||||
resp = client.post("/api/network/interfaces/eth0/reload")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
@_ne("post")
|
||||
def test_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("reload failed")
|
||||
resp = client.post("/api/network/interfaces/eth0/reload")
|
||||
assert resp.status_code == 500
|
||||
|
||||
|
||||
class TestNetworkApplyAll:
|
||||
@_ne("post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = {"applied": 2, "interfaces": ["eth0", "eth1"]}
|
||||
resp = client.post("/api/network/apply")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["applied"] == 2
|
||||
|
||||
@_ne("post")
|
||||
def test_runtime_error(self, mock_post, client):
|
||||
mock_post.side_effect = RuntimeError("apply failed")
|
||||
resp = client.post("/api/network/apply")
|
||||
assert resp.status_code == 500
|
||||
|
||||
Reference in New Issue
Block a user