diff --git a/README.md b/README.md index fe641f4..31309f6 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,41 @@ Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart sowie Client-spezifischer Override erscheinen korrekt getrennt in der Konfiguration. +## Netzwerkeinstellungen des Hosts (Systemeinstellungen) + +Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um +die Netzwerkkonfiguration des Hosts, auf dem der PoE Manager selbst läuft +(IP-Adresse, DNS-Server, DHCP-Client vs. statisch) — nicht um Adressen, die +er an andere Geräte vergibt. + +- **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. +- **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`). +- **Ä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). +- **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 + bestätigt** (nur möglich, wenn die Seite über die neue Konfiguration noch + 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 + `dhcpcd.conf`-Rewrites und eines vollständigen Anwenden-dann-Auto- + Rollback-Durchlaufs verifiziert. + ## Änderungslog Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 64a4abb..3bff2e3 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -813,6 +813,20 @@ def log_action(action, target=None, details=None): conn.close() +def log_action_system(action, target=None, details=None): + """Wie log_action(), aber ohne Abhängigkeit von current_user — für + Code, der außerhalb eines Request-Kontexts läuft (z.B. der + Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user + nicht auflösbar ist und einen AttributeError werfen würde).""" + conn = get_db_connection() + conn.execute( + "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", + (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "system", action, target, details), + ) + conn.commit() + conn.close() + + def touch_record(conn, table, key_col, key_val): """Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches).""" who = current_user.username if current_user.is_authenticated else "system" @@ -1214,6 +1228,192 @@ def run_check_now(): # Settings # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht zu +# verwechseln mit dem Kea-DHCP-*Server* für Client-Geräte weiter unten). +# +# Höchste Vorsicht geboten: eine falsche Änderung hier kann die Erreichbarkeit +# dieses Hosts (SSH/Web-UI) komplett kappen. Deshalb: +# - Erkennung des tatsächlich aktiven Netzwerk-Backends (NetworkManager +# oder dhcpcd) statt blind eine Methode anzunehmen; ist keins von beiden +# aktiv (z.B. in dieser WSL-Testumgebung), bleibt die Seite bewusst rein +# lesend statt zu raten. +# - Vor jeder Änderung wird die vorherige Konfiguration gesichert. +# - Nach dem Anwenden läuft ein Sicherheits-Timer im Hintergrund: wird die +# neue Konfiguration nicht innerhalb von NETWORK_REVERT_SECONDS explizit +# bestätigt (das ist nur möglich, wenn die Web-UI über die NEUE +# Konfiguration noch erreichbar ist), wird automatisch die gesicherte +# Konfiguration wiederhergestellt — analog zu "netplan try". +# --------------------------------------------------------------------------- + +NETWORK_REVERT_SECONDS = 45 +_pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., "backup": {...}} + + +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.""" + for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")): + try: + result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5) + if result.stdout.strip() == "active": + return name + except Exception: + pass + return "unknown" + + +def _read_resolv_conf_dns(): + try: + with open("/etc/resolv.conf", encoding="utf-8") as f: + return [line.split()[1] for line in f if line.strip().startswith("nameserver")] + except OSError: + return [] + + +def _read_network_state(interface, backend): + """Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network + (bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf, + Modus (dhcp/static) backend-spezifisch ermittelt. Jeder Erkennungsschritt + ist defensiv (try/except) — im Zweifel lieber "unbekannt" anzeigen als + einen falschen Zustand zu behaupten.""" + net_info = _detect_interface_network(interface) + mode = "unknown" + if backend == "networkmanager": + try: + dev_out = subprocess.run( + ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface], + capture_output=True, text=True, timeout=5, + ) + conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None + if conn_name: + method_out = subprocess.run( + ["nmcli", "-g", "ipv4.method", "connection", "show", conn_name], + capture_output=True, text=True, timeout=5, + ) + mode = "static" if method_out.stdout.strip() == "manual" else "dhcp" + except Exception: + pass + elif backend == "dhcpcd": + try: + with open("/etc/dhcpcd.conf", encoding="utf-8") as f: + content = f.read() + block_match = re.search(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", content, re.S | re.M) + block = block_match.group(1) if block_match else "" + mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp" + except OSError: + mode = "dhcp" + return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend} + + +def _backup_network_config(interface, backend): + """Kompletten Ist-Zustand vor einer Änderung sichern, damit + _revert_network_config() ihn exakt wiederherstellen kann.""" + state = _read_network_state(interface, backend) + backup = { + "interface": interface, "backend": backend, "mode": state["mode"], + "ip": state.get("ip"), "prefix": state.get("prefix"), "gateway": state.get("gateway"), + "dns": state.get("dns", []), + } + if backend == "dhcpcd": + try: + with open("/etc/dhcpcd.conf", encoding="utf-8") as f: + backup["dhcpcd_conf"] = f.read() + except OSError: + backup["dhcpcd_conf"] = None + elif backend == "networkmanager": + try: + dev_out = subprocess.run( + ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface], + capture_output=True, text=True, timeout=5, + ) + backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None + except Exception: + backup["nm_connection"] = None + return backup + + +def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list): + """Wendet die neue Konfiguration über das erkannte Backend an. Gibt + (ok, message) zurück statt zu werfen, damit der Aufrufer immer eine + Flash-Meldung bekommt statt eines 500ers.""" + dns_csv = ",".join(dns_list) + if backend == "networkmanager": + try: + dev_out = subprocess.run( + ["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface], + capture_output=True, text=True, timeout=5, + ) + conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None + if not conn_name: + return False, f"Keine aktive NetworkManager-Verbindung für {interface} gefunden." + args = ["nmcli", "connection", "modify", conn_name] + if mode == "static": + args += ["ipv4.method", "manual", "ipv4.addresses", f"{ip}/{prefix}", "ipv4.gateway", gateway] + else: + args += ["ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", ""] + if dns_csv: + args += ["ipv4.dns", dns_csv, "ipv4.ignore-auto-dns", "yes"] + else: + args += ["ipv4.dns", "", "ipv4.ignore-auto-dns", "no"] + ok, out = _dhcp_run_privileged(args, timeout=15) + if not ok: + return False, out + return _dhcp_run_privileged(["nmcli", "connection", "up", conn_name], timeout=20) + except Exception as e: + return False, str(e) + + if backend == "dhcpcd": + try: + with open("/etc/dhcpcd.conf", encoding="utf-8") as f: + content = f.read() + content = re.sub(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", "", content, flags=re.S | re.M) + block_lines = [f"interface {interface}"] + if mode == "static": + block_lines.append(f"static ip_address={ip}/{prefix}") + if gateway: + block_lines.append(f"static routers={gateway}") + if dns_list: + block_lines.append(f"static domain_name_servers={' '.join(dns_list)}") + if len(block_lines) > 1: + content = content.rstrip() + "\n\n" + "\n".join(block_lines) + "\n" + with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f: + f.write(content) + return _dhcp_run_privileged(["systemctl", "restart", "dhcpcd"], timeout=20) + except Exception as e: + return False, str(e) + + return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager noch dhcpcd aktiv)." + + +def _revert_network_config(token): + """Timer-Callback: falls binnen NETWORK_REVERT_SECONDS keine Bestätigung + einging, gesicherten Zustand wiederherstellen. Läuft in einem + Hintergrund-Thread (threading.Timer) — Fehler werden geloggt statt die + App zum Absturz zu bringen.""" + entry = _pending_network_revert.get(token) + if not entry: + return + backup = entry["backup"] + try: + if backup["backend"] == "dhcpcd" and backup.get("dhcpcd_conf") is not None: + with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f: + f.write(backup["dhcpcd_conf"]) + subprocess.run(["systemctl", "restart", "dhcpcd"], timeout=20) + elif backup["backend"] == "networkmanager": + _apply_network_config( + "networkmanager", backup["interface"], + backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []), + ) + 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) + finally: + _pending_network_revert.pop(token, None) + + @app.route("/settings", methods=["GET", "POST"]) @login_required def settings(): @@ -1227,16 +1427,75 @@ def settings(): if not current_user.has_permission("settings_system.edit"): flash("Keine Berechtigung, die Systemeinstellungen zu ändern.", "danger") return redirect(url_for("settings")) - new_interval = int(request.form["interval"]) - set_setting("interval", new_interval) - set_setting("check_interval", new_interval * 60) - log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten") - _restart_check_service() - flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success") + if "interval" in request.form: + new_interval = int(request.form["interval"]) + set_setting("interval", new_interval) + set_setting("check_interval", new_interval * 60) + log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten") + _restart_check_service() + flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success") + + elif "apply_network" in request.form: + interface = request.form.get("net_interface", "").strip() + mode = request.form.get("net_mode", "dhcp") + ip = request.form.get("net_ip", "").strip() + prefix = request.form.get("net_prefix", "").strip() + gateway = request.form.get("net_gateway", "").strip() + dns_list = [d.strip() for d in request.form.get("net_dns", "").split(",") if d.strip()] + backend = _detect_network_backend() + + if interface not in _list_network_interfaces(): + flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger") + elif backend == "unknown": + flash("Kein unterstütztes Netzwerk-Backend erkannt — Änderungen über die App sind deaktiviert.", "danger") + elif mode == "static" and not (ip and prefix and gateway): + flash("Für eine statische Konfiguration werden IP-Adresse, Prefix und Gateway benötigt.", "danger") + else: + backup = _backup_network_config(interface, backend) + ok, out = _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list) + if ok: + token = secrets.token_hex(8) + timer = threading.Timer(NETWORK_REVERT_SECONDS, _revert_network_config, args=(token,)) + timer.daemon = True + _pending_network_revert.clear() # nur eine ausstehende Änderung gleichzeitig + _pending_network_revert[token] = {"timer": timer, "backup": backup} + timer.start() + log_action("settings.network_apply", interface, f"Modus {mode}") + flash( + f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte " + f"unten bestätigen — sonst wird nach {NETWORK_REVERT_SECONDS}s automatisch zurückgerollt.", + "success", + ) + else: + flash(f"Anwenden fehlgeschlagen: {out}", "danger") + + elif "confirm_network" in request.form: + token = request.form.get("confirm_network") + entry = _pending_network_revert.pop(token, None) + if entry: + entry["timer"].cancel() + log_action("settings.network_confirm", entry["backup"]["interface"]) + flash("Netzwerkkonfiguration bestätigt — kein automatisches Rollback mehr.", "success") + else: + flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger") + return redirect(url_for("settings")) - return render_template("settings.html", interval=interval) + net_backend = _detect_network_backend() + net_interface = get_setting("net_interface") or (_list_network_interfaces() or [None])[0] + net_state = _read_network_state(net_interface, net_backend) if net_interface else None + pending_token = next(iter(_pending_network_revert), None) + return render_template( + "settings.html", + interval=interval, + net_backend=net_backend, + net_interfaces=_list_network_interfaces(), + net_interface=net_interface, + net_state=net_state, + net_revert_seconds=NETWORK_REVERT_SECONDS, + pending_network_token=pending_token, + ) @app.route("/settings/import-export") diff --git a/srv/poe_manager/templates/settings.html b/srv/poe_manager/templates/settings.html index 8791222..abcdce5 100644 --- a/srv/poe_manager/templates/settings.html +++ b/srv/poe_manager/templates/settings.html @@ -1,34 +1,129 @@ {% extends "base.html" %} {% set active_page = "settings_system" %} {% block page_title %}Systemeinstellungen{% endblock %} -{% block page_sub %}
Prüfintervall für das Monitoring
{% endblock %} +{% block page_sub %}
Prüfintervall und Netzwerkkonfiguration dieses Hosts
{% endblock %} {% block content %} -
-
-
-

