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
+98 -8
View File
@@ -20,6 +20,7 @@ from cryptography.hazmat.primitives import hashes
from datetime import datetime
import base64, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
import yaml
class _SendAllSocketProxy:
@@ -1244,13 +1245,17 @@ def run_check_now():
NETWORK_REVERT_SECONDS = 45
_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():
"""Rein lesend: welcher der beiden gängigen Netzwerk-Manager auf
Debian/Raspberry Pi OS ist aktiv? Keine Annahme, falls keins von
beiden läuft (z.B. in dieser WSL-Testumgebung, die ihr Netz über den
Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst rein lesend."""
"""Rein lesend: welcher der gängigen Netzwerk-Manager ist aktiv? Keine
Annahme, falls keiner läuft (z.B. in dieser WSL-Testumgebung, die ihr
Netz über den Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst
rein lesend."""
for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")):
try:
result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5)
@@ -1258,6 +1263,17 @@ def _detect_network_backend():
return name
except Exception:
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"
@@ -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"
except OSError:
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}
@@ -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
except Exception:
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
@@ -1381,7 +1412,38 @@ def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_lis
except Exception as 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):
@@ -1403,6 +1465,19 @@ def _revert_network_config(token):
"networkmanager", backup["interface"],
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")
except Exception as 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():
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():
@@ -1625,7 +1710,8 @@ def _detect_interface_network(interface):
manuell pflegen zu lassen) — dadurch koppelt sich die generierte
DHCP-Konfiguration immer an das Netz, in dem der Host tatsächlich
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:
result["error"] = "Kein Interface ausgewählt."
return result
@@ -1640,7 +1726,11 @@ def _detect_interface_network(interface):
return result
ip_str, prefix = match.group(1), int(match.group(2))
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(
["ip", "-4", "route", "show", "default", "dev", interface],