DHCP: Subnet/Netzmaske/Gateway immer aus System-Netzwerkkonfiguration ableiten
- Neue Helfer _list_network_interfaces() (echte Interfaces aus /sys/class/net) und _detect_interface_network() (IPv4/Prefix per "ip addr show", Gateway per "ip route show default") — Subnet/ Netzmaske/Router werden dadurch bei jeder Anzeige/Generierung live vom System gelesen statt manuell gepflegt zu werden. - dhcp_subnet/dhcp_netmask als manuelle Settings entfernt; Interface ist jetzt ein Dropdown mit den tatsächlich vorhandenen Interfaces statt Freitext, serverseitig zusätzlich gegen die echte Liste validiert. - _dhcp_reservation_candidates()/_render_kea_config() nehmen jetzt das erkannte Netz (ipaddress.IPv4Network) bzw. net_info entgegen statt Subnet/Netzmaske aus der Konfiguration zu lesen; Router kommt vom erkannten Gateway (Fallback: erster DNS-Eintrag, wie zuvor). - UI zeigt das erkannte Netz (IP/Prefix, Subnet, Gateway) read-only an; Schreiben/Vorschau werden blockiert bzw. liefern eine leere Reservierungsliste, wenn die Erkennung fehlschlägt, statt eine mit Sicherheit falsche Konfiguration zu erzeugen. - Live getestet: Erkennung liefert korrekt das tatsächliche WSL-NAT-Netz (172.25.64.0/20) samt Gateway; generierte Config besteht kea-dhcp4 -t; ein Testgerät mit IP im erkannten Netz erscheint korrekt als Reservierung, Geräte außerhalb werden weiterhin sauber übersprungen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+97
-45
@@ -1226,8 +1226,6 @@ DHCP_SERVICE = "kea-dhcp4-server"
|
||||
|
||||
DHCP_DEFAULTS = {
|
||||
"dhcp_interface": "eth0",
|
||||
"dhcp_subnet": "192.168.1.0",
|
||||
"dhcp_netmask": "255.255.255.0",
|
||||
"dhcp_range_start": "192.168.1.100",
|
||||
"dhcp_range_end": "192.168.1.200",
|
||||
"dhcp_dns": "192.168.1.1",
|
||||
@@ -1291,6 +1289,58 @@ def _detect_dhcp_status():
|
||||
return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version}
|
||||
|
||||
|
||||
def _list_network_interfaces():
|
||||
"""Echte Netzwerkschnittstellen dieses Hosts (ohne loopback) — Quelle
|
||||
für die Interface-Auswahl, damit nur tatsächlich vorhandene Interfaces
|
||||
wählbar sind statt eines frei eintippbaren Textfelds."""
|
||||
try:
|
||||
return sorted(name for name in os.listdir("/sys/class/net") if name != "lo")
|
||||
except OSError:
|
||||
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, "error": None}
|
||||
if not interface:
|
||||
result["error"] = "Kein Interface ausgewählt."
|
||||
return result
|
||||
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)
|
||||
result.update(ok=True, ip=ip_str, prefix=prefix, network=network, netmask=str(network.netmask))
|
||||
|
||||
route_out = subprocess.run(
|
||||
["ip", "-4", "route", "show", "default", "dev", interface],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
|
||||
if not gw_match:
|
||||
# Fallback: irgendeine Default-Route, falls keine speziell an
|
||||
# dieses Interface gebunden ist (typisch bei Hosts mit nur
|
||||
# einer aktiven Schnittstelle).
|
||||
route_out = subprocess.run(
|
||||
["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
|
||||
|
||||
|
||||
def _dhcp_safe_hostname(name, mac):
|
||||
"""Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
|
||||
gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/
|
||||
@@ -1304,20 +1354,17 @@ def _dhcp_safe_hostname(name, mac):
|
||||
return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:]
|
||||
|
||||
|
||||
def _dhcp_reservation_candidates(conn, cfg):
|
||||
def _dhcp_reservation_candidates(conn, network):
|
||||
"""Aktive Geräte mit gültiger MAC + IP, aus denen sich eine feste
|
||||
DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden
|
||||
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
|
||||
übersprungen: Geräte, deren IP außerhalb des konfigurierten Subnets
|
||||
liegt — Kea lehnt eine Reservierung außerhalb ihres subnet4-Blocks als
|
||||
Konfigurationsfehler ab (mit einem Live-Test gegen echtes Kea
|
||||
verifiziert), eine solche Reservierung würde also nie eine gültige
|
||||
Konfiguration ergeben."""
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{cfg['dhcp_subnet']}/{_dhcp_prefix_length(cfg['dhcp_netmask'])}", strict=False)
|
||||
except ValueError:
|
||||
network = None
|
||||
|
||||
übersprungen: Geräte, deren IP außerhalb des (aus der System-
|
||||
Netzwerkkonfiguration erkannten) Subnets liegt — Kea lehnt eine
|
||||
Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab
|
||||
(mit einem Live-Test gegen echtes Kea verifiziert). `network` ist ein
|
||||
ipaddress.IPv4Network oder None, falls die Erkennung fehlgeschlagen ist
|
||||
(dann werden alle Geräte als "außerhalb" übersprungen, statt eine mit
|
||||
Sicherheit ungültige Konfiguration zu erzeugen)."""
|
||||
rows = conn.execute(
|
||||
"SELECT mac, rpi_ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC"
|
||||
).fetchall()
|
||||
@@ -1358,25 +1405,18 @@ def _dhcp_option_values(conn):
|
||||
return values
|
||||
|
||||
|
||||
def _dhcp_prefix_length(netmask):
|
||||
"""Wandelt eine punktierte Netzmaske in die CIDR-Bitlänge für Keas
|
||||
"subnet"-Notation um. Fällt bei ungültiger Eingabe auf /24 zurück,
|
||||
statt die Konfigurationserzeugung mit einer Exception abzubrechen."""
|
||||
try:
|
||||
return ipaddress.IPv4Network(f"0.0.0.0/{netmask}").prefixlen
|
||||
except (ValueError, ipaddress.NetmaskValueError):
|
||||
return 24
|
||||
|
||||
|
||||
def _render_kea_config(cfg, reservations, option_defs, option_values):
|
||||
def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
|
||||
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
|
||||
Globale Options landen im Top-Level option-data (gilt für alle Subnetze),
|
||||
Client-Overrides im option-data der jeweiligen Reservierung — Kea
|
||||
überschreibt damit automatisch gleichnamige globale/Subnet-Options für
|
||||
genau diesen Client, ohne dass wir das selbst zusammenrechnen müssen."""
|
||||
prefix = _dhcp_prefix_length(cfg["dhcp_netmask"])
|
||||
Subnet/Netzmaske kommen bewusst NICHT aus manueller Eingabe, sondern aus
|
||||
net_info (siehe _detect_interface_network) — die Konfiguration koppelt
|
||||
sich damit immer an das Netz, in dem der gewählte Interface tatsächlich
|
||||
hängt. Globale Options landen im Top-Level option-data (gilt für alle
|
||||
Subnetze), Client-Overrides im option-data der jeweiligen Reservierung
|
||||
— Kea überschreibt damit automatisch gleichnamige globale/Subnet-
|
||||
Options für genau diesen Client, ohne dass wir das selbst
|
||||
zusammenrechnen müssen."""
|
||||
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip())
|
||||
router = cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else ""
|
||||
router = net_info.get("gateway") or (cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else "")
|
||||
|
||||
option_def_list = [
|
||||
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
|
||||
@@ -1422,11 +1462,11 @@ def _render_kea_config(cfg, reservations, option_defs, option_values):
|
||||
"subnet4": [
|
||||
{
|
||||
"id": 1,
|
||||
"subnet": f"{cfg['dhcp_subnet']}/{prefix}",
|
||||
"subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}",
|
||||
"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}],
|
||||
"reservations": kea_reservations,
|
||||
}
|
||||
],
|
||||
] if net_info.get("ok") else [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1458,12 +1498,16 @@ def settings_dhcp():
|
||||
return redirect(url_for("settings_dhcp"))
|
||||
|
||||
if "save_dhcp_config" in request.form:
|
||||
for key in DHCP_DEFAULTS:
|
||||
value = request.form.get(key, "").strip()
|
||||
if value:
|
||||
set_setting(key, value)
|
||||
log_action("settings.update", "DHCP-Konfiguration")
|
||||
flash("DHCP-Konfiguration gespeichert.", "success")
|
||||
submitted_interface = request.form.get("dhcp_interface", "").strip()
|
||||
if submitted_interface and submitted_interface not in _list_network_interfaces():
|
||||
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
|
||||
else:
|
||||
for key in DHCP_DEFAULTS:
|
||||
value = request.form.get(key, "").strip()
|
||||
if value:
|
||||
set_setting(key, value)
|
||||
log_action("settings.update", "DHCP-Konfiguration")
|
||||
flash("DHCP-Konfiguration gespeichert.", "success")
|
||||
|
||||
elif "add_dhcp_option" in request.form:
|
||||
code_raw = request.form.get("code", "").strip()
|
||||
@@ -1536,19 +1580,24 @@ def settings_dhcp():
|
||||
|
||||
elif "write_dhcp_file" in request.form:
|
||||
cfg = _load_dhcp_settings()
|
||||
reservations, _ = _dhcp_reservation_candidates(conn, cfg)
|
||||
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")
|
||||
conn.close()
|
||||
return redirect(url_for("settings_dhcp"))
|
||||
reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"])
|
||||
option_defs = _dhcp_option_defs(conn)
|
||||
option_values = _dhcp_option_values(conn)
|
||||
content = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
content = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
output_path = cfg["dhcp_output_path"]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(content + "\n")
|
||||
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen")
|
||||
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen, Netz {net_info['network']}")
|
||||
flash(
|
||||
f"{len(reservations)} Reservierungen nach {output_path} geschrieben. "
|
||||
f"Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
|
||||
f"{len(reservations)} Reservierungen für Netz {net_info['network']} (Interface {cfg['dhcp_interface']}) "
|
||||
f"nach {output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
|
||||
f"das führt die App bewusst nicht automatisch mit aus.",
|
||||
"success",
|
||||
)
|
||||
@@ -1593,7 +1642,8 @@ def settings_dhcp():
|
||||
return redirect(url_for("settings_dhcp"))
|
||||
|
||||
cfg = _load_dhcp_settings()
|
||||
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, cfg)
|
||||
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
||||
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, net_info.get("network"))
|
||||
option_defs = _dhcp_option_defs(conn)
|
||||
option_values = _dhcp_option_values(conn)
|
||||
skipped_count = conn.execute(
|
||||
@@ -1601,11 +1651,13 @@ def settings_dhcp():
|
||||
).fetchone()["n"]
|
||||
conn.close()
|
||||
status = _detect_dhcp_status()
|
||||
preview = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
preview = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
||||
return render_template(
|
||||
"settings_dhcp.html",
|
||||
cfg=cfg,
|
||||
status=status,
|
||||
net_info=net_info,
|
||||
interfaces=_list_network_interfaces(),
|
||||
reservations=reservations,
|
||||
skipped_count=skipped_count,
|
||||
out_of_subnet_count=out_of_subnet_count,
|
||||
|
||||
Reference in New Issue
Block a user