Netzwerkeinstellungen: netplan/systemd-networkd als drittes Backend + DHCP-Interface-Default-Fix

Auf einer echten, frisch installierten Ubuntu-24.04-VM (192.168.80.114,
Standard-Setup, Netz per DHCP) zeigten sich zwei reale Bugs:

1. Systemeinstellungen > Netzwerkeinstellungen: 'Kein unterstütztes
   Backend erkannt' trotz laufendem Netz -- _detect_network_backend()
   kannte nur NetworkManager und dhcpcd, nicht netplan/systemd-networkd,
   den mit Abstand haeufigsten Fall auf einem normalen Ubuntu-Host.
   Neues drittes Backend 'netplan' ergaenzt: Erkennung ueber
   systemd-networkd + vorhandenes netplan-Binary, Moduserkennung ueber
   das 'dynamic'-Flag des Kernels auf der aktuellen Adresse, Anwenden
   ueber eine eigene Override-Datei (/etc/netplan/90-poe-manager.yaml,
   gewinnt alphabetisch gegen z.B. cloud-inits 50-*.yaml) plus
   Aufruf von netplan apply, Backup/Auto-Rollback wie bei den anderen
   Backends.
   Live verifiziert: Anwenden loeste echten DHCP-Lease-Renew aus, nach
   Ablauf der 45s-Bestaetigungsfrist wurde die Override-Datei automatisch
   wieder entfernt (Rollback funktioniert).

2. DHCP-Konfiguration: 'Keine IPv4-Adresse auf eth0 gefunden', obwohl
   das Interface-Dropdown korrekt 'enp1s0' zeigte -- DHCP_DEFAULTS hatte
   'eth0' hart kodiert (Annahme aus einer aelteren Testumgebung), was auf
   Systemen mit Predictable Network Interface Names schlicht nicht
   existiert. _load_dhcp_settings() korrigiert das jetzt: liegt der
   gespeicherte/Default-Wert nicht unter den tatsaechlich vorhandenen
   Schnittstellen, wird automatisch die erste echte vorgeschlagen.

