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:
2026-08-11 13:43:06 +02:00
parent 357bb4caa5
commit 9660258940
2 changed files with 114 additions and 35 deletions
+15 -5
View File
@@ -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
+99 -30
View File
@@ -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)