From c3d69a98704ab80458abc1aca13b4cc5bc94b48f Mon Sep 17 00:00:00 2001 From: alientim Date: Mon, 10 Aug 2026 21:47:54 +0200 Subject: [PATCH] DHCP: Backend auf Kea umgestellt, eigene Options (global + pro Client), echte Install-/Dienststeuerung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend-Wechsel von isc-dhcp-server (seit 2022 EOL) auf Kea DHCP (kea-dhcp4-server) — aktiv weiterentwickelt, bildet "globaler Wert, pro Client überschreibbar" nativ über Kea-Reservation-Option-Data ab. - Neue Tabellen dhcp_option_defs/dhcp_option_values: eigene/herstellerspezifische Options mit Code/Name/Typ/Beschreibung, Wert global sowie optional pro Client (device_mac="" für global, siehe Kommentar zur SQLite-NULL- UNIQUE-Falle). UI dafür: Optionen-Tabelle + "Neue Option"-Modal + Pro-Client-Overrides-Modal je Reservierung. - Kea-JSON-Generator (_render_kea_config): option-def für jede eigene Option, globale Werte im Top-Level option-data, Client-Overrides im option-data der jeweiligen Reservierung. - Reservierungen werden jetzt zusätzlich auf Zugehörigkeit zum konfigurierten Subnet gefiltert (_dhcp_reservation_candidates) — beim Live-Test gegen echtes Kea gefunden: Kea lehnt Reservierungen außerhalb ihres Subnets als Konfigurationsfehler ab, das muss also schon bei der Generierung berücksichtigt werden statt erst beim Laden zu crashen. - Neue, einzeln bestätigte Aktionen: "kea-dhcp4-server installieren" (apt-get), "Aktivieren & (neu) starten" sowie "Stoppen" (systemctl) — bewusst getrennt von "Konfiguration speichern"/"In Datei schreiben". - Live gegen eine echte, frisch installierte Kea-3.0.3-Instanz verifiziert: Installation erfolgreich, generierte Config besteht "kea-dhcp4 -t", Dienst übernimmt sie beim Neustart fehlerfrei (DHCP4_CONFIG_COMPLETE), globaler Options-Wert und Client-Override erscheinen korrekt getrennt. Danach wieder gestoppt; Paket bleibt installiert. Co-Authored-By: Claude Sonnet 5 --- README.md | 71 ++-- srv/poe_manager/app.py | 373 ++++++++++++++++--- srv/poe_manager/create_db.py | 29 ++ srv/poe_manager/templates/settings_dhcp.html | 187 +++++++++- 4 files changed, 557 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 770ab58..987db89 100644 --- a/README.md +++ b/README.md @@ -212,36 +212,59 @@ sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest), `expect`-Aufrufen in `poe.sh` selbst (`ssh -p ...`) für automatische wie manuelle PoE-Neustarts. -## DHCP-Reservierungen (Systemeinstellungen → DHCP) +## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP) Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch -feste DHCP-Reservierungen, ohne die Daten ein zweites Mal pflegen zu müssen. -Eigenes Rechtepaar `settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in -der Navbar unter Einstellungen. +feste DHCP-Reservierungen inkl. eigener/herstellerspezifischer Options, ohne +die Daten ein zweites Mal pflegen zu müssen. Eigenes Rechtepaar +`settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in der Navbar unter +Einstellungen. -- **Status-Erkennung** (rein lesend): prüft per `shutil.which("dhcpd")` und - `systemctl is-active isc-dhcp-server`, ob ein ISC-DHCP-Server installiert - bzw. aktiv ist. Die App installiert und startet **nie selbst** einen - DHCP-Dienst — ist keiner gefunden, zeigt die Seite lediglich den passenden - manuellen Befehl (`sudo apt-get install isc-dhcp-server`) an. Grund: ein - unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes - Netzwerksegment stören, das soll ausschließlich eine bewusste, - eigenständige Admin-Entscheidung außerhalb der App bleiben. +**Backend: Kea DHCP** (`kea-dhcp4-server`), nicht das ältere +`isc-dhcp-server` — ISC hat isc-dhcp-server 2022 als EOL erklärt, Kea ist +der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client +überschreibbar“ nativ ab (siehe unten). + +- **Status-Erkennung** (rein lesend): `shutil.which("kea-dhcp4")` + + `systemctl is-active`/`is-enabled`. Zeigt Version, Lauf- und + Autostart-Status. +- **Installation & Dienststeuerung**: eigene, jeweils einzeln bestätigte + Buttons — „kea-dhcp4-server installieren“ (`apt-get install -y`), + „Aktivieren & (neu) starten“ (`systemctl enable` + `restart`) und + „Stoppen“. Bewusst **nicht** automatisch an „Konfiguration speichern“ + 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 Datei — als - `dhcp_*`-Schlüssel in der bestehenden `settings`-Tabelle gespeichert. -- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird ein - `host { hardware ethernet ...; fixed-address ...; }`-Block erzeugt; der + 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. +- **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 + Options wie eine Terminal-Boot-URL). Jede Option hat einen **globalen** + Wert sowie optional einen **Wert pro Client** — ein Client-Override + überschreibt den globalen Wert ausschließlich für dieses eine Gerät + (technisch: globale Werte landen im Top-Level `option-data`, Overrides im + `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 Hostname wird aus dem Gerätenamen abgeleitet (nur `a-z0-9-`, Kollisionen - bekommen automatisch ein `-2`/`-3`-Suffix). Geräte ohne MAC/IP werden mit - Hinweis übersprungen statt eine kaputte Reservierung zu erzeugen. + 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 + eine Reservierung außerhalb ihres Subnets als Konfigurationsfehler ab + (mit einer echten Kea-Instanz verifiziert). - **Schreiben**: „In Datei schreiben“ (nur mit `settings_dhcp.edit`) - schreibt die generierte Subnet-/Reservierungs-Deklaration in eine - **separate Include-Datei** (Standard-Vorschlag - `/etc/dhcp/dhcpd.conf.d/aruba-poe-reservations.conf`) — **nicht** in die - eigentliche `dhcpd.conf`. Einmalig manuell per `include "";` - einbinden. Die App reloadet/restartet den DHCP-Dienst dabei bewusst - **nicht automatisch** — dieselbe Begründung wie bei der Status-Erkennung. + schreibt die vollständige, generierte Kea-JSON-Konfiguration an den + konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem + Dienst-Neustart über den separaten Button. + +Live gegen eine echte, frisch installierte Kea-Instanz verifiziert: +Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart +übernimmt sie fehlerfrei (`DHCP4_CONFIG_COMPLETE`), globaler Options-Wert +sowie Client-spezifischer Override erscheinen korrekt getrennt in der +Konfiguration. ## Änderungslog diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index 5114f2f..94d8f3e 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -17,7 +17,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, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback +import base64, ipaddress, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko @@ -543,6 +543,28 @@ def _ensure_schema(): if "ssh_port" not in switch_cols: conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER") + # DHCP: eigene Options-Definitionen + Werte (global/per-Client, + # siehe create_db.py für die ausführliche Begründung von device_mac=''). + conn.execute(""" + CREATE TABLE IF NOT EXISTS dhcp_option_defs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + description TEXT + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS dhcp_option_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + option_def_id INTEGER NOT NULL, + device_mac TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL, + FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id), + UNIQUE (option_def_id, device_mac) + ) + """) + # 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: @@ -1184,17 +1206,24 @@ def settings_import_export(): # --------------------------------------------------------------------------- -# DHCP-Reservierungen +# DHCP-Reservierungen (Kea DHCP) # -# Bewusste Beschränkung: die App erkennt nur, ob ein DHCP-Server installiert -# ist und generiert eine reine Reservierungs-Include-Datei aus den -# Client-Daten — sie installiert NIE selbst ein Paket und startet/reloadet -# NIE den DHCP-Dienst. Ein fehlkonfigurierter/unerwartet aktiver DHCP-Server -# kann ein ganzes Netz stören; Installation und Dienst-Reload bleiben daher -# bewusst manuelle, außerhalb der App liegende Admin-Schritte (Anleitung -# dazu wird auf der Seite angezeigt). +# Backend bewusst Kea (kea-dhcp4-server), nicht das ältere isc-dhcp-server: +# Kea wird von ISC selbst aktiv weiterentwickelt (isc-dhcp-server ist seit +# 2022 EOL) und bildet "globaler Wert, pro Client überschreibbar" nativ ab +# (Reservation-Options überschreiben in Kea automatisch gleichnamige +# globale/Subnet-Options) — genau das von den Custom-Options gefordert. +# +# Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen +# (apt-get install / systemctl enable+restart) statt automatisch an +# "Konfiguration speichern" gekoppelt — ein unerwartet aktiver oder +# fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, das +# soll immer ein bewusster, separater Klick bleiben. # --------------------------------------------------------------------------- +DHCP_PACKAGE = "kea-dhcp4-server" +DHCP_SERVICE = "kea-dhcp4-server" + DHCP_DEFAULTS = { "dhcp_interface": "eth0", "dhcp_subnet": "192.168.1.0", @@ -1205,47 +1234,69 @@ DHCP_DEFAULTS = { "dhcp_domain": "local", "dhcp_lease_default": "600", "dhcp_lease_max": "7200", - "dhcp_output_path": "/etc/dhcp/dhcpd.conf.d/aruba-poe-reservations.conf", + "dhcp_output_path": "/etc/kea/kea-dhcp4.conf", } +# Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible +# Teilmenge statt der vollen Liste, orientiert an den in der Praxis +# gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex). +DHCP_OPTION_TYPES = [ + ("string", "Text (string)"), + ("ipv4-address", "IP-Adresse (ipv4-address)"), + ("uint8", "Zahl, 1 Byte (uint8)"), + ("uint16", "Zahl, 2 Byte (uint16)"), + ("uint32", "Zahl, 4 Byte (uint32)"), + ("boolean", "Wahrheitswert (boolean)"), + ("binary", "Hexadezimal/Rohdaten (binary)"), +] +DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES} + def _load_dhcp_settings(): return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} def _detect_dhcp_status(): - """Rein lesende Erkennung, ob ein ISC-DHCP-Server auf diesem Host - installiert ist und läuft — führt selbst NIE eine Installation, einen - Start oder einen Reload aus.""" - dhcpd_path = shutil.which("dhcpd") - installed = dhcpd_path is not None + """Rein lesende Erkennung, ob Kea auf diesem Host installiert ist und + läuft — löst selbst nichts aus. Installation/Start laufen ausschließlich + über die separaten, bestätigten Aktionen weiter unten.""" + kea_path = shutil.which("kea-dhcp4") + installed = kea_path is not None active = None + enabled = None version = None if installed: try: version_out = subprocess.run( - ["dhcpd", "--version"], capture_output=True, text=True, timeout=5 + ["kea-dhcp4", "-v"], capture_output=True, text=True, timeout=5 ) - version = (version_out.stdout or version_out.stderr or "").strip().splitlines()[0] if (version_out.stdout or version_out.stderr) else None + out = (version_out.stdout or version_out.stderr or "").strip() + version = out.splitlines()[0] if out else None except Exception: version = None try: status = subprocess.run( - ["systemctl", "is-active", "isc-dhcp-server"], - capture_output=True, text=True, timeout=5, + ["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5, ) active = status.stdout.strip() == "active" except Exception: active = None - return {"installed": installed, "path": dhcpd_path, "active": active, "version": version} + try: + enabled_out = subprocess.run( + ["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5, + ) + enabled = enabled_out.stdout.strip() == "enabled" + except Exception: + enabled = None + return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version} def _dhcp_safe_hostname(name, mac): - """Wandelt einen Client-Namen in einen für DHCP-Host-Deklarationen und - option host-name gültigen Bezeichner um (nur a-z0-9-, keine - führenden/abschließenden/doppelten Bindestriche). Fällt bei leerem - Ergebnis auf die letzten 4 Byte der MAC zurück, damit nie ein leerer - oder doppelter Bezeichner entsteht.""" + """Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname + gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/ + doppelten Bindestriche). Fällt bei leerem Ergebnis auf die letzten 4 + Byte der MAC zurück, damit nie ein leerer oder doppelter Bezeichner + entsteht.""" slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-") slug = re.sub(r"-{2,}", "-", slug) if slug: @@ -1253,55 +1304,142 @@ def _dhcp_safe_hostname(name, mac): return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:] -def _dhcp_reservation_candidates(conn): +def _dhcp_reservation_candidates(conn, cfg): """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.""" + ü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 + 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 = [] + out_of_subnet = 0 for row in rows: + try: + in_subnet = network is not None and ipaddress.IPv4Address(row["rpi_ip"]) in network + except ValueError: + in_subnet = False + if not in_subnet: + 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 Host-Blöcke mit demselben Bezeichner - # zu erzeugen (das würde dhcpd -t als Fehler ablehnen). + # Namen) auflösen, statt zwei Reservierungen mit demselben + # Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen). base_hostname, suffix = hostname, 2 while hostname in seen_hostnames: 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 + return reservations, out_of_subnet -def _render_dhcp_config(cfg, reservations): - lines = [ - "# Automatisch generiert vom PoE Manager — Änderungen werden beim", - "# nächsten Schreiben aus den Geräte-Stammdaten überschrieben.", - "# Diese Datei enthält NUR die Subnet-/Reservierungs-Deklaration —", - "# per \"include\" aus der eigentlichen dhcpd.conf einbinden.", - "", - f"subnet {cfg['dhcp_subnet']} netmask {cfg['dhcp_netmask']} {{", - f" option routers {cfg['dhcp_dns'].split(',')[0].strip() if cfg['dhcp_dns'] else cfg['dhcp_subnet']};", - f" option domain-name-servers {cfg['dhcp_dns']};", - f" option domain-name \"{cfg['dhcp_domain']}\";", - f" range {cfg['dhcp_range_start']} {cfg['dhcp_range_end']};", - f" default-lease-time {cfg['dhcp_lease_default']};", - f" max-lease-time {cfg['dhcp_lease_max']};", - "}", - "", +def _dhcp_option_defs(conn): + return conn.execute("SELECT id, code, name, type, description FROM dhcp_option_defs ORDER BY code ASC").fetchall() + + +def _dhcp_option_values(conn): + """{option_def_id: {"": , "": , ...}} + — device_mac='' ist der globale Wert (siehe create_db.py).""" + values = {} + for row in conn.execute("SELECT option_def_id, device_mac, value FROM dhcp_option_values"): + values.setdefault(row["option_def_id"], {})[row["device_mac"]] = row["value"] + 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): + """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"]) + 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 "" + + 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: + global_value = option_values.get(d["id"], {}).get("") + if global_value: + global_option_data.append({"name": d["name"], "data": global_value}) + + kea_reservations = [] for r in reservations: - lines += [ - f"host {r['hostname']} {{", - f" hardware ethernet {r['mac']};", - f" fixed-address {r['ip']};", - f" option host-name \"{r['hostname']}\";", - "}", - ] - return "\n".join(lines) + "\n" + 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) + + return { + "Dhcp4": { + "interfaces-config": {"interfaces": [cfg["dhcp_interface"]]}, + "lease-database": {"type": "memfile", "lfc-interval": 3600}, + "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"{cfg['dhcp_subnet']}/{prefix}", + "pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}], + "reservations": kea_reservations, + } + ], + } + } + + +def _dhcp_run_privileged(args, timeout): + """Führt einen Installations-/Dienst-Befehl aus und fasst das Ergebnis + einheitlich zusammen — wirft nie, damit der aufrufende Code immer eine + Flash-Meldung statt eines 500ers bekommt.""" + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + return result.returncode == 0, (result.stdout or "") + (result.stderr or "") + except Exception as e: + return False, str(e) @app.route("/settings/dhcp", methods=["GET", "POST"]) @@ -1327,43 +1465,156 @@ def settings_dhcp(): 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() + name = request.form.get("name", "").strip() + opt_type = request.form.get("type", "").strip() + description = request.form.get("description", "").strip() + initial_value = request.form.get("initial_value", "").strip() + if not code_raw.isdigit() or not (1 <= int(code_raw) <= 254): + flash("Code muss eine Zahl zwischen 1 und 254 sein.", "danger") + elif not re.match(r"^[a-z][a-z0-9-]*$", name): + flash("Name muss mit einem Kleinbuchstaben beginnen und darf nur a-z, 0-9 und - enthalten.", "danger") + elif opt_type not in DHCP_OPTION_TYPE_KEYS: + flash("Ungültiger Datentyp.", "danger") + else: + try: + cur = conn.execute( + "INSERT INTO dhcp_option_defs (code, name, type, description) VALUES (?, ?, ?, ?)", + (int(code_raw), name, opt_type, description or None), + ) + if initial_value: + conn.execute( + "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?)", + (cur.lastrowid, initial_value), + ) + conn.commit() + log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}") + flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success") + except sqlite3.IntegrityError: + flash("Code oder Name wird bereits verwendet.", "danger") + + elif "delete_dhcp_option" in request.form: + option_def_id = request.form.get("delete_dhcp_option") + option_def = conn.execute("SELECT name FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone() + conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,)) + conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,)) + conn.commit() + log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id) + flash("Option gelöscht.", "success") + + elif "save_dhcp_option_global" in request.form: + option_def_id = request.form.get("option_def_id") + value = request.form.get("value", "").strip() + if value: + conn.execute( + "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?) " + "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value", + (option_def_id, value), + ) + else: + conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=''", (option_def_id,)) + conn.commit() + log_action("settings.dhcp_option_value", option_def_id, "global") + flash("Globaler Wert gespeichert.", "success") + + elif "save_dhcp_device_options" in request.form: + mac = request.form.get("mac", "").strip() + for d in _dhcp_option_defs(conn): + value = request.form.get(f"opt_{d['id']}", "").strip() + if value: + conn.execute( + "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) " + "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value", + (d["id"], mac, value), + ) + else: + conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=?", (d["id"], mac)) + conn.commit() + log_action("settings.dhcp_option_value", mac, "Client-Override") + flash(f"DHCP-Optionen für {mac} gespeichert.", "success") + elif "write_dhcp_file" in request.form: cfg = _load_dhcp_settings() - reservations = _dhcp_reservation_candidates(conn) - content = _render_dhcp_config(cfg, reservations) + reservations, _ = _dhcp_reservation_candidates(conn, cfg) + 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) 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) + f.write(content + "\n") log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen") flash( f"{len(reservations)} Reservierungen nach {output_path} geschrieben. " - f"Der DHCP-Dienst übernimmt die Änderung erst nach einem manuellen " - f"„sudo systemctl reload isc-dhcp-server“ — das führt die App bewusst nicht automatisch aus.", + f"Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — " + f"das führt die App bewusst nicht automatisch mit aus.", "success", ) except OSError as e: flash(f"Konnte Datei nicht schreiben: {e}", "danger") + elif "install_dhcp_package" in request.form: + ok_update, _ = _dhcp_run_privileged(["apt-get", "update"], timeout=60) + env_prefix = {"DEBIAN_FRONTEND": "noninteractive"} + try: + result = subprocess.run( + ["apt-get", "install", "-y", DHCP_PACKAGE], + capture_output=True, text=True, timeout=180, + env={**os.environ, **env_prefix}, + ) + ok = result.returncode == 0 + output_tail = "\n".join(((result.stdout or "") + (result.stderr or "")).strip().splitlines()[-10:]) + except Exception as e: + ok, output_tail = False, str(e) + log_action("settings.dhcp_install", DHCP_PACKAGE, "erfolgreich" if ok else "fehlgeschlagen") + if ok: + flash(f"{DHCP_PACKAGE} installiert.", "success") + else: + flash(f"Installation fehlgeschlagen: {output_tail or 'unbekannter Fehler'}", "danger") + + elif "dhcp_service_action" in request.form: + action = request.form.get("dhcp_service_action") + if 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) + log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen")) + if ok_enable and ok_restart: + flash(f"{DHCP_SERVICE} aktiviert und neu gestartet.", "success") + else: + 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) + 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") + conn.close() return redirect(url_for("settings_dhcp")) cfg = _load_dhcp_settings() - reservations = _dhcp_reservation_candidates(conn) + reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, cfg) + option_defs = _dhcp_option_defs(conn) + option_values = _dhcp_option_values(conn) skipped_count = conn.execute( "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND (mac IS NULL OR rpi_ip IS NULL)" ).fetchone()["n"] conn.close() status = _detect_dhcp_status() - preview = _render_dhcp_config(cfg, reservations) + preview = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False) return render_template( "settings_dhcp.html", cfg=cfg, status=status, reservations=reservations, skipped_count=skipped_count, + out_of_subnet_count=out_of_subnet_count, preview=preview, + option_defs=option_defs, + option_values=option_values, + option_types=DHCP_OPTION_TYPES, + dhcp_package=DHCP_PACKAGE, + dhcp_service=DHCP_SERVICE, ) diff --git a/srv/poe_manager/create_db.py b/srv/poe_manager/create_db.py index 883f140..061bbc5 100644 --- a/srv/poe_manager/create_db.py +++ b/srv/poe_manager/create_db.py @@ -104,6 +104,35 @@ CREATE TABLE IF NOT EXISTS settings ( ); """) +# DHCP: eigene Options-Definitionen (analog "Vordefinierte Optionen und +# Werte" bei einem Windows-DHCP-Server) — Code/Name/Typ/Beschreibung. +c.execute(""" +CREATE TABLE IF NOT EXISTS dhcp_option_defs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code INTEGER NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + description TEXT +); +""") + +# DHCP: Werte je Option — device_mac='' bedeutet global (gilt für den +# ganzen Scope), ein gesetzter device_mac überschreibt den globalen Wert +# für genau dieses Gerät. Bewusst '' statt NULL für den globalen Fall, +# da SQLite NULL in UNIQUE-Constraints nicht als gleich behandelt (zwei +# NULL-Zeilen wären sonst kein Konflikt) — mit '' funktioniert ON CONFLICT +# zuverlässig für den Upsert des globalen Werts. +c.execute(""" +CREATE TABLE IF NOT EXISTS dhcp_option_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + option_def_id INTEGER NOT NULL, + device_mac TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL, + FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id), + UNIQUE (option_def_id, device_mac) +); +""") + # Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE # PoE-Neustarts (die stehen im Live-Log von poe.sh). c.execute(""" diff --git a/srv/poe_manager/templates/settings_dhcp.html b/srv/poe_manager/templates/settings_dhcp.html index baa3e93..cbfdbcb 100644 --- a/srv/poe_manager/templates/settings_dhcp.html +++ b/srv/poe_manager/templates/settings_dhcp.html @@ -2,7 +2,7 @@ {% set active_page = "settings_dhcp" %} {% set can_edit = current_user.has_permission('settings_dhcp.edit') %} {% block page_title %}DHCP{% endblock %} -{% block page_sub %}
Reservierungen aus den Client-Stammdaten generieren
{% endblock %} +{% block page_sub %}
Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)
{% endblock %} {% block content %}
@@ -11,11 +11,11 @@

