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
111 lines
6.0 KiB
HTML
111 lines
6.0 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Interfaces - Vacuum Wall{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>Interfaces</h1>
|
|
<div class="subtitle">Network interface to zone bindings</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Interface</th>
|
|
<th>MAC Address</th>
|
|
<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 []) %}
|
|
{% 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', []) %}
|
|
{{ ip }}{% if not loop.last %}, {% endif %}
|
|
{% endfor %}
|
|
{% if not iface.get('ips') %}N/A{% endif %}
|
|
</td>
|
|
<td>
|
|
<span class="status-dot {{ 'status-up' if iface.get('state') == 'UP' else 'status-down' }}"></span>
|
|
{{ 'Up' if iface.get('state') == 'UP' else 'Down' }}
|
|
</td>
|
|
<td>
|
|
{% if zones %}
|
|
<select
|
|
hx-on::change="assignZone('{{ iface.get('name', '') }}', this)"
|
|
>
|
|
{% for zname in zones %}
|
|
<option value="{{ zname }}" {% if zname == iface.get('zone') %}selected{% endif %}>{{ zname }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
{% else %}
|
|
<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="7" class="text-muted text-sm">No interfaces found</td>
|
|
</tr>
|
|
{% endif %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% endblock %} |