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
+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)