Netzwerkeinstellungen: Mehrere Interfaces korrekt unterstützen

- Kritischer Bug behoben: _apply_network_config() baute die netplan-
  Override-Datei bei JEDEM Speichern komplett neu auf (nur mit dem gerade
  bearbeiteten Interface) statt bestehende Einträge anderer Interfaces zu
  erhalten -- ein zweites Interface einzurichten hat dadurch die statische
  Konfiguration des ersten aus der Datei geworfen (fiel auf DHCP zurück).
  Jetzt wird die vorhandene Datei eingelesen und nur der Block des
  gespeicherten Interfaces ersetzt/ergänzt.
- Live auf POETEST reproduziert und nach dem Fix verifiziert: enp1s0 verlor
  durch den Bug seine statische 192.168.80.202/24 (fiel auf DHCP zurück),
  während enp3s0 (zweites, neu eingerichtetes Interface) 192.168.82.202/24
  behielt. Nach dem Fix enp1s0 über die echte GUI-Route wiederhergestellt
  (Werte aus dem aktiven DHCP-Lease bestätigt) -- beide Interfaces stehen
  jetzt nebeneinander in derselben netplan-Datei.
- Neuer JSON-Endpunkt /settings/network_state + JS: die Eingabemaske
  (Modus/IP/Prefix/Gateway/DNS) lädt beim Umschalten des Interface-Dropdowns
  jetzt dessen tatsächlichen Ist-Zustand nach, statt weiter die Werte des
  zuvor angezeigten Interfaces zu zeigen.
- Die Übersicht ganz oben auf der Karte zeigt jetzt JEDES aktive Interface
  gleichzeitig (eigene Zeile mit IP/Gateway/Modus/DNS) statt nur das gerade
  im Formular ausgewählte.
- net_interface-Setting wird jetzt tatsächlich persistiert (war vorbereitet,
  aber nie geschrieben), damit die Seite nach einem Reload das zuletzt
  bearbeitete statt immer nur das alphabetisch erste Interface vorbelegt.
