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:
@@ -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>';
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user