DHCP-Server-Status

-
Rein lesende Erkennung — die App installiert oder startet nie selbst einen DHCP-Dienst.
+
Rein lesende Erkennung — Installation und Dienststeuerung laufen ausschließlich über die Buttons unten, nie automatisch.
{% if status.installed %} -
+
Installiert {% if status.active is sameas true %} Dienst aktiv @@ -24,19 +24,34 @@ {% else %} Dienst-Status unbekannt {% endif %} + {% if status.enabled is sameas true %}Autostart an{% endif %} +
+ {% if status.version %}
{{ status.version }}
{% endif %} + {% if can_edit %} +
+
+ + +
+ {% if status.active is sameas true %} +
+ + +
+ {% endif %}
- {% if status.version %}
{{ status.version }}
{% endif %} - {% if status.active is not sameas true %} -

- Dienst manuell starten: sudo systemctl enable --now isc-dhcp-server -

{% endif %} {% else %} Nicht installiert -

- Kein dhcpd auf diesem Host gefunden. Installation bewusst manuell, außerhalb der App:
- sudo apt-get install isc-dhcp-server +

+ Kein kea-dhcp4 auf diesem Host gefunden.

+ {% if can_edit %} +
+ + +
+ {% endif %} {% endif %}
@@ -67,7 +82,7 @@
-
Kommagetrennt, falls mehrere.
+
Kommagetrennt, falls mehrere. Der erste Eintrag wird auch als Router/Gateway verwendet.
@@ -78,9 +93,9 @@
-
+
-
Separate Include-Datei — nicht die aktive dhcpd.conf. Per include "{{ cfg.dhcp_output_path }}"; einmalig manuell einbinden.
+
Standardmäßig die aktive Kea-Konfiguration. Nach dem Schreiben den Dienst per Button neu starten, damit die Änderung wirksam wird.
+
+
+
+

Eigene DHCP-Options

+
Zusätzliche/herstellerspezifische Options (z.B. Terminal-URL) — global setzbar, pro Client über „Optionen“ bei den Reservierungen unten überschreibbar.
+
+ {% if can_edit %} + + {% endif %} +
+ + {% if option_defs %} +
+
+ + + + {% for d in option_defs %} + + + + + + + + + {% endfor %} + +
CodeNameTypBeschreibungGlobaler WertAktionen
{{ d.code }}{{ d.name }}{{ d.type }}{{ d.description or '—' }} + {% if can_edit %} +
+ + + + +
+ {% else %} + {{ option_values.get(d.id, {}).get('', '—') }} + {% endif %} +
+ {% if can_edit %} +
+ + +
+ {% endif %} +
+
+
+ {% else %} +

Noch keine eigenen Options definiert.

+ {% endif %} +
+

Reservierungen aus den Clients

- {{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP. + {{ 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 can_edit %} -
+ +
+ + + +
+
+{% endif %} + {% endblock %}