diff --git a/README.md b/README.md index 987db89..d54dfba 100644 --- a/README.md +++ b/README.md @@ -235,10 +235,16 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client oder „In Datei schreiben“ gekoppelt — ein unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, jede dieser Aktionen soll ein bewusster, separater Klick bleiben. -- **Konfiguration**: Interface, Subnet, Netzmaske, Range, DNS-Server, - Domain, Lease-Zeiten sowie der Ausgabepfad der generierten Kea-Config - (Standard: die aktive `/etc/kea/kea-dhcp4.conf`) — als `dhcp_*`-Schlüssel - in der bestehenden `settings`-Tabelle gespeichert. +- **Netzwerk-Kopplung**: Subnet, Netzmaske und Gateway werden **nie manuell + eingegeben**, sondern bei jeder Anzeige/Generierung live aus der + System-Netzwerkkonfiguration des gewählten Interfaces gelesen + (`_detect_interface_network()`: `ip -4 addr show dev ` + + `ip route show default`). Die generierte Kea-Konfiguration ist dadurch + immer an das Netz gekoppelt, in dem der Host tatsächlich hängt, auch wenn + sich dessen IP/Subnet mal ändert — nur das Interface selbst (Dropdown, + echte vorhandene Interfaces aus `/sys/class/net`), die Lease-Range, + DNS-Server, Domain, Lease-Zeiten und der Ausgabepfad sind konfigurierbar + (`dhcp_*`-Schlüssel in der `settings`-Tabelle). - **Eigene Options** (`dhcp_option_defs`/`dhcp_option_values`, analog den „Vordefinierten Optionen und Werten“ eines Windows-DHCP-Servers): Code, Name, Kea-Datentyp und Beschreibung frei definierbar (z.B. herstellerspezifische @@ -249,7 +255,7 @@ der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client `option-data` der jeweiligen Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). - **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP **innerhalb - des konfigurierten Subnets** wird eine Kea-Reservierung erzeugt; der + des erkannten Subnets** wird eine Kea-Reservierung erzeugt; der Hostname wird aus dem Gerätenamen abgeleitet (nur `a-z0-9-`, Kollisionen bekommen automatisch ein `-2`/`-3`-Suffix). Geräte ohne MAC/IP oder mit einer IP außerhalb des Subnets werden mit Hinweis übersprungen — Kea lehnt diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 94d8f3e..76cb602 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -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, diff --git a/srv/poe_manager/templates/settings_dhcp.html b/srv/poe_manager/templates/settings_dhcp.html index cbfdbcb..37da6ab 100644 --- a/srv/poe_manager/templates/settings_dhcp.html +++ b/srv/poe_manager/templates/settings_dhcp.html @@ -59,30 +59,44 @@

Netzwerk-Konfiguration

-
Grundlage für die generierte Subnet- und Reservierungs-Deklaration.
+
Subnet, Netzmaske und Gateway werden immer live aus der System-Netzwerkkonfiguration des gewählten Interfaces übernommen — nicht manuell gepflegt, damit sie nie aus dem Ruder laufen können.
+ + {% if net_info.ok %} +
+ Erkannt + {{ net_info.ip }}/{{ net_info.prefix }} (Netz {{ net_info.network }}) + {% if net_info.gateway %}Gateway {{ net_info.gateway }}{% endif %} +
+ {% else %} +
+ Nicht erkannt + {{ net_info.error }} +
+ {% endif %} + {% if can_edit %}
- -
-
- -
-
- + +
Subnet/Netzmaske/Gateway oben werden für das hier ausgewählte Interface erkannt.
+
Muss innerhalb des oben erkannten Netzes liegen.
-
Kommagetrennt, falls mehrere. Der erste Eintrag wird auch als Router/Gateway verwendet.
+
Kommagetrennt, falls mehrere. Router/Gateway kommt vom erkannten Netz oben; nur falls dort keins gefunden wird, wird ersatzweise der erste DNS-Eintrag verwendet.
@@ -173,7 +187,7 @@
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP im konfigurierten Subnet. {% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %} - {% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} des konfigurierten Subnets „{{ cfg.dhcp_subnet }}“ übersprungen — Kea lehnt Reservierungen außerhalb ihres Subnets ab.{% endif %} + {% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} des erkannten Netzes{% if net_info.network %} „{{ net_info.network }}“{% endif %} übersprungen — Kea lehnt Reservierungen außerhalb ihres Subnets ab.{% endif %}
{% if can_edit %}