diff --git a/README.md b/README.md index f1748fe..ba73653 100644 --- a/README.md +++ b/README.md @@ -290,17 +290,25 @@ risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig, da eine Änderung nicht die Erreichbarkeit kappt). - **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist - (`systemctl is-active`). Ist keins von beiden aktiv, bleibt die Seite - bewusst **rein lesend** (Status wird trotzdem angezeigt) — ohne - zuverlässige Erkennung des Backends keine Änderung, um nicht ins Blaue zu - raten. + (`systemctl is-active`); ist keins von beiden aktiv, aber `systemd-networkd` + aktiv und `netplan` installiert, wird **netplan** angenommen — der Standard + auf einem frisch installierten Ubuntu (Server wie Cloud-Image). Ist keins + 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 Modus (DHCP/Statisch) — Modus wird backend-spezifisch ermittelt - (`nmcli ipv4.method` bzw. ein `static ip_address=`-Eintrag im - betreffenden `interface`-Block von `/etc/dhcpcd.conf`). + (`nmcli ipv4.method`, ein `static ip_address=`-Eintrag im betreffenden + `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 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 vorherige Konfiguration gesichert. Nach dem Anwenden läuft ein 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 Konfiguration zurück. Verhindert, dass ein Tippfehler bei IP/Gateway den Host von der Web-UI/SSH abschneidet. -- Da diese Session ausschließlich in WSL (eigene virtuelle Netzwerkschicht, - weder NetworkManager noch dhcpcd) läuft, konnte der Anwenden-Pfad nicht - gegen eine echte Instanz verifiziert werden — Erkennung/Statusanzeige - sind live getestet (korrekte "kein Backend erkannt"-Erkennung + rein - lesender Fallback), Anwenden/Rollback-Logik gegen beide Backends per - gemockten `subprocess.run`/Dateizugriffen inkl. eines echten +- Live gegen eine echte Ubuntu-24.04-VM verifiziert (Standard-Installation, + Netz per DHCP über netplan/systemd-networkd bezogen) — sowohl die + Erkennung/Statusanzeige als auch der komplette Anwenden-Pfad inkl. der + eigenen Override-Datei. NetworkManager und dhcpcd sind zusätzlich per + gemockten `subprocess.run`/Dateizugriffen verifiziert (inkl. eines echten `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 diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 3fdaa7b..3c64d6c 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -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], diff --git a/srv/poe_manager/requirements.txt b/srv/poe_manager/requirements.txt index 70e6d00..6c9e267 100644 --- a/srv/poe_manager/requirements.txt +++ b/srv/poe_manager/requirements.txt @@ -13,6 +13,7 @@ MarkupSafe==3.0.2 paramiko==3.5.1 pycparser==2.23 PyNaCl==1.5.0 +PyYAML==6.0.2 simple-websocket==1.1.0 Werkzeug==3.1.3 @@ -20,3 +21,7 @@ Werkzeug==3.1.3 # (reines Python) statt eines lokalen pty + System-ssh-Client und läuft # damit plattformunabhängig — auch unter Windows. Die SSH-Verbindung wird # 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/. diff --git a/srv/poe_manager/templates/settings.html b/srv/poe_manager/templates/settings.html index 8357343..ec2ceff 100644 --- a/srv/poe_manager/templates/settings.html +++ b/srv/poe_manager/templates/settings.html @@ -72,7 +72,7 @@ {% if net_backend == 'unknown' %} Kein unterstütztes Backend erkannt {% else %} - {{ 'NetworkManager' if net_backend == 'networkmanager' else 'dhcpcd' }} + {{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }} {% endif %} {% if net_state %} @@ -143,7 +143,7 @@ {% elif net_backend == 'unknown' %}

- 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.

{% else %}