From efda77f813a927bf564fa8b1cec363fdb9a99874 Mon Sep 17 00:00:00 2001 From: alientim Date: Tue, 11 Aug 2026 14:41:41 +0200 Subject: [PATCH] Fix: falscher Kea-Schluessel lease-file statt name blockierte jeden Dienststart Live reproduziert: 'kea-dhcp4 -t' lehnte JEDE generierte Konfiguration mit 'got unexpected keyword "lease-file" in lease-database map' ab, sobald mindestens ein Subnetz konfiguriert war -- der Dienst liess sich dadurch nie starten, unabhaengig von der Anzahl/Gueltigkeit der Subnetze selbst. Kea nennt den Datei-Pfad des memfile-Lease-Backends 'name', nicht 'lease-file'. Nach dem Fix: 'kea-dhcp4 -t' besteht, Dienststart auf der Test-VM mit einem echten Subnetz verifiziert (DHCP4_STARTED, Dienst aktiv). --- srv/poe_manager/app.py | 511 +++++++++++++++++++++++++++++------------ 1 file changed, 359 insertions(+), 152 deletions(-) diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index f72ef73..d08b1b4 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -18,7 +18,7 @@ from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from datetime import datetime -import base64, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback +import base64, csv, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko import yaml @@ -604,6 +604,50 @@ def _ensure_schema(): ) """) + # DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen + # Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz + # haben, für die alle DHCP angeboten werden soll. gateway/dns sind je + # Subnetz optional (leer = automatisch erkanntes Gateway des Interfaces + # bzw. kein DNS-Override); domain/Lease-Zeiten/Ausgabepfad bleiben + # bewusst global (siehe DHCP_DEFAULTS), da sie Kea-weit gelten. + conn.execute(""" + CREATE TABLE IF NOT EXISTS dhcp_subnets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interface TEXT NOT NULL, + range_start TEXT NOT NULL, + range_end TEXT NOT NULL, + gateway TEXT, + dns TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + """) + # Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/ + # _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile + # der neuen Tabelle überführen, statt eine bereits funktionierende + # Konfiguration beim Upgrade stillschweigend zu verlieren. Per Guard nur + # einmalig, da die alten settings-Schlüssel absichtlich stehen bleiben + # (Altlast, wird nicht mehr gelesen) und sonst bei jedem Start erneut + # eine (dann ggf. schon gelöschte) Zeile anlegen würde. + _migration_key_dhcp = "_migrated_dhcp_single_range_to_subnets_v1" + if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_dhcp,)).fetchone(): + legacy_start = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_start'").fetchone() + legacy_end = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_end'").fetchone() + if legacy_start and legacy_end and legacy_start["value"] and legacy_end["value"]: + legacy_interface = conn.execute("SELECT value FROM settings WHERE key='dhcp_interface'").fetchone() + legacy_gateway = conn.execute("SELECT value FROM settings WHERE key='dhcp_gateway'").fetchone() + legacy_dns = conn.execute("SELECT value FROM settings WHERE key='dhcp_dns'").fetchone() + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) " + "VALUES (?, ?, ?, ?, ?, 0)", + ( + (legacy_interface["value"] if legacy_interface else None) or "eth0", + legacy_start["value"], legacy_end["value"], + legacy_gateway["value"] if legacy_gateway else None, + legacy_dns["value"] if legacy_dns else None, + ), + ) + conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_dhcp,)) + # Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten # (ältere DB-Version) in eigene Credentials-Datensätze überführen. if "username" in switch_cols and "password" in switch_cols: @@ -1552,8 +1596,9 @@ def settings(): if _stop_dhcp_service_if_active(): log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt") msg += ( - " Der DHCP-Server wurde dabei gestoppt, da die konfigurierte Range zum neuen Netz nicht " - "mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf neu starten." + " Der DHCP-Server wurde dabei gestoppt und deaktiviert, da die konfigurierte Range zum " + "neuen Netz nicht mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf " + "neu starten (bleibt sonst auch nach einem Host-Neustart aus)." ) flash(msg, "success") else: @@ -1625,19 +1670,19 @@ def settings_import_export(): DHCP_PACKAGE = "kea-dhcp4-server" DHCP_SERVICE = "kea-dhcp4-server" +# Explizit statt dem eingebauten Kea-Default gesetzt (siehe _render_kea_config) +# — Anzeige der aktiven Leases (_dhcp_read_leases) liest genau diesen Pfad, +# damit beide Seiten garantiert übereinstimmen, statt sich auf einen +# distributionsabhängigen impliziten Default zu verlassen. +KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv" +# Interface, Range, Gateway und DNS sind seit der Mehrfach-Subnetz- +# Unterstützung KEINE globalen Einstellungen mehr, sondern je Zeile in der +# dhcp_subnets-Tabelle gepflegt (siehe _dhcp_subnets/_dhcp_matching_network) +# — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben. +# Domain, Lease-Zeiten und Ausgabepfad gelten dagegen für den gesamten +# Kea-Dienst und bleiben deshalb global. DHCP_DEFAULTS = { - "dhcp_interface": "eth0", - # Bewusst KEINE vorausgefüllten Range-/DNS-Defaults, die wie eine echte - # Konfiguration aussehen könnten, aber zufällig nicht zum echten Netz - # passen — leer + Platzhaltertext im Formular ist ehrlicher, als einen - # Dienst unbemerkt mit falschen Werten starten zu lassen. - "dhcp_range_start": "", - "dhcp_range_end": "", - "dhcp_dns": "", - # Optionaler Override — leer bedeutet "automatisch erkanntes Gateway - # dieses Hosts verwenden" (siehe _render_kea_config). - "dhcp_gateway": "", "dhcp_domain": "local", "dhcp_lease_default": "600", "dhcp_lease_max": "7200", @@ -1645,8 +1690,18 @@ DHCP_DEFAULTS = { } -def _dhcp_config_is_complete(cfg): - return bool(cfg.get("dhcp_range_start") and cfg.get("dhcp_range_end")) +def _dhcp_subnets(conn): + return conn.execute("SELECT * FROM dhcp_subnets ORDER BY sort_order ASC, id ASC").fetchall() + + +def _dhcp_subnet_matches(subnet_row): + """Validiert eine einzelne Subnetz-Zeile gegen das tatsächliche Netz + ihres Interfaces (siehe _dhcp_matching_network) und gibt bei Erfolg + (net_info, None) zurück, sonst (None, Fehlertext). Eine Subnetz-Zeile + ohne passendes reales Netz kann Kea nicht zugeordnet werden — es muss + eine physische IP im gewünschten Bereich vorhanden sein, sonst fehlt + die CIDR/Netzmaske, die Kea für den subnet4-Block braucht.""" + return _dhcp_matching_network(subnet_row["interface"], subnet_row["range_start"], subnet_row["range_end"]) # Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible # Teilmenge statt der vollen Liste, orientiert an den in der Praxis @@ -1664,17 +1719,7 @@ DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES} def _load_dhcp_settings(): - cfg = {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} - available = _list_network_interfaces() - if available and cfg.get("dhcp_interface") not in available: - # Der hart codierte Default ("eth0") bzw. ein früher gespeicherter Wert - # existiert auf Systemen mit Predictable Network Interface Names - # (z.B. "enp1s0") nicht — dann lieber die tatsächlich vorhandene erste - # Schnittstelle vorschlagen, statt eine erkennbar falsche anzuzeigen - # (führte sonst dazu, dass die Erkennung fälschlich "eth0" statt der - # im Dropdown sichtbaren echten Schnittstelle prüfte). - cfg["dhcp_interface"] = available[0] - return cfg + return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} def _detect_dhcp_status(): @@ -1713,18 +1758,23 @@ def _detect_dhcp_status(): def _stop_dhcp_service_if_active(): - """Stoppt den Kea-DHCP-Dienst, falls er gerade läuft — aufgerufen, wenn - sich die Host-Netzwerkkonfiguration ändert (Systemeinstellungen > - Netzwerkeinstellungen). Eine vorher passende Range/Interface-Bindung - kann nach einer IP-/Subnetzänderung plötzlich zum falschen Netz gehören. - Bewusst nur stoppen, nicht automatisch neu starten — der Admin muss die - Range aktiv auf der DHCP-Seite prüfen/bestätigen, statt dass die App mit - einer möglicherweise falschen Konfiguration weiterläuft oder blind rät.""" + """Stoppt UND deaktiviert den Kea-DHCP-Dienst, falls er gerade läuft — + aufgerufen, wenn sich die Host-Netzwerkkonfiguration ändert + (Systemeinstellungen > Netzwerkeinstellungen). Eine vorher passende + Range/Interface-Bindung kann nach einer IP-/Subnetzänderung plötzlich + zum falschen Netz gehören. Bewusst nur stoppen, nicht automatisch neu + starten — der Admin muss die Range aktiv auf der DHCP-Seite + prüfen/bestätigen, statt dass die App mit einer möglicherweise falschen + Konfiguration weiterläuft oder blind rät. Zusätzlich deaktivieren (nicht + nur stoppen), damit ein Host-Neustart vor dieser Prüfung den Dienst + nicht trotzdem automatisch mit der ggf. falschen Range wieder hochfährt.""" try: status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5) if status.stdout.strip() != "active": return False - return subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0 + ok = subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0 + subprocess.run(["systemctl", "disable", DHCP_SERVICE], timeout=15) + return ok except Exception: return False @@ -1844,42 +1894,70 @@ def _dhcp_safe_hostname(name, mac): return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:] -def _dhcp_reservation_candidates(conn, network): +def _dhcp_valid_subnets(subnets): + """Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen + ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine + physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea + damit nichts anfangen). Gibt (valid, invalid) zurück: valid ist um + "net_info" angereichert (für Reservierungs-Zuordnung und Kea-Config), + invalid enthält (Zeile, Fehlertext) für die Anzeige/Warnungen.""" + valid, invalid = [], [] + for s in subnets: + net_info, error = _dhcp_subnet_matches(s) + if error: + invalid.append((s, error)) + else: + valid.append({ + "id": s["id"], "interface": s["interface"], + "range_start": s["range_start"], "range_end": s["range_end"], + "gateway": s["gateway"], "dns": s["dns"], "net_info": net_info, + }) + return valid, invalid + + +def _dhcp_reservation_candidates(conn, valid_subnets): """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 (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).""" + übersprungen: Geräte, deren IP zu KEINEM der übergebenen (bereits + validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb + ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test + gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id, + out_of_subnet_count) zurück — jedes Gerät landet bei genau einem + Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere + Subnetze gleichzeitig anbieten).""" 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() - seen_hostnames = set() - reservations = [] + seen_hostnames_by_subnet = {s["id"]: set() for s in valid_subnets} + reservations_by_subnet = {s["id"]: [] for s in valid_subnets} out_of_subnet = 0 for row in rows: try: - in_subnet = network is not None and ipaddress.IPv4Address(row["rpi_ip"]) in network + ip = ipaddress.IPv4Address(row["rpi_ip"]) except ValueError: - in_subnet = False - if not in_subnet: + out_of_subnet += 1 + continue + matched = next((s for s in valid_subnets if ip in s["net_info"]["network"]), None) + if matched is None: out_of_subnet += 1 continue hostname = _dhcp_safe_hostname(row["name"], row["mac"]) # Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem - # Namen) auflösen, statt zwei Reservierungen mit demselben - # Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen). + # Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit + # demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen). + seen = seen_hostnames_by_subnet[matched["id"]] base_hostname, suffix = hostname, 2 - while hostname in seen_hostnames: + while hostname in seen: hostname = f"{base_hostname}-{suffix}" suffix += 1 - seen_hostnames.add(hostname) - reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]}) - return reservations, out_of_subnet + seen.add(hostname) + reservations_by_subnet[matched["id"]].append({ + "mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"], + "subnet_id": matched["id"], + "subnet_label": f"{matched['interface']} ({matched['net_info']['network']})", + }) + return reservations_by_subnet, out_of_subnet def _dhcp_option_defs(conn): @@ -1895,32 +1973,70 @@ def _dhcp_option_values(conn): return values -def _render_kea_config(cfg, net_info, reservations, option_defs, option_values): - """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar). - 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()) - # Gateway: expliziter Override hat Vorrang, sonst automatisch erkanntes - # Gateway dieses Hosts — für den Fall, dass Clients einen anderen Router - # bekommen sollen als den, über den dieser Host selbst geht. - router = cfg.get("dhcp_gateway", "").strip() or net_info.get("gateway") or "" +def _dhcp_read_leases(): + """Liest die aktuell aktiven Kea-Leases direkt aus der memfile-CSV + (siehe lease-file in _render_kea_config) — kein Kea-Control-Agent + nötig, nur Lesezugriff auf die Datei. Wichtig, um Clients OHNE eigene + Reservierung sichtbar zu machen, die sich einfach eine freie IP aus dem + Pool genommen haben (siehe reserved-Flag, wird vom Aufrufer ergänzt). + Gibt None zurück, falls die Datei (noch) nicht existiert — z.B. weil + der Dienst noch nie gestartet wurde oder noch keine Adresse vergeben + hat — statt fälschlich "keine Leases" zu behaupten.""" + try: + with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + except OSError: + return None + now = int(time.time()) + leases = [] + for row in rows: + # state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt), + # 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist + # eine tatsächlich vergebene, gültige Adresse. + if (row.get("state") or "0") != "0": + continue + try: + expire = int(row.get("expire") or 0) + except ValueError: + expire = 0 + # Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc) + # noch nicht aus der CSV entfernt — nicht als aktiv anzeigen. + if expire and expire < now: + continue + leases.append({ + "ip": row.get("address") or "", + "mac": (row.get("hwaddr") or "").upper(), + "hostname": row.get("hostname") or "", + "expires_at": datetime.fromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None, + }) + try: + leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"])) + except ValueError: + pass + return leases + +def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values): + """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON- + serialisierbar) — ein subnet4-Block je konfiguriertem Subnetz, damit ein + Host mehrere IPs/Interfaces mit jeweils eigenem Netz gleichzeitig + bedienen kann. Subnet/Netzmaske kommen bewusst NICHT aus manueller + Eingabe, sondern aus dem net_info jedes Subnetzes (siehe + _dhcp_valid_subnets) — nur Subnetze mit einer zu einem echten, + tatsächlich vorhandenen Netz passenden Range fließen hier ein + (`valid_subnets`, bereits gefiltert). Gateway/DNS sind je Subnetz + eigenes option-data (unterschiedliche Netze haben i.d.R. + unterschiedliche Router); Domain und selbstdefinierte Options bleiben + global. Client-Overrides landen 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.""" option_def_list = [ {"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} for d in option_defs ] global_option_data = [] - if router: - global_option_data.append({"name": "routers", "data": router}) - if dns_servers: - global_option_data.append({"name": "domain-name-servers", "data": dns_servers}) if cfg["dhcp_domain"]: global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]}) for d in option_defs: @@ -1928,42 +2044,56 @@ def _render_kea_config(cfg, net_info, reservations, option_defs, option_values): if global_value: global_option_data.append({"name": d["name"], "data": global_value}) - kea_reservations = [] - for r in reservations: - res_option_data = [] - for d in option_defs: - override = option_values.get(d["id"], {}).get(r["mac"]) - if override: - res_option_data.append({"name": d["name"], "data": override}) - entry = { - "hw-address": r["mac"].lower(), - "ip-address": r["ip"], - "hostname": r["hostname"], - } - if res_option_data: - entry["option-data"] = res_option_data - kea_reservations.append(entry) + subnet4 = [] + for idx, s in enumerate(valid_subnets, start=1): + # Gateway: expliziter Override hat Vorrang, sonst automatisch + # erkanntes Gateway dieses Hosts — für den Fall, dass Clients einen + # anderen Router bekommen sollen als den, über den dieser Host für + # dieses Netz selbst geht. + router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or "" + dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip()) + sub_option_data = [] + if router: + sub_option_data.append({"name": "routers", "data": router}) + if dns_servers: + sub_option_data.append({"name": "domain-name-servers", "data": dns_servers}) + + kea_reservations = [] + for r in reservations_by_subnet.get(s["id"], []): + res_option_data = [] + for d in option_defs: + override = option_values.get(d["id"], {}).get(r["mac"]) + if override: + res_option_data.append({"name": d["name"], "data": override}) + entry = { + "hw-address": r["mac"].lower(), + "ip-address": r["ip"], + "hostname": r["hostname"], + } + if res_option_data: + entry["option-data"] = res_option_data + kea_reservations.append(entry) + + subnet4.append({ + "id": idx, + "subnet": f"{s['net_info']['network'].network_address}/{s['net_info']['network'].prefixlen}", + "pools": [{"pool": f"{s['range_start']} - {s['range_end']}"}], + **({"option-data": sub_option_data} if sub_option_data else {}), + "reservations": kea_reservations, + }) return { "Dhcp4": { - "interfaces-config": {"interfaces": [cfg["dhcp_interface"]]}, - "lease-database": {"type": "memfile", "lfc-interval": 3600}, + "interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})}, + # Kea nennt den Datei-Pfad des memfile-Backends "name", nicht + # "lease-file" (mit falschem Schlüssel lehnt Kea die Config mit + # "unexpected keyword" ab — live so reproduziert und gefixt). + "lease-database": {"type": "memfile", "lfc-interval": 3600, "name": KEA_LEASE_FILE}, "valid-lifetime": int(cfg["dhcp_lease_default"] or 600), "max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200), "option-def": option_def_list, "option-data": global_option_data, - "subnet4": [ - { - "id": 1, - "subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}", - # Kein Pool ohne eingetragene Range — ein leerer/falscher - # Pool-String wäre eine Kea-Konfiguration, die entweder - # gar nicht startet oder (schlimmer) mit einer geratenen - # Range startet, die nicht zum echten Netz passt. - **({"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}]} if _dhcp_config_is_complete(cfg) else {}), - "reservations": kea_reservations, - } - ] if net_info.get("ok") else [], + "subnet4": subnet4, } } @@ -1995,28 +2125,67 @@ def settings_dhcp(): return redirect(url_for("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") + # Nur noch die globalen, Kea-weiten Einstellungen — Interface/ + # Range/Gateway/DNS werden seit der Mehrfach-Subnetz-Unterstützung + # separat je Subnetz gepflegt (siehe add/edit/delete_dhcp_subnet). + 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_subnet" in request.form: + interface = request.form.get("interface", "").strip() + range_start = request.form.get("range_start", "").strip() + range_end = request.form.get("range_end", "").strip() + gateway = request.form.get("gateway", "").strip() + dns = request.form.get("dns", "").strip() + if interface not in _list_network_interfaces(): + flash(f"Interface „{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") + _, range_error = _dhcp_matching_network(interface, range_start, range_end) + if range_error: + flash(range_error, "danger") + else: + max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"] + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) " + "VALUES (?, ?, ?, ?, ?, ?)", + (interface, range_start, range_end, gateway or None, dns or None, max_order + 1), + ) + conn.commit() + log_action("settings.dhcp_subnet_add", interface, f"{range_start}–{range_end}") + flash(f"Subnetz {range_start}–{range_end} auf „{interface}“ hinzugefügt.", "success") + + elif "edit_dhcp_subnet" in request.form: + subnet_id = request.form.get("edit_dhcp_subnet") + interface = request.form.get("interface", "").strip() + range_start = request.form.get("range_start", "").strip() + range_end = request.form.get("range_end", "").strip() + gateway = request.form.get("gateway", "").strip() + dns = request.form.get("dns", "").strip() + if interface not in _list_network_interfaces(): + flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger") + else: + _, range_error = _dhcp_matching_network(interface, range_start, range_end) + if range_error: + flash(range_error, "danger") + else: + conn.execute( + "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=? WHERE id=?", + (interface, range_start, range_end, gateway or None, dns or None, subnet_id), + ) + conn.commit() + log_action("settings.dhcp_subnet_edit", interface, f"{range_start}–{range_end}") + flash("Subnetz aktualisiert.", "success") + + elif "delete_dhcp_subnet" in request.form: + subnet_id = request.form.get("delete_dhcp_subnet") + conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,)) + conn.commit() + log_action("settings.dhcp_subnet_delete", subnet_id) + flash("Subnetz gelöscht.", "success") elif "add_dhcp_option" in request.form: code_raw = request.form.get("code", "").strip() @@ -2089,31 +2258,41 @@ def settings_dhcp(): elif "write_dhcp_file" in request.form: cfg = _load_dhcp_settings() - if not _dhcp_config_is_complete(cfg): - flash("Range Start/Ende müssen eingetragen sein, bevor eine Konfiguration geschrieben wird.", "danger") + valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn)) + if not valid_subnets: + flash( + "Kein gültiges Subnetz konfiguriert — mindestens ein Subnetz mit einer zu einem echten Netz " + "passenden Range wird benötigt, bevor eine Konfiguration geschrieben wird.", + "danger", + ) conn.close() return redirect(url_for("settings_dhcp")) - 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"]) + reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets) option_defs = _dhcp_option_defs(conn) option_values = _dhcp_option_values(conn) - content = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False) + content = json.dumps( + _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values), + indent=2, ensure_ascii=False, + ) output_path = cfg["dhcp_output_path"] + total_reservations = sum(len(v) for v in reservations_by_subnet.values()) 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, Netz {net_info['network']}") - flash( - 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", + log_action( + "settings.dhcp_write", output_path, + f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen", ) + msg = ( + f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach " + f"{output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart " + f"(Button unten) — das führt die App bewusst nicht automatisch mit aus." + ) + if invalid_subnets: + skipped = ", ".join(f"{s['interface']} ({s['range_start']}–{s['range_end']})" for s, _ in invalid_subnets) + msg += f" Übersprungen, da zu keinem echten Netz passend: {skipped}." + flash(msg, "success") except OSError as e: flash(f"Konnte Datei nicht schreiben: {e}", "danger") @@ -2138,20 +2317,18 @@ def settings_dhcp(): elif "dhcp_service_action" in request.form: action = request.form.get("dhcp_service_action") - 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") + valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn)) + if action == "enable_restart" and not valid_subnets_now: + flash( + "Mindestens ein Subnetz mit einer zu einem echten Netz passenden Range muss konfiguriert und " + "die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.", + "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) @@ -2162,15 +2339,24 @@ def settings_dhcp(): flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger") elif action == "stop": ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15) + # Auch deaktivieren, nicht nur stoppen — sonst würde systemd + # den Dienst nach einem Neustart des Hosts automatisch wieder + # starten, obwohl der Admin ihn hier bewusst abgeschaltet hat. + # Damit übersteht ein Host-Neustart genau den zuletzt bewusst + # gewählten Zustand (an oder aus), statt Kea unabhängig davon + # immer wieder hochzufahren. + _dhcp_run_privileged(["systemctl", "disable", DHCP_SERVICE], timeout=15) log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen")) - flash(f"{DHCP_SERVICE} gestoppt." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger") + flash(f"{DHCP_SERVICE} gestoppt und deaktiviert (startet nach einem Neustart nicht automatisch wieder)." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger") conn.close() return redirect(url_for("settings_dhcp")) cfg = _load_dhcp_settings() - net_info = _detect_interface_network(cfg["dhcp_interface"]) - reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, net_info.get("network")) + subnets = _dhcp_subnets(conn) + valid_subnets, invalid_subnets = _dhcp_valid_subnets(subnets) + reservations_by_subnet, out_of_subnet_count = _dhcp_reservation_candidates(conn, valid_subnets) + reservations = [r for rs in reservations_by_subnet.values() for r in rs] option_defs = _dhcp_option_defs(conn) option_values = _dhcp_option_values(conn) skipped_count = conn.execute( @@ -2178,12 +2364,32 @@ def settings_dhcp(): ).fetchone()["n"] conn.close() status = _detect_dhcp_status() - preview = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False) + preview = json.dumps( + _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values), + indent=2, ensure_ascii=False, + ) + # Aktive Leases direkt aus Kea auslesen (siehe _dhcp_read_leases) und um + # ein reserved-Flag ergänzen — Clients OHNE eigene Reservierung, die sich + # einfach eine freie IP aus dem Pool genommen haben, sollen auffallen. + reserved_macs = {r["mac"].upper() for r in reservations} + leases = _dhcp_read_leases() + if leases is not None: + for lease in leases: + lease["reserved"] = lease["mac"] in reserved_macs + # Für die Anzeige je Subnetz-Zeile (Erkannt/Nicht erkannt + Grund) — + # als einfaches Dict statt verschachteltem Jinja-selectattr, aus + # demselben Grund wie option_values.get(...) unten im Template. + subnet_status = {} + for vs in valid_subnets: + subnet_status[vs["id"]] = {"ok": True, "network": str(vs["net_info"]["network"])} + for s, error in invalid_subnets: + subnet_status[s["id"]] = {"ok": False, "error": error} return render_template( "settings_dhcp.html", cfg=cfg, status=status, - net_info=net_info, + subnets=subnets, + subnet_status=subnet_status, interfaces=_list_network_interfaces(), reservations=reservations, skipped_count=skipped_count, @@ -2194,6 +2400,7 @@ def settings_dhcp(): option_types=DHCP_OPTION_TYPES, dhcp_package=DHCP_PACKAGE, dhcp_service=DHCP_SERVICE, + leases=leases, )