Neue Abhaengigkeit PyYAML fuer das Schreiben der netplan-Override-Datei.
This commit is contained in:
2026-08-11 13:04:43 +02:00
parent 9e23243872
commit 2b5d9d1b48
4 changed files with 127 additions and 24 deletions
+22 -14
View File
@@ -290,17 +290,25 @@ risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig,
da eine Änderung nicht die Erreichbarkeit kappt). da eine Änderung nicht die Erreichbarkeit kappt).
- **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist - **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist
(`systemctl is-active`). Ist keins von beiden aktiv, bleibt die Seite (`systemctl is-active`); ist keins von beiden aktiv, aber `systemd-networkd`
bewusst **rein lesend** (Status wird trotzdem angezeigt) — ohne aktiv und `netplan` installiert, wird **netplan** angenommen — der Standard
zuverlässige Erkennung des Backends keine Änderung, um nicht ins Blaue zu auf einem frisch installierten Ubuntu (Server wie Cloud-Image). Ist keins
raten. der drei erkennbar, bleibt die Seite bewusst **rein lesend** (Status wird
trotzdem angezeigt) — ohne zuverlässige Erkennung des Backends keine
Änderung, um nicht ins Blaue zu raten.
- **Anzeige**: aktuelles Interface, IP/Prefix, Gateway, DNS-Server sowie - **Anzeige**: aktuelles Interface, IP/Prefix, Gateway, DNS-Server sowie
Modus (DHCP/Statisch) — Modus wird backend-spezifisch ermittelt Modus (DHCP/Statisch) — Modus wird backend-spezifisch ermittelt
(`nmcli ipv4.method` bzw. ein `static ip_address=`-Eintrag im (`nmcli ipv4.method`, ein `static ip_address=`-Eintrag im betreffenden
betreffenden `interface`-Block von `/etc/dhcpcd.conf`). `interface`-Block von `/etc/dhcpcd.conf`, bzw. bei netplan am `dynamic`-Flag
des Kernels auf der aktuellen Adresse — zuverlässiger als die u.U. mehreren
überlagernden YAML-Dateien unter `/etc/netplan/` selbst zu parsen).
- **Änderung**: Interface, Modus (DHCP/Statisch), bei Statisch zusätzlich - **Änderung**: Interface, Modus (DHCP/Statisch), bei Statisch zusätzlich
IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert
in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). Bei netplan
landet die Änderung in einer eigenen, niedrig priorisierten Override-Datei
(`/etc/netplan/90-poe-manager.yaml`) statt in der vom Distributor bzw.
cloud-init mitgelieferten — netplan wendet Dateien alphabetisch an, spätere
Werte gewinnen pro Schlüssel, damit bleibt die Original-Datei unangetastet.
- **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die - **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die
vorherige Konfiguration gesichert. Nach dem Anwenden läuft ein vorherige Konfiguration gesichert. Nach dem Anwenden läuft ein
Hintergrund-Timer (Standard 45s) — wird die Änderung nicht **explizit Hintergrund-Timer (Standard 45s) — wird die Änderung nicht **explizit
@@ -308,14 +316,14 @@ da eine Änderung nicht die Erreichbarkeit kappt).
erreichbar ist), rollt die App automatisch auf die gesicherte erreichbar ist), rollt die App automatisch auf die gesicherte
Konfiguration zurück. Verhindert, dass ein Tippfehler bei IP/Gateway den Konfiguration zurück. Verhindert, dass ein Tippfehler bei IP/Gateway den
Host von der Web-UI/SSH abschneidet. Host von der Web-UI/SSH abschneidet.
- Da diese Session ausschließlich in WSL (eigene virtuelle Netzwerkschicht, - Live gegen eine echte Ubuntu-24.04-VM verifiziert (Standard-Installation,
weder NetworkManager noch dhcpcd) läuft, konnte der Anwenden-Pfad nicht Netz per DHCP über netplan/systemd-networkd bezogen) — sowohl die
gegen eine echte Instanz verifiziert werden — Erkennung/Statusanzeige Erkennung/Statusanzeige als auch der komplette Anwenden-Pfad inkl. der
sind live getestet (korrekte "kein Backend erkannt"-Erkennung + rein eigenen Override-Datei. NetworkManager und dhcpcd sind zusätzlich per
lesender Fallback), Anwenden/Rollback-Logik gegen beide Backends per gemockten `subprocess.run`/Dateizugriffen verifiziert (inkl. eines echten
gemockten `subprocess.run`/Dateizugriffen inkl. eines echten
`dhcpcd.conf`-Rewrites und eines vollständigen Anwenden-dann-Auto- `dhcpcd.conf`-Rewrites und eines vollständigen Anwenden-dann-Auto-
Rollback-Durchlaufs verifiziert. Rollback-Durchlaufs), da in der WSL-Entwicklungsumgebung keins von beiden
verfügbar ist.
## Änderungslog ## Änderungslog
+98 -8
View File
@@ -20,6 +20,7 @@ from cryptography.hazmat.primitives import hashes
from datetime import datetime from datetime import datetime
import base64, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import base64, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko import paramiko
import yaml
class _SendAllSocketProxy: class _SendAllSocketProxy:
@@ -1244,13 +1245,17 @@ def run_check_now():
NETWORK_REVERT_SECONDS = 45 NETWORK_REVERT_SECONDS = 45
_pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., "backup": {...}} _pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., "backup": {...}}
# Eigene netplan-Override-Datei (siehe _apply_network_config) — bewusst nicht
# die von cloud-init/dem Distributor mitgelieferte Datei, damit ein Rollback
# nie versehentlich fremden Inhalt überschreibt.
NETPLAN_CONFIG_PATH = "/etc/netplan/90-poe-manager.yaml"
def _detect_network_backend(): def _detect_network_backend():
"""Rein lesend: welcher der beiden gängigen Netzwerk-Manager auf """Rein lesend: welcher der gängigen Netzwerk-Manager ist aktiv? Keine
Debian/Raspberry Pi OS ist aktiv? Keine Annahme, falls keins von Annahme, falls keiner läuft (z.B. in dieser WSL-Testumgebung, die ihr
beiden läuft (z.B. in dieser WSL-Testumgebung, die ihr Netz über den Netz über den Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst
Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst rein lesend.""" rein lesend."""
for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")): for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")):
try: try:
result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5) result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5)
@@ -1258,6 +1263,17 @@ def _detect_network_backend():
return name return name
except Exception: except Exception:
pass pass
# Standard-Ubuntu (Server- wie Cloud-Images) verwaltet sein Netz
# inzwischen über netplan + systemd-networkd statt NetworkManager oder
# dhcpcd — mit Abstand der häufigste Fall auf einem "normalen" frisch
# installierten Ubuntu-Host (live an einer echten Ubuntu-24.04-VM
# verifiziert, die ihre IP per DHCP bezieht).
try:
result = subprocess.run(["systemctl", "is-active", "systemd-networkd"], capture_output=True, text=True, timeout=5)
if result.stdout.strip() == "active" and shutil.which("netplan"):
return "netplan"
except Exception:
pass
return "unknown" return "unknown"
@@ -1301,6 +1317,12 @@ def _read_network_state(interface, backend):
mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp" mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp"
except OSError: except OSError:
mode = "dhcp" mode = "dhcp"
elif backend == "netplan":
# Netplan/systemd-networkd markiert eine per DHCP bezogene Adresse
# im Kernel mit "dynamic" — zuverlässiger als die YAML-Dateien unter
# /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern).
if net_info.get("ok"):
mode = "dhcp" if net_info.get("dynamic") else "static"
return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend} return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend}
@@ -1328,6 +1350,15 @@ def _backup_network_config(interface, backend):
backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
except Exception: except Exception:
backup["nm_connection"] = None backup["nm_connection"] = None
elif backend == "netplan":
# Eigene Override-Datei sichern statt der von cloud-init/dem
# Distributor mitgelieferten Dateien — None heißt "existierte vorher
# nicht", dann löscht der Revert sie wieder statt sie leer zu lassen.
try:
with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f:
backup["netplan_conf"] = f.read()
except OSError:
backup["netplan_conf"] = None
return backup return backup
@@ -1381,7 +1412,38 @@ def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_lis
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager noch dhcpcd aktiv)." if backend == "netplan":
try:
eth_cfg = {}
if mode == "static":
eth_cfg["dhcp4"] = False
eth_cfg["addresses"] = [f"{ip}/{prefix}"]
if gateway:
eth_cfg["routes"] = [{"to": "default", "via": gateway}]
else:
eth_cfg["dhcp4"] = True
if dns_list:
# Ohne diesen Override würde networkd die eigenen
# DNS-Server mit den per DHCP gelieferten zusammen
# verwenden, statt sie wie bei den anderen Backends
# (ipv4.ignore-auto-dns / dhcpcd) vollständig zu ersetzen.
eth_cfg["dhcp4-overrides"] = {"use-dns": False}
if dns_list:
eth_cfg["nameservers"] = {"addresses": dns_list}
doc = {"network": {"version": 2, "ethernets": {interface: eth_cfg}}}
# 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
# Werte pro Schlüssel gewinnen, das ist hier der etablierte Weg
# für eigene Overrides statt die Distributor-Datei zu editieren.
with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f:
yaml.safe_dump(doc, f, default_flow_style=False)
os.chmod(NETPLAN_CONFIG_PATH, 0o600)
return _dhcp_run_privileged(["netplan", "apply"], timeout=20)
except Exception as e:
return False, str(e)
return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager, dhcpcd noch netplan aktiv)."
def _revert_network_config(token): def _revert_network_config(token):
@@ -1403,6 +1465,19 @@ def _revert_network_config(token):
"networkmanager", backup["interface"], "networkmanager", backup["interface"],
backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []), backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []),
) )
elif backup["backend"] == "netplan":
if backup.get("netplan_conf") is not None:
with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f:
f.write(backup["netplan_conf"])
else:
# Datei existierte vor der Änderung nicht (erste Netplan-
# Änderung über diese Seite) — dann muss sie beim Rollback
# auch wieder verschwinden statt leer zurückzubleiben.
try:
os.remove(NETPLAN_CONFIG_PATH)
except OSError:
pass
subprocess.run(["netplan", "apply"], timeout=20)
log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt") log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt")
except Exception as e: except Exception as e:
app.logger.error("Network auto-revert failed: %s", e) app.logger.error("Network auto-revert failed: %s", e)
@@ -1571,7 +1646,17 @@ DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES}
def _load_dhcp_settings(): def _load_dhcp_settings():
return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} cfg = {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()}
available = _list_network_interfaces()
if available and cfg.get("dhcp_interface") not in available:
# Der hart codierte Default ("eth0") bzw. ein früher gespeicherter Wert
# existiert auf Systemen mit Predictable Network Interface Names
# (z.B. "enp1s0") nicht — dann lieber die tatsächlich vorhandene erste
# Schnittstelle vorschlagen, statt eine erkennbar falsche anzuzeigen
# (führte sonst dazu, dass die Erkennung fälschlich "eth0" statt der
# im Dropdown sichtbaren echten Schnittstelle prüfte).
cfg["dhcp_interface"] = available[0]
return cfg
def _detect_dhcp_status(): def _detect_dhcp_status():
@@ -1625,7 +1710,8 @@ def _detect_interface_network(interface):
manuell pflegen zu lassen) — dadurch koppelt sich die generierte manuell pflegen zu lassen) — dadurch koppelt sich die generierte
DHCP-Konfiguration immer an das Netz, in dem der Host tatsächlich DHCP-Konfiguration immer an das Netz, in dem der Host tatsächlich
hängt, auch wenn sich dessen IP/Subnet mal ändert.""" hängt, auch wenn sich dessen IP/Subnet mal ändert."""
result = {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, "error": None} result = {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None,
"dynamic": None, "error": None}
if not interface: if not interface:
result["error"] = "Kein Interface ausgewählt." result["error"] = "Kein Interface ausgewählt."
return result return result
@@ -1640,7 +1726,11 @@ def _detect_interface_network(interface):
return result return result
ip_str, prefix = match.group(1), int(match.group(2)) ip_str, prefix = match.group(1), int(match.group(2))
network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False) network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False)
result.update(ok=True, ip=ip_str, prefix=prefix, network=network, netmask=str(network.netmask)) # Der Kernel markiert eine per DHCP bezogene Adresse mit "dynamic" —
# unabhängig vom Backend eine zuverlässige Quelle für den aktuellen
# DHCP/Statisch-Modus (genutzt von der netplan-Erkennung unten).
dynamic = bool(re.search(rf"inet {re.escape(ip_str)}/{prefix}\b[^\n]*\bdynamic\b", addr_out.stdout))
result.update(ok=True, ip=ip_str, prefix=prefix, network=network, netmask=str(network.netmask), dynamic=dynamic)
route_out = subprocess.run( route_out = subprocess.run(
["ip", "-4", "route", "show", "default", "dev", interface], ["ip", "-4", "route", "show", "default", "dev", interface],
+5
View File
@@ -13,6 +13,7 @@ MarkupSafe==3.0.2
paramiko==3.5.1 paramiko==3.5.1
pycparser==2.23 pycparser==2.23
PyNaCl==1.5.0 PyNaCl==1.5.0
PyYAML==6.0.2
simple-websocket==1.1.0 simple-websocket==1.1.0
Werkzeug==3.1.3 Werkzeug==3.1.3
@@ -20,3 +21,7 @@ Werkzeug==3.1.3
# (reines Python) statt eines lokalen pty + System-ssh-Client und läuft # (reines Python) statt eines lokalen pty + System-ssh-Client und läuft
# damit plattformunabhängig — auch unter Windows. Die SSH-Verbindung wird # damit plattformunabhängig — auch unter Windows. Die SSH-Verbindung wird
# dabei immer serverseitig (vom PoE-Server) aufgebaut, nie vom Browser aus. # dabei immer serverseitig (vom PoE-Server) aufgebaut, nie vom Browser aus.
#
# PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen
# gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest
# die eigene Override-Datei unter /etc/netplan/.
+2 -2
View File
@@ -72,7 +72,7 @@
{% if net_backend == 'unknown' %} {% if net_backend == 'unknown' %}
<span class="pill unknown">Kein unterstütztes Backend erkannt</span> <span class="pill unknown">Kein unterstütztes Backend erkannt</span>
{% else %} {% else %}
<span class="pill online">{{ 'NetworkManager' if net_backend == 'networkmanager' else 'dhcpcd' }}</span> <span class="pill online">{{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }}</span>
{% endif %} {% endif %}
{% if net_state %} {% if net_state %}
<span class="mono text-faint" style="font-size:12px;"> <span class="mono text-faint" style="font-size:12px;">
@@ -143,7 +143,7 @@
</form> </form>
{% elif net_backend == 'unknown' %} {% elif net_backend == 'unknown' %}
<p class="text-faint" style="font-size:12.5px;"> <p class="text-faint" style="font-size:12.5px;">
Weder NetworkManager noch dhcpcd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert. Weder NetworkManager, dhcpcd noch netplan/systemd-networkd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert.
Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen. Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen.
</p> </p>
{% else %} {% else %}