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:
+52
-11
@@ -83,6 +83,23 @@ class Registry:
|
||||
"""
|
||||
return self._routes.get((method.upper(), path))
|
||||
|
||||
def match(self, method: str, path: str):
|
||||
"""Match *path* against registered patterns, returning handler + params.
|
||||
|
||||
Patterns may contain ``<param>`` segments (e.g. ``/foo/<name>``).
|
||||
Matching segments are captured into a dict and merged into *body*.
|
||||
|
||||
Returns:
|
||||
Tuple of (handler_fn, params_dict) or (None, None) if no match.
|
||||
"""
|
||||
for (reg_method, reg_path), fn in self._routes.items():
|
||||
if reg_method != method.upper():
|
||||
continue
|
||||
pat_params = _match_path(reg_path, path)
|
||||
if pat_params is not None:
|
||||
return fn, pat_params
|
||||
return None, None
|
||||
|
||||
|
||||
registry = Registry()
|
||||
|
||||
@@ -127,6 +144,29 @@ def error(msg: str, code: int = 400) -> web.Response:
|
||||
return web.json_response({"ok": False, "error": msg}, status=code)
|
||||
|
||||
|
||||
def _match_path(pattern: str, path: str) -> dict[str, str] | None:
|
||||
"""Match *path* against a URL pattern containing ``<param>`` segments.
|
||||
|
||||
Args:
|
||||
pattern: URL pattern like ``/network/interfaces/<name>``.
|
||||
path: Actual request path like ``/network/interfaces/eth1``.
|
||||
|
||||
Returns:
|
||||
Dict mapping param names to their matched values, or ``None`` if no match.
|
||||
"""
|
||||
p_parts = pattern.strip("/").split("/")
|
||||
r_parts = path.strip("/").split("/")
|
||||
if len(p_parts) != len(r_parts):
|
||||
return None
|
||||
params: dict[str, str] = {}
|
||||
for p_seg, r_seg in zip(p_parts, r_parts, strict=True):
|
||||
if p_seg.startswith("<") and p_seg.endswith(">"):
|
||||
params[p_seg[1:-1]] = r_seg
|
||||
elif p_seg != r_seg:
|
||||
return None
|
||||
return params
|
||||
|
||||
|
||||
async def _handle_request(request: web.Request) -> web.Response:
|
||||
"""Dispatch a request to the appropriate handler.
|
||||
|
||||
@@ -136,27 +176,25 @@ async def _handle_request(request: web.Request) -> web.Response:
|
||||
Returns:
|
||||
The handler's response.
|
||||
"""
|
||||
handler_fn = registry.get(request.method, request.path)
|
||||
handler_fn, pat_params = registry.match(request.method, request.path)
|
||||
if handler_fn is None:
|
||||
return error(f"Method {request.method} not allowed for {request.path}", 404)
|
||||
|
||||
# Build body from JSON and merge query params. GET requests send params
|
||||
# as URL query string, so they need to be treated as body for handlers.
|
||||
body: dict[str, Any] | None = None
|
||||
# Build body — merge order (highest wins): path params > JSON body > query params.
|
||||
# Path params come from the URL path (e.g. /interfaces/eth0) and should not
|
||||
# be overridable by body or query parameters.
|
||||
body: dict[str, Any] | None = pat_params if pat_params else None
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
body = await request.json()
|
||||
json_body = await request.json()
|
||||
body = {**json_body, **body} if body is not None else json_body
|
||||
except json.JSONDecodeError:
|
||||
return error("Invalid JSON body", 400)
|
||||
|
||||
query_dict = dict(request.query)
|
||||
if query_dict:
|
||||
query_body = {k: v[0] if len(v) == 1 else v for k, v in query_dict.items()}
|
||||
if body is not None:
|
||||
merged = {**query_body, **body}
|
||||
body = merged
|
||||
else:
|
||||
body = query_body
|
||||
body = {**query_body, **body} if body is not None else query_body
|
||||
|
||||
try:
|
||||
if body is not None:
|
||||
@@ -216,7 +254,7 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
results[op_id] = {"ok": False, "error": "'id' and 'path' are required"}
|
||||
continue
|
||||
|
||||
handler_fn = registry.get(method, path)
|
||||
handler_fn, pat_params = registry.match(method, path)
|
||||
if handler_fn is None:
|
||||
results[op_id] = {
|
||||
"ok": False,
|
||||
@@ -225,6 +263,8 @@ async def _handle_batch(request: web.Request) -> web.Response:
|
||||
continue
|
||||
|
||||
op_body = op.get("body")
|
||||
if pat_params:
|
||||
op_body = {**(op_body or {}), **pat_params}
|
||||
|
||||
try:
|
||||
result = handler_fn(None, op_body)
|
||||
@@ -320,6 +360,7 @@ def _register_routes() -> None:
|
||||
dnsmasq, # noqa: F401
|
||||
firewall, # noqa: F401
|
||||
logs, # noqa: F401
|
||||
network, # noqa: F401
|
||||
nginx, # noqa: F401
|
||||
wireguard, # noqa: F401
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user