From 966025894072a62dca6ce5507ded83f8f91dfd6f Mon Sep 17 00:00:00 2001 From: alientim Date: Tue, 11 Aug 2026 13:43:06 +0200 Subject: [PATCH] Fix: DHCP-Range wurde nie gegen das tatsaechliche Netz geprueft Auf der Test-VM liess sich der Dienst mit einer gespeicherten Range (192.168.81.203-212) starten, obwohl das erkannte Netz des Interfaces 192.168.80.0/24 war -- die Range gehoerte also zu einem komplett anderen Subnetz. _dhcp_config_is_complete() prophfte bisher nur, ob Range Start/ Ende ueberhaupt ausgefuellt waren, nie ob sie zum echten Netz passen. Neue Funktion _dhcp_matching_network(interface, start, end) validiert die Range jetzt an drei Stellen: beim Speichern (save_dhcp_config), beim Schreiben der Kea-Datei (write_dhcp_file) und beim Starten des Dienstes (dhcp_service_action=enable_restart) -- jede der drei Aktionen wird mit einer genauen Fehlermeldung abgelehnt, statt eine zum Netz nicht passende Range durchzulassen. Die Pruefung beruecksichtigt dabei ALLE IPv4-Adressen des Interfaces, nicht nur die erste -- ein Interface kann mehrere IPs/Subnetze gleichzeitig tragen (Alias-IPs). Dafuer wurde _detect_interface_network() in eine neue _detect_interface_networks() (Mehrzahl, alle Adressen) plus einen duennen Wrapper fuer die primaere/erste Adresse aufgeteilt -- alle bisherigen Aufrufer (Anzeige auf der DHCP-Seite, Host-Netzwerkeinstellungen) bleiben unveraendert auf der primaeren Adresse. Echte Mehrfach-Ranges gleichzeitig (mehrere Subnetze parallel, z.B. auf verschiedenen Interfaces) sind bewusst nicht Teil dieses Fixes -- laut Ruecksprache ein separater, groesserer Umbau (DB-Tabelle statt globaler Range, mehrere subnet4-Bloecke in der Kea-Config, Listen-UI) und als naechster Schritt vorgesehen. Live auf der Test-VM verifiziert: falsche Range wird beim Speichern, Schreiben und Starten korrekt abgelehnt, eine zum erkannten Netz passende Range weiterhin akzeptiert -- keine Fehler im journalctl-Log. --- README.md | 20 +++++-- srv/poe_manager/app.py | 129 +++++++++++++++++++++++++++++++---------- 2 files changed, 114 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e6c28ea..b3ae0cf 100644 --- a/README.md +++ b/README.md @@ -263,12 +263,22 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client schreibt die vollständige, generierte Kea-JSON-Konfiguration an den konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem Dienst-Neustart über den separaten Button. -- **Range ist Pflicht**: kein vorausgefüllter Default für Range/DNS mehr - (nur Platzhaltertext) — ohne eingetragene Range schreibt „In Datei - schreiben“ nichts und „Aktivieren & (neu) starten“ verweigert den Start, - statt den Dienst mit einer geratenen, zum echten Netz eventuell nicht - passenden Range laufen zu lassen. Gateway ist separat und optional +- **Range ist Pflicht und muss zu einem echten Netz passen**: kein + vorausgefüllter Default für Range/DNS mehr (nur Platzhaltertext) — ohne + eingetragene Range schreibt „In Datei schreiben“ nichts und „Aktivieren & + (neu) starten“ verweigert den Start. Zusätzlich wird die Range beim + Speichern, Schreiben UND Starten gegen die tatsächlich am gewählten + Interface vorhandenen IPv4-Netze geprüft (ein Interface kann mehrere IPs/ + Subnetze gleichzeitig tragen, z.B. Alias-IPs — alle werden berücksichtigt). + Passt die Range zu keinem davon (z.B. weil sich das Netz des Hosts seit + dem letzten Speichern geändert hat), wird die Aktion mit einer genauen + Fehlermeldung abgelehnt, statt den Dienst mit einer zum echten Netz nicht + passenden Range laufen zu lassen — live auf einer echten Fehlkonfiguration + reproduziert und verifiziert. Gateway ist separat und optional überschreibbar (Default: automatisch erkanntes Gateway des Interfaces). + (Mehrere DHCP-Ranges gleichzeitig — z.B. auf unterschiedlichen Interfaces + — sind aktuell nicht unterstützt, die Konfiguration ist auf eine globale + Range/ein Interface ausgelegt; als nächster Ausbauschritt vorgesehen.) - **Ampel im Topbar**: neben dem Prüfintervall-Timer zeigt ein grüner/roter Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`. - Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index c5101f3..f72ef73 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -1739,34 +1739,20 @@ def _list_network_interfaces(): return [] -def _detect_interface_network(interface): - """Liest Subnet, Netzmaske und Gateway direkt aus der laufenden - System-Netzwerkkonfiguration des gewählten Interfaces aus (statt sie - 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, - "dynamic": None, "error": None} +def _detect_interface_networks(interface): + """Wie _detect_interface_network() (siehe unten), aber für ALLE IPv4- + Adressen des Interfaces statt nur der ersten — ein Interface kann + mehrere IPs/Subnetze gleichzeitig tragen (z.B. Alias-IPs oder mehrere + Ranges auf derselben Karte). Gibt eine Liste von net_info-Dicts + zurück (leer, falls keine IPv4-Adresse gefunden wurde/das Interface + nicht existiert).""" if not interface: - result["error"] = "Kein Interface ausgewählt." - return result + return [] try: addr_out = subprocess.run( ["ip", "-4", "-o", "addr", "show", "dev", interface], capture_output=True, text=True, timeout=5, ) - match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", addr_out.stdout) - if not match: - result["error"] = f"Keine IPv4-Adresse auf „{interface}“ gefunden." - return result - ip_str, prefix = match.group(1), int(match.group(2)) - network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False) - # 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], capture_output=True, text=True, timeout=5, @@ -1780,10 +1766,69 @@ def _detect_interface_network(interface): ["ip", "-4", "route", "show", "default"], capture_output=True, text=True, timeout=5, ) gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout) - result["gateway"] = gw_match.group(1) if gw_match else None - except Exception as e: - result["error"] = str(e) - return result + gateway = gw_match.group(1) if gw_match else None + + results = [] + for line in addr_out.stdout.splitlines(): + match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", line) + if not match: + continue + ip_str, prefix = match.group(1), int(match.group(2)) + network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False) + # 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 = "dynamic" in line + results.append({ + "ok": True, "ip": ip_str, "prefix": prefix, "network": network, + "netmask": str(network.netmask), "gateway": gateway, "dynamic": dynamic, "error": None, + }) + return results + except Exception: + return [] + + +def _detect_interface_network(interface): + """Liest Subnet, Netzmaske und Gateway der PRIMÄREN (ersten) IPv4- + Adresse eines Interfaces direkt aus der laufenden System- + Netzwerkkonfiguration aus (statt sie 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. Trägt das Interface mehrere IPv4-Adressen, + siehe _detect_interface_networks() für alle.""" + if not interface: + return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, + "dynamic": None, "error": "Kein Interface ausgewählt."} + networks = _detect_interface_networks(interface) + if not networks: + return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, + "dynamic": None, "error": f"Keine IPv4-Adresse auf „{interface}“ gefunden."} + return networks[0] + + +def _dhcp_matching_network(interface, range_start, range_end): + """Prüft eine DHCP-Range gegen ALLE auf dem Interface aktuell + konfigurierten IPv4-Netze (ein Interface kann mehrere IPs/Subnetze + tragen, z.B. Alias-IPs) und gibt bei Erfolg (net_info, None) zurück, + sonst (None, Fehlertext). Verhindert, dass eine Range gespeichert, + geschrieben oder ein Dienststart erlaubt wird, die zu keinem + tatsächlich vorhandenen Subnetz passt — z.B. wenn sich das Netz des + Hosts seit dem letzten Speichern geändert hat.""" + try: + start_ip = ipaddress.IPv4Address(range_start) + end_ip = ipaddress.IPv4Address(range_end) + except ValueError: + return None, "Range Start/Ende müssen gültige IPv4-Adressen sein." + if int(start_ip) > int(end_ip): + return None, "Range Start muss kleiner oder gleich Range Ende sein." + networks = _detect_interface_networks(interface) + if not networks: + return None, f"Keine IPv4-Adresse auf „{interface}“ gefunden." + for net_info in networks: + if start_ip in net_info["network"] and end_ip in net_info["network"]: + return net_info, None + nets_str = ", ".join(str(n["network"]) for n in networks) + return None, f"Range {range_start}–{range_end} liegt in keinem der auf „{interface}“ erkannten Netze ({nets_str})." def _dhcp_safe_hostname(name, mac): @@ -1951,8 +1996,20 @@ def settings_dhcp(): if "save_dhcp_config" in request.form: submitted_interface = request.form.get("dhcp_interface", "").strip() + range_start = request.form.get("dhcp_range_start", "").strip() + range_end = request.form.get("dhcp_range_end", "").strip() + range_error = None + if range_start and range_end: + # Gegen das (ggf. gerade erst umgestellte) Interface aus DIESEM + # Formular prüfen, nicht gegen das zuvor gespeicherte — sonst + # würde ein gleichzeitiger Interface- und Range-Wechsel in einem + # Speichern fälschlich gegen das alte Interface geprüft. + check_interface = submitted_interface or get_setting("dhcp_interface", DHCP_DEFAULTS["dhcp_interface"]) + _, range_error = _dhcp_matching_network(check_interface, range_start, range_end) if submitted_interface and submitted_interface not in _list_network_interfaces(): flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger") + elif range_error: + flash(range_error, "danger") else: for key in DHCP_DEFAULTS: value = request.form.get(key, "").strip() @@ -2036,9 +2093,9 @@ def settings_dhcp(): flash("Range Start/Ende müssen eingetragen sein, bevor eine Konfiguration geschrieben wird.", "danger") conn.close() return redirect(url_for("settings_dhcp")) - net_info = _detect_interface_network(cfg["dhcp_interface"]) - if not net_info["ok"]: - flash(f"Netzwerk-Erkennung für „{cfg['dhcp_interface']}“ fehlgeschlagen: {net_info['error']} — nichts geschrieben.", "danger") + net_info, range_error = _dhcp_matching_network(cfg["dhcp_interface"], cfg["dhcp_range_start"], cfg["dhcp_range_end"]) + if range_error: + flash(f"Nichts geschrieben: {range_error}", "danger") conn.close() return redirect(url_for("settings_dhcp")) reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"]) @@ -2081,8 +2138,20 @@ def settings_dhcp(): elif "dhcp_service_action" in request.form: action = request.form.get("dhcp_service_action") - if action == "enable_restart" and not _dhcp_config_is_complete(_load_dhcp_settings()): + cfg_now = _load_dhcp_settings() + # Bewusst auch beim Starten nochmal live gegen das aktuelle Netz + # geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich + # der Dienst mit einer Range starten, die z.B. nach einem + # Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr + # zu einem echten Subnetz passt. + range_error = ( + _dhcp_matching_network(cfg_now["dhcp_interface"], cfg_now["dhcp_range_start"], cfg_now["dhcp_range_end"])[1] + if _dhcp_config_is_complete(cfg_now) else None + ) + if action == "enable_restart" and not _dhcp_config_is_complete(cfg_now): flash("Range Start/Ende müssen eingetragen und die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.", "danger") + elif action == "enable_restart" and range_error: + flash(f"Dienst nicht gestartet: {range_error}", "danger") elif action == "enable_restart": ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15) ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)