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:
@@ -0,0 +1,149 @@
|
||||
"""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)
|
||||
@@ -20,10 +20,12 @@ from flask import Flask, render_template, request
|
||||
|
||||
from daemon.client import get
|
||||
from lib.logging import setup_logging
|
||||
from lib.network import get_config
|
||||
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.logs import bp as logs_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 wireguard_bp
|
||||
|
||||
@@ -78,6 +80,7 @@ app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.urandom(32).hex()
|
||||
|
||||
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")
|
||||
@@ -86,6 +89,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
|
||||
|
||||
BLUEPRINTS = [
|
||||
("firewall", firewall_bp),
|
||||
("network", network_bp),
|
||||
("dhcp", dhcp_bp),
|
||||
("proxy", proxy_bp),
|
||||
("certs", certs_bp),
|
||||
@@ -353,9 +357,11 @@ def interfaces_page():
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
network_config = _safely(get_config, {})
|
||||
return render_template(
|
||||
"interfaces.html",
|
||||
interfaces=fw_state.get("interfaces", []),
|
||||
network_config=network_config,
|
||||
zones=fw_state.get("active_zones", {}).keys() or [],
|
||||
firewall_config=_safely(_fw_config_get, {}),
|
||||
firewall_pending=fw_state.get("pending", {}),
|
||||
@@ -433,11 +439,13 @@ def dhcp_page():
|
||||
"""
|
||||
all_status = _safely(_load_status_all, {})
|
||||
dm_state = all_status.get("dnsmasq", {}) or {}
|
||||
fw_state = all_status.get("firewall", {}) or {}
|
||||
return render_template(
|
||||
"dhcp.html",
|
||||
config=dm_state.get("config", {}),
|
||||
status=dm_state.get("status", {}),
|
||||
leases=dm_state.get("leases", []),
|
||||
interfaces=fw_state.get("interfaces", []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ const showSuccessToast = (msg) => showToast(msg, 'success');
|
||||
|
||||
const showErrorToast = (msg) => showToast(msg, 'error');
|
||||
|
||||
const showWarningToast = (msg) => showToast(msg, 'warning');
|
||||
|
||||
// Modal helpers
|
||||
const openModal = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -495,3 +497,82 @@ function renderIssueSteps(steps, status) {
|
||||
container.innerHTML += '<div style="margin-top:12px;text-align:center;"><span class="badge badge-success" style="font-size:13px;padding:4px 12px;">✓ Certificate issued</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Network Interface Config helpers ─────────────────────────────
|
||||
|
||||
const saveInterfaceConfig = (ifaceName) => {
|
||||
const addrs = (document.getElementById('addrs-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const gateway = (document.getElementById('gw-' + ifaceName)?.value || '').trim();
|
||||
const dns = (document.getElementById('dns-' + ifaceName)?.value || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const routesContainer = document.getElementById('routes-' + ifaceName);
|
||||
let routes = [];
|
||||
if (routesContainer) {
|
||||
routes = Array.from(routesContainer.querySelectorAll('.route-row')).map(row => {
|
||||
const dest = (row.querySelector('.route-dest')?.value || '').trim();
|
||||
const gw = (row.querySelector('.route-gw')?.value || '').trim();
|
||||
if (dest || gw) return { destination: dest, gateway: gw };
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ addresses: addrs, gateway: gateway || undefined, dns: dns, routes: routes })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok && data.data && data.data.applied === false) {
|
||||
showWarningToast('Config saved for ' + ifaceName + ' (system deploy skipped — not running as privileged)');
|
||||
} else if (data.ok) {
|
||||
showSuccessToast('Config saved for ' + ifaceName);
|
||||
} else {
|
||||
showErrorToast(data.error || 'Failed to save config');
|
||||
}
|
||||
})
|
||||
.catch(e => { showErrorToast('Failed to save config: ' + e.message); });
|
||||
};
|
||||
|
||||
const reloadNetworkd = (ifaceName) => {
|
||||
fetch('/api/network/interfaces/' + encodeURIComponent(ifaceName) + '/reload', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
showSuccessToast('Network reload triggered for ' + ifaceName);
|
||||
} else {
|
||||
showErrorToast(data.error || 'Reload failed');
|
||||
}
|
||||
})
|
||||
.catch(e => { showErrorToast('Reload failed: ' + e.message); });
|
||||
};
|
||||
|
||||
const toggleRoutes = (ifaceName) => {
|
||||
const panel = document.getElementById('routes-panel-' + ifaceName);
|
||||
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
const addRoute = (ifaceName) => {
|
||||
const container = document.getElementById('routes-' + ifaceName);
|
||||
if (!container) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'route-row';
|
||||
row.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:4px;';
|
||||
row.innerHTML = '<input type="text" class="route-dest" placeholder="Destination CIDR" style="flex:1;" />' +
|
||||
'<input type="text" class="route-gw" placeholder="Gateway" style="flex:1;" />' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button>';
|
||||
container.appendChild(row);
|
||||
};
|
||||
|
||||
const renderNetworkRoutes = (routes, containerId) => {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const safe = (s) => escHtml(String(s || ''));
|
||||
container.innerHTML = (routes || [])
|
||||
.map((r, i) =>
|
||||
'<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">' +
|
||||
'<input type="text" class="route-dest" value="' + safe(r.destination) + '" placeholder="Destination CIDR" style="flex:1;" />' +
|
||||
'<input type="text" class="route-gw" value="' + safe(r.gateway) + '" placeholder="Gateway" style="flex:1;" />' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button></div>'
|
||||
).join('') || '<div class="text-muted text-sm">No static routes</div>';
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -18,12 +18,19 @@
|
||||
<th>IP Address</th>
|
||||
<th>State</th>
|
||||
<th>Zone</th>
|
||||
<th>IP Config</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interface-list">
|
||||
{% for iface in (interfaces or []) %}
|
||||
<tr>
|
||||
<td><strong>{{ iface.get('display_name', iface.get('name', 'unknown')) }}</strong></td>
|
||||
{% set entry = ((network_config or {}).get('interfaces') or {}).get(iface.get('name')) or {} %}
|
||||
{% set addrs = (entry.get('addresses') or []) | join(', ') %}
|
||||
{% set gw = entry.get('gateway') or '' %}
|
||||
{% set dns_list = (entry.get('dns') or []) | join(', ') %}
|
||||
{% set routes = entry.get('routes') or [] %}
|
||||
<tr data-iface="{{ iface.get('name', '') }}">
|
||||
<td><strong>{{ iface.get('name', 'unknown') }}</strong></td>
|
||||
<td class="text-muted">{{ iface.get('mac', 'N/A') }}</td>
|
||||
<td>
|
||||
{% for ip in iface.get('ips', []) %}
|
||||
@@ -38,7 +45,7 @@
|
||||
<td>
|
||||
{% if zones %}
|
||||
<select
|
||||
hx-on::change="fetch('/api/firewall/zones/'+encodeURIComponent(this.value)+'/interfaces',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interfaces:['{{ iface.name }}']})}).then(r=>{if(!r.ok)throw r}).then(r=>r.ok?(showSuccessToast('{{ iface.display_name }} assigned to '+this.value),refreshTable('/api/firewall/interfaces',document.getElementById('interface-list'),renderInterfaces)):r.json().then(j=>{throw new Error(j.error||r.statusText)})).catch(e=>{showErrorToast(e.message);this.selectedIndex=0})"
|
||||
hx-on::change="assignZone('{{ iface.get('name', '') }}', this)"
|
||||
>
|
||||
{% for zname in zones %}
|
||||
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
|
||||
@@ -48,14 +55,57 @@
|
||||
<span class="text-muted">No zones configured</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="min-width: 280px;">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="margin-bottom:4px;">
|
||||
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">Addresses</label>
|
||||
<input type="text" id="addrs-{{ iface.get('name', '') }}" value="{{ addrs }}" placeholder="192.168.1.1/24" />
|
||||
</div>
|
||||
<div style="margin-bottom:4px;">
|
||||
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">Gateway</label>
|
||||
<input type="text" id="gw-{{ iface.get('name', '') }}" value="{{ gw }}" placeholder="e.g. 192.168.1.254" />
|
||||
</div>
|
||||
<div style="margin-bottom:4px;">
|
||||
<label style="display:block;font-size:11px;color:var(--text-muted);margin-bottom:3px;">DNS</label>
|
||||
<input type="text" id="dns-{{ iface.get('name', '') }}" value="{{ dns_list }}" placeholder="1.1.1.1, 8.8.8.8" />
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="toggleRoutes('{{ iface.get('name', '') }}')" style="font-size:11px;width:100%;justify-content:center;">
|
||||
▼ Routes
|
||||
</button>
|
||||
<div id="routes-panel-{{ iface.get('name', '') }}" style="display:none;margin-top:6px;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--input-bg);">
|
||||
<div id="routes-{{ iface.get('name', '') }}">
|
||||
{% if routes %}
|
||||
{% for route in routes %}
|
||||
<div class="route-row" style="display:flex;gap:6px;align-items:center;margin-bottom:4px;">
|
||||
<input type="text" class="route-dest" value="{{ route.get('destination', '') }}" placeholder="Destination CIDR" style="flex:1;" />
|
||||
<input type="text" class="route-gw" value="{{ route.get('gateway', '') }}" placeholder="Gateway" style="flex:1;" />
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="this.parentElement.remove();">✕</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-muted text-sm">No static routes</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="addRoute('{{ iface.get('name', '') }}')" style="margin-top:4px;font-size:11px;">+ Add Route</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex" style="flex-direction:column;gap:6px;">
|
||||
<button class="btn btn-sm btn-primary" onclick="saveInterfaceConfig('{{ iface.get('name', '') }}')">Save</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="reloadNetworkd('{{ iface.get('name', '') }}')">Reload</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not (interfaces or []) %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-muted text-sm">No interfaces found</td>
|
||||
<td colspan="7" class="text-muted text-sm">No interfaces found</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user