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
150 lines
4.1 KiB
Python
150 lines
4.1 KiB
Python
"""Network management API blueprint.
|
|
|
|
Exposes /api/network/* and delegates to vacuum-walld for interface
|
|
IP configuration via systemd-networkd.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from flask import Blueprint, request
|
|
|
|
from daemon.client import NotFound, get, post
|
|
from webui.api.common import _error, _ok
|
|
|
|
logger = logging.getLogger(__name__)
|
|
bp = Blueprint("network", __name__)
|
|
|
|
|
|
@bp.route("/interfaces", methods=["GET"])
|
|
def list_interfaces():
|
|
"""List all interfaces with their network config and runtime state.
|
|
|
|
Endpoint:
|
|
GET /api/network/interfaces
|
|
|
|
Returns:
|
|
JSON with interface config + runtime state.
|
|
"""
|
|
try:
|
|
return _ok(get("/network/interfaces"))
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to list network interfaces: %s", exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/interfaces/<name>", methods=["GET"])
|
|
def get_interface(name: str):
|
|
"""Get config + runtime state for a specific interface.
|
|
|
|
Endpoint:
|
|
GET /api/network/interfaces/<name>
|
|
|
|
Returns:
|
|
JSON with interface config and runtime state.
|
|
"""
|
|
try:
|
|
return _ok(get("/network/interfaces/" + name, {"name": name}))
|
|
except NotFound as exc:
|
|
logger.info("Interface '%s' not found: %s", name, exc)
|
|
return _error(str(exc), 404)
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to get interface '%s': %s", name, exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/interfaces/<name>", methods=["POST"])
|
|
def save_interface(name: str):
|
|
"""Save and apply network config for an interface.
|
|
|
|
Endpoint:
|
|
POST /api/network/interfaces/<name>
|
|
|
|
Args:
|
|
body: JSON with addresses, gateway, dns, routes.
|
|
|
|
Returns:
|
|
JSON confirmation.
|
|
"""
|
|
body = request.get_json(silent=True) or {}
|
|
try:
|
|
post("/network/interfaces/" + name, body)
|
|
logger.info("Interface '%s' config saved", name)
|
|
return _ok({"name": name, "applied": True})
|
|
except NotFound as exc:
|
|
return _error(str(exc), 404)
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to save interface '%s': %s", name, exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/interfaces/<name>/reload", methods=["POST"])
|
|
def reload_interface(name: str):
|
|
"""Reload networkd for a single interface.
|
|
|
|
Endpoint:
|
|
POST /api/network/interfaces/<name>/reload
|
|
|
|
Returns:
|
|
JSON confirmation.
|
|
"""
|
|
try:
|
|
post("/network/interfaces/" + name + "/reload", {"name": name})
|
|
logger.info("Interface '%s' reloaded", name)
|
|
return _ok({"name": name, "reloaded": True})
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to reload interface '%s': %s", name, exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/apply", methods=["POST"])
|
|
def apply_all():
|
|
"""Apply network config for ALL interfaces (full sync).
|
|
|
|
Endpoint:
|
|
POST /api/network/apply
|
|
|
|
Returns:
|
|
JSON with number of interfaces applied.
|
|
"""
|
|
try:
|
|
result = post("/network/apply", {})
|
|
logger.info("Network config applied: %d interfaces", result.get("applied", 0))
|
|
return _ok(result)
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to apply network config: %s", exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/infer-dhcp-ranges", methods=["GET"])
|
|
def infer_dhcp_ranges():
|
|
"""Suggest candidate DHCP ranges based on static interface IPs.
|
|
|
|
Endpoint:
|
|
GET /api/network/infer-dhcp-ranges
|
|
|
|
Returns:
|
|
JSON with per-interface suggested DHCP ranges.
|
|
"""
|
|
try:
|
|
return _ok(get("/network/infer-dhcp-ranges"))
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to infer DHCP ranges: %s", exc)
|
|
return _error(str(exc), 500)
|
|
|
|
|
|
@bp.route("/infer-zones", methods=["GET"])
|
|
def infer_zones():
|
|
"""Suggest firewalld zone assignments for configured interfaces.
|
|
|
|
Endpoint:
|
|
GET /api/network/infer-zones
|
|
|
|
Returns:
|
|
JSON with per-interface suggested zone names.
|
|
"""
|
|
try:
|
|
return _ok(get("/network/infer-zones"))
|
|
except RuntimeError as exc:
|
|
logger.error("Failed to infer zones: %s", exc)
|
|
return _error(str(exc), 500)
|