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.
This commit is contained in:
@@ -263,12 +263,22 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
|
|||||||
schreibt die vollständige, generierte Kea-JSON-Konfiguration an den
|
schreibt die vollständige, generierte Kea-JSON-Konfiguration an den
|
||||||
konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem
|
konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem
|
||||||
Dienst-Neustart über den separaten Button.
|
Dienst-Neustart über den separaten Button.
|
||||||
- **Range ist Pflicht**: kein vorausgefüllter Default für Range/DNS mehr
|
- **Range ist Pflicht und muss zu einem echten Netz passen**: kein
|
||||||
(nur Platzhaltertext) — ohne eingetragene Range schreibt „In Datei
|
vorausgefüllter Default für Range/DNS mehr (nur Platzhaltertext) — ohne
|
||||||
schreiben“ nichts und „Aktivieren & (neu) starten“ verweigert den Start,
|
eingetragene Range schreibt „In Datei schreiben“ nichts und „Aktivieren &
|
||||||
statt den Dienst mit einer geratenen, zum echten Netz eventuell nicht
|
(neu) starten“ verweigert den Start. Zusätzlich wird die Range beim
|
||||||
passenden Range laufen zu lassen. Gateway ist separat und optional
|
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).
|
ü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
|
- **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`.
|
Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`.
|
||||||
- Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche
|
- Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche
|
||||||
|
|||||||
+99
-30
@@ -1739,34 +1739,20 @@ def _list_network_interfaces():
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _detect_interface_network(interface):
|
def _detect_interface_networks(interface):
|
||||||
"""Liest Subnet, Netzmaske und Gateway direkt aus der laufenden
|
"""Wie _detect_interface_network() (siehe unten), aber für ALLE IPv4-
|
||||||
System-Netzwerkkonfiguration des gewählten Interfaces aus (statt sie
|
Adressen des Interfaces statt nur der ersten — ein Interface kann
|
||||||
manuell pflegen zu lassen) — dadurch koppelt sich die generierte
|
mehrere IPs/Subnetze gleichzeitig tragen (z.B. Alias-IPs oder mehrere
|
||||||
DHCP-Konfiguration immer an das Netz, in dem der Host tatsächlich
|
Ranges auf derselben Karte). Gibt eine Liste von net_info-Dicts
|
||||||
hängt, auch wenn sich dessen IP/Subnet mal ändert."""
|
zurück (leer, falls keine IPv4-Adresse gefunden wurde/das Interface
|
||||||
result = {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None,
|
nicht existiert)."""
|
||||||
"dynamic": None, "error": None}
|
|
||||||
if not interface:
|
if not interface:
|
||||||
result["error"] = "Kein Interface ausgewählt."
|
return []
|
||||||
return result
|
|
||||||
try:
|
try:
|
||||||
addr_out = subprocess.run(
|
addr_out = subprocess.run(
|
||||||
["ip", "-4", "-o", "addr", "show", "dev", interface],
|
["ip", "-4", "-o", "addr", "show", "dev", interface],
|
||||||
capture_output=True, text=True, timeout=5,
|
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(
|
route_out = subprocess.run(
|
||||||
["ip", "-4", "route", "show", "default", "dev", interface],
|
["ip", "-4", "route", "show", "default", "dev", interface],
|
||||||
capture_output=True, text=True, timeout=5,
|
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,
|
["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)
|
gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
|
||||||
result["gateway"] = gw_match.group(1) if gw_match else None
|
gateway = gw_match.group(1) if gw_match else None
|
||||||
except Exception as e:
|
|
||||||
result["error"] = str(e)
|
results = []
|
||||||
return result
|
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):
|
def _dhcp_safe_hostname(name, mac):
|
||||||
@@ -1951,8 +1996,20 @@ def settings_dhcp():
|
|||||||
|
|
||||||
if "save_dhcp_config" in request.form:
|
if "save_dhcp_config" in request.form:
|
||||||
submitted_interface = request.form.get("dhcp_interface", "").strip()
|
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():
|
if submitted_interface and submitted_interface not in _list_network_interfaces():
|
||||||
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
|
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
|
||||||
|
elif range_error:
|
||||||
|
flash(range_error, "danger")
|
||||||
else:
|
else:
|
||||||
for key in DHCP_DEFAULTS:
|
for key in DHCP_DEFAULTS:
|
||||||
value = request.form.get(key, "").strip()
|
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")
|
flash("Range Start/Ende müssen eingetragen sein, bevor eine Konfiguration geschrieben wird.", "danger")
|
||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for("settings_dhcp"))
|
return redirect(url_for("settings_dhcp"))
|
||||||
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
net_info, range_error = _dhcp_matching_network(cfg["dhcp_interface"], cfg["dhcp_range_start"], cfg["dhcp_range_end"])
|
||||||
if not net_info["ok"]:
|
if range_error:
|
||||||
flash(f"Netzwerk-Erkennung für „{cfg['dhcp_interface']}“ fehlgeschlagen: {net_info['error']} — nichts geschrieben.", "danger")
|
flash(f"Nichts geschrieben: {range_error}", "danger")
|
||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for("settings_dhcp"))
|
return redirect(url_for("settings_dhcp"))
|
||||||
reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"])
|
reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"])
|
||||||
@@ -2081,8 +2138,20 @@ def settings_dhcp():
|
|||||||
|
|
||||||
elif "dhcp_service_action" in request.form:
|
elif "dhcp_service_action" in request.form:
|
||||||
action = request.form.get("dhcp_service_action")
|
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")
|
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":
|
elif action == "enable_restart":
|
||||||
ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
|
ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
|
||||||
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
|
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
|
||||||
|
|||||||
Reference in New Issue
Block a user