This commit is contained in:
2026-08-13 13:00:02 +02:00
parent 3ab3407a18
commit 34d9cdebd6
2 changed files with 100 additions and 17 deletions
+60 -3
View File
@@ -2618,7 +2618,23 @@ def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_lis
eth_cfg["dhcp4-overrides"] = {"use-dns": False}
if dns_list:
eth_cfg["nameservers"] = {"addresses": dns_list}
doc = {"network": {"version": 2, "ethernets": {interface: eth_cfg}}}
# Bestehende Datei einlesen und nur den Block DIESES Interfaces
# ersetzen/ergänzen -- ein Host kann mehrere Interfaces haben,
# jedes über einen eigenen Aufruf dieser Seite konfiguriert. Ein
# kompletter Neuaufbau des Dokuments (wie ursprünglich) hätte bei
# jedem Speichern alle ANDEREN, bereits konfigurierten Interfaces
# aus der Datei geworfen -- deren Konfiguration fiele dadurch auf
# die Distributor-Datei zurück (i.d.R. DHCP), obwohl an ihnen gar
# nichts geändert wurde (live reproduziert: zweites Interface
# eingerichtet, erstes verlor dadurch seine statische IP).
try:
with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f:
existing_doc = yaml.safe_load(f) or {}
except OSError:
existing_doc = {}
ethernets = ((existing_doc.get("network") or {}).get("ethernets") or {}).copy()
ethernets[interface] = eth_cfg
doc = {"network": {"version": 2, "ethernets": ethernets}}
# Eigene, niedrig priorisierte Datei statt der von cloud-init
# mitgelieferten (z.B. 50-cloud-init.yaml) — netplan wendet
# Dateien in alphabetischer Reihenfolge an und lässt spätere
@@ -2727,6 +2743,11 @@ def settings():
_pending_network_revert.clear() # nur eine ausstehende Änderung gleichzeitig
_pending_network_revert[token] = {"timer": timer, "backup": backup}
timer.start()
# Damit die Seite beim nächsten Laden das zuletzt bearbeitete
# statt immer nur das alphabetisch erste Interface zeigt --
# gerade bei mehreren Interfaces sonst verwirrend, siehe
# net_interface-Fallback weiter unten.
set_setting("net_interface", interface)
log_action("settings.network_apply", interface, f"Modus {mode}")
msg = (
f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte "
@@ -2826,8 +2847,20 @@ def settings():
return redirect(url_for("settings"))
net_backend = _detect_network_backend()
net_interface = get_setting("net_interface") or (_list_network_interfaces() or [None])[0]
all_interfaces = _list_network_interfaces()
net_interface = get_setting("net_interface") or (all_interfaces or [None])[0]
net_state = _read_network_state(net_interface, net_backend) if net_interface else None
# Übersicht ganz oben zeigt JEDES tatsächlich aktive Interface (eigene
# IPv4-Adresse vorhanden) gleichzeitig, nicht nur das gerade im
# Formular ausgewählte -- bei mehreren Interfaces sonst leicht der
# Eindruck, das zweite existiere für die App gar nicht (live so
# gemeldet: die Übersicht zeigte nach dem Umschalten der Auswahl
# weiterhin nur die Werte des ersten Interfaces).
active_net_states = [
{"interface": iface, **_read_network_state(iface, net_backend)}
for iface in all_interfaces
]
active_net_states = [s for s in active_net_states if s.get("ok")]
pending_token = next(iter(_pending_network_revert), None)
try:
current_hostname = socket.gethostname()
@@ -2838,9 +2871,10 @@ def settings():
interval=interval,
current_hostname=current_hostname,
net_backend=net_backend,
net_interfaces=_list_network_interfaces(),
net_interfaces=all_interfaces,
net_interface=net_interface,
net_state=net_state,
active_net_states=active_net_states,
net_revert_seconds=NETWORK_REVERT_SECONDS,
pending_network_token=pending_token,
log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL),
@@ -2858,6 +2892,29 @@ def settings():
)
@app.route("/settings/network_state")
@login_required
def settings_network_state():
"""JSON-Endpunkt für die Interface-Auswahl auf der Netzwerkeinstellungen-
Karte -- liefert den tatsächlichen Ist-Zustand DES ausgewählten
Interfaces, damit die Eingabemaske beim Umschalten live aktualisiert
wird statt weiter die Werte des zuvor angezeigten Interfaces zu zeigen
(bei mehreren Interfaces sonst leicht mit dessen Konfiguration zu
verwechseln -- live reproduziert)."""
if not current_user.can_view_settings_system:
return jsonify({"error": "Keine Berechtigung."}), 403
interface = request.args.get("interface", "").strip()
if interface not in _list_network_interfaces():
return jsonify({"error": "Unbekanntes Interface."}), 404
backend = _detect_network_backend()
state = _read_network_state(interface, backend)
# "network" ist ein ipaddress.IPv4Network-Objekt (siehe
# _detect_interface_network) -- nicht JSON-serialisierbar und vom
# Frontend hier auch nicht benötigt (nur ip/prefix/gateway/dns/mode).
state.pop("network", None)
return jsonify(state)
@app.route("/settings/import-export")
@login_required
def settings_import_export():
+40 -14
View File
@@ -77,25 +77,29 @@
</div>
</div>
<div class="flex gap-2" style="align-items:center; margin-bottom:16px; flex-wrap:wrap;">
<div class="flex gap-2" style="align-items:center; margin-bottom:12px; flex-wrap:wrap;">
{% if net_backend == 'unknown' %}
<span class="pill unknown">Kein unterstütztes Backend erkannt</span>
{% else %}
<span class="pill online">{{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }}</span>
{% endif %}
{% if net_state %}
</div>
{% for state in active_net_states %}
<div class="flex gap-2" style="align-items:center; margin-bottom:8px; flex-wrap:wrap;">
<span class="mono text-faint" style="font-size:12px;">
{{ net_interface }}
{% if net_state.ok %}— {{ net_state.ip }}/{{ net_state.prefix }}{% if net_state.gateway %}, Gateway {{ net_state.gateway }}{% endif %}{% endif %}
{{ state.interface }} — {{ state.ip }}/{{ state.prefix }}{% if state.gateway %}, Gateway {{ state.gateway }}{% endif %}
</span>
<span class="pill {{ 'user' if net_state.mode == 'static' else ('online' if net_state.mode == 'dhcp' else 'unknown') }}">
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[net_state.mode] }}
<span class="pill {{ 'user' if state.mode == 'static' else ('online' if state.mode == 'dhcp' else 'unknown') }}">
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[state.mode] }}
</span>
{% if state.dns %}
<span class="text-faint" style="font-size:12px;">DNS: <span class="mono">{{ state.dns|join(', ') }}</span></span>
{% endif %}
</div>
{% if net_state and net_state.dns %}
<div class="text-faint" style="font-size:12px; margin-bottom:16px;">Aktuelle DNS-Server: <span class="mono">{{ net_state.dns|join(', ') }}</span></div>
{% endif %}
{% else %}
<div class="text-faint" style="font-size:12px; margin-bottom:8px;">Kein aktives Interface mit IPv4-Adresse gefunden.</div>
{% endfor %}
<div style="margin-bottom:16px;"></div>
{% if pending_network_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
@@ -115,11 +119,12 @@
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
<input type="hidden" name="apply_network" value="1">
<div class="field"><label>Interface</label>
<select name="net_interface">
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
{% for iface in net_interfaces %}
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
<div class="field-hint">Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.</div>
</div>
<div class="field"><label>Modus</label>
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
@@ -129,17 +134,17 @@
</div>
<div id="netStaticFields" class="{{ 'hidden' if net_state.mode != 'static' }}">
<div class="field"><label>IP-Adresse</label>
<input type="text" name="net_ip" value="{{ net_state.ip if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.50">
<input type="text" name="net_ip" id="netIpInput" value="{{ net_state.ip if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.50">
</div>
<div class="field"><label>Prefix (CIDR-Bits)</label>
<input type="number" name="net_prefix" min="1" max="32" value="{{ net_state.prefix if net_state.mode == 'static' else '' }}" placeholder="z.B. 24">
<input type="number" name="net_prefix" id="netPrefixInput" min="1" max="32" value="{{ net_state.prefix if net_state.mode == 'static' else '' }}" placeholder="z.B. 24">
</div>
<div class="field"><label>Gateway</label>
<input type="text" name="net_gateway" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
</div>
</div>
<div class="field"><label>DNS-Server</label>
<input type="text" name="net_dns" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
@@ -302,5 +307,26 @@ function moveNavItem(btn, dir) {
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
// Lädt beim Umschalten des Interfaces (Netzwerkeinstellungen) dessen
// tatsächlichen Ist-Zustand nach -- ohne das würden die Felder weiter die
// Werte des zuvor angezeigten Interfaces zeigen, obwohl gerade ein anderes
// ausgewählt wurde (bei mehreren Interfaces leicht zu Verwechslungen
// geführt, siehe /settings/network_state).
function loadNetworkState(iface) {
fetch("{{ url_for('settings_network_state') }}?interface=" + encodeURIComponent(iface))
.then(r => r.json())
.then(state => {
if (state.error) return;
const isStatic = state.mode === "static";
document.getElementById("netModeSelect").value = isStatic ? "static" : "dhcp";
document.getElementById("netStaticFields").classList.toggle("hidden", !isStatic);
document.getElementById("netIpInput").value = isStatic ? (state.ip || "") : "";
document.getElementById("netPrefixInput").value = isStatic ? (state.prefix || "") : "";
document.getElementById("netGatewayInput").value = isStatic ? (state.gateway || "") : "";
document.getElementById("netDnsInput").value = (state.dns || []).join(", ");
})
.catch(() => {});
}
</script>
{% endblock %}