Prüfintervall

-
Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?
+
+ +
+
+
+

Prüfintervall

+
Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?
+
-
- {% if current_user.has_permission('settings_system.edit') %} -
+ {% if current_user.has_permission('settings_system.edit') %} + +
+ + +
Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.
+
+ +
+ {% else %}
- - -
Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.
+ + +
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
- - - {% else %} -
- - -
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
+ {% endif %}
- {% endif %} + +
+
+
+

Netzwerkeinstellungen

+
IP-Adresse, DNS-Server und DHCP/Statisch-Umschaltung dieses Hosts selbst (nicht zu verwechseln mit dem DHCP-Server für Clients unter „DHCP“).
+
+
+ +
+ {% if net_backend == 'unknown' %} + Kein unterstütztes Backend erkannt + {% else %} + {{ 'NetworkManager' if net_backend == 'networkmanager' else 'dhcpcd' }} + {% endif %} + {% if net_state %} + + {{ net_interface }} + {% if net_state.ok %}— {{ net_state.ip }}/{{ net_state.prefix }}{% if net_state.gateway %}, Gateway {{ net_state.gateway }}{% endif %}{% endif %} + + + {{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[net_state.mode] }} + + {% endif %} +
+ {% if net_state and net_state.dns %} +
Aktuelle DNS-Server: {{ net_state.dns|join(', ') }}
+ {% endif %} + + {% if pending_network_token %} +
+

+ Neue Netzwerkkonfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung — + bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ net_revert_seconds }}s ohne Bestätigung). +

+
+ + +
+
+ {% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %} +
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.
+
+ +

+ ⚠ Kann die Erreichbarkeit dieses Hosts unterbrechen. Ohne Bestätigung wird automatisch nach {{ net_revert_seconds }}s zurückgerollt. +

+
+ {% elif net_backend == 'unknown' %} +

+ Weder NetworkManager noch dhcpcd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert. + Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen. +

+ {% else %} +

Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.

+ {% endif %} +
+
{% endblock %}