DHCP: Backend auf Kea umgestellt, eigene Options (global + pro Client), echte Install-/Dienststeuerung

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 21:47:54 +02:00
co-authored by Claude Sonnet 5
parent 9eb9cfdda6
commit c3d69a9870
4 changed files with 557 additions and 103 deletions
+47 -24
View File
@@ -212,36 +212,59 @@ sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest),
`expect`-Aufrufen in `poe.sh` selbst (`ssh -p <port> ...`) für automatische `expect`-Aufrufen in `poe.sh` selbst (`ssh -p <port> ...`) für automatische
wie manuelle PoE-Neustarts. wie manuelle PoE-Neustarts.
## DHCP-Reservierungen (Systemeinstellungen → DHCP) ## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP)
Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch
feste DHCP-Reservierungen, ohne die Daten ein zweites Mal pflegen zu müssen. feste DHCP-Reservierungen inkl. eigener/herstellerspezifischer Options, ohne
Eigenes Rechtepaar `settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in die Daten ein zweites Mal pflegen zu müssen. Eigenes Rechtepaar
der Navbar unter Einstellungen. `settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in der Navbar unter
Einstellungen.
- **Status-Erkennung** (rein lesend): prüft per `shutil.which("dhcpd")` und **Backend: Kea DHCP** (`kea-dhcp4-server`), nicht das ältere
`systemctl is-active isc-dhcp-server`, ob ein ISC-DHCP-Server installiert `isc-dhcp-server` — ISC hat isc-dhcp-server 2022 als EOL erklärt, Kea ist
bzw. aktiv ist. Die App installiert und startet **nie selbst** einen der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
DHCP-Dienst — ist keiner gefunden, zeigt die Seite lediglich den passenden überschreibbar“ nativ ab (siehe unten).
manuellen Befehl (`sudo apt-get install isc-dhcp-server`) an. Grund: ein
unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes - **Status-Erkennung** (rein lesend): `shutil.which("kea-dhcp4")` +
Netzwerksegment stören, das soll ausschließlich eine bewusste, `systemctl is-active`/`is-enabled`. Zeigt Version, Lauf- und
eigenständige Admin-Entscheidung außerhalb der App bleiben. 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, - **Konfiguration**: Interface, Subnet, Netzmaske, Range, DNS-Server,
Domain, Lease-Zeiten sowie der Ausgabepfad der generierten Datei — als Domain, Lease-Zeiten sowie der Ausgabepfad der generierten Kea-Config
`dhcp_*`-Schlüssel in der bestehenden `settings`-Tabelle gespeichert. (Standard: die aktive `/etc/kea/kea-dhcp4.conf`) — als `dhcp_*`-Schlüssel
- **Reservierungen**: für jedes aktive Gerät mit gültiger MAC + IP wird ein in der bestehenden `settings`-Tabelle gespeichert.
`host { hardware ethernet ...; fixed-address ...; }`-Block erzeugt; der - **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 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 bekommen automatisch ein `-2`/`-3`-Suffix). Geräte ohne MAC/IP oder mit
Hinweis übersprungen statt eine kaputte Reservierung zu erzeugen. 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`) - **Schreiben**: „In Datei schreiben“ (nur mit `settings_dhcp.edit`)
schreibt die generierte Subnet-/Reservierungs-Deklaration in eine schreibt die vollständige, generierte Kea-JSON-Konfiguration an den
**separate Include-Datei** (Standard-Vorschlag konfigurierten Ausgabepfad. Wirksam wird sie erst nach einem
`/etc/dhcp/dhcpd.conf.d/aruba-poe-reservations.conf`) — **nicht** in die Dienst-Neustart über den separaten Button.
eigentliche `dhcpd.conf`. Einmalig manuell per `include "<Pfad>";`
einbinden. Die App reloadet/restartet den DHCP-Dienst dabei bewusst Live gegen eine echte, frisch installierte Kea-Instanz verifiziert:
**nicht automatisch** — dieselbe Begründung wie bei der Status-Erkennung. 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 ## Änderungslog
+312 -61
View File
@@ -17,7 +17,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from datetime import datetime 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 import paramiko
@@ -543,6 +543,28 @@ def _ensure_schema():
if "ssh_port" not in switch_cols: if "ssh_port" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER") 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 # Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten
# (ältere DB-Version) in eigene Credentials-Datensätze überführen. # (ältere DB-Version) in eigene Credentials-Datensätze überführen.
if "username" in switch_cols and "password" in switch_cols: 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 # Backend bewusst Kea (kea-dhcp4-server), nicht das ältere isc-dhcp-server:
# ist und generiert eine reine Reservierungs-Include-Datei aus den # Kea wird von ISC selbst aktiv weiterentwickelt (isc-dhcp-server ist seit
# Client-Daten — sie installiert NIE selbst ein Paket und startet/reloadet # 2022 EOL) und bildet "globaler Wert, pro Client überschreibbar" nativ ab
# NIE den DHCP-Dienst. Ein fehlkonfigurierter/unerwartet aktiver DHCP-Server # (Reservation-Options überschreiben in Kea automatisch gleichnamige
# kann ein ganzes Netz stören; Installation und Dienst-Reload bleiben daher # globale/Subnet-Options) — genau das von den Custom-Options gefordert.
# bewusst manuelle, außerhalb der App liegende Admin-Schritte (Anleitung #
# dazu wird auf der Seite angezeigt). # 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_DEFAULTS = {
"dhcp_interface": "eth0", "dhcp_interface": "eth0",
"dhcp_subnet": "192.168.1.0", "dhcp_subnet": "192.168.1.0",
@@ -1205,47 +1234,69 @@ DHCP_DEFAULTS = {
"dhcp_domain": "local", "dhcp_domain": "local",
"dhcp_lease_default": "600", "dhcp_lease_default": "600",
"dhcp_lease_max": "7200", "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(): def _load_dhcp_settings():
return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()} return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()}
def _detect_dhcp_status(): def _detect_dhcp_status():
"""Rein lesende Erkennung, ob ein ISC-DHCP-Server auf diesem Host """Rein lesende Erkennung, ob Kea auf diesem Host installiert ist und
installiert ist und läuft — führt selbst NIE eine Installation, einen läuft — löst selbst nichts aus. Installation/Start laufen ausschließlich
Start oder einen Reload aus.""" über die separaten, bestätigten Aktionen weiter unten."""
dhcpd_path = shutil.which("dhcpd") kea_path = shutil.which("kea-dhcp4")
installed = dhcpd_path is not None installed = kea_path is not None
active = None active = None
enabled = None
version = None version = None
if installed: if installed:
try: try:
version_out = subprocess.run( 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: except Exception:
version = None version = None
try: try:
status = subprocess.run( status = subprocess.run(
["systemctl", "is-active", "isc-dhcp-server"], ["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
capture_output=True, text=True, timeout=5,
) )
active = status.stdout.strip() == "active" active = status.stdout.strip() == "active"
except Exception: except Exception:
active = None 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): def _dhcp_safe_hostname(name, mac):
"""Wandelt einen Client-Namen in einen für DHCP-Host-Deklarationen und """Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
option host-name gültigen Bezeichner um (nur a-z0-9-, keine gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/
führenden/abschließenden/doppelten Bindestriche). Fällt bei leerem doppelten Bindestriche). Fällt bei leerem Ergebnis auf die letzten 4
Ergebnis auf die letzten 4 Byte der MAC zurück, damit nie ein leerer Byte der MAC zurück, damit nie ein leerer oder doppelter Bezeichner
oder doppelter Bezeichner entsteht.""" entsteht."""
slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-") slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug) slug = re.sub(r"-{2,}", "-", slug)
if slug: if slug:
@@ -1253,55 +1304,142 @@ def _dhcp_safe_hostname(name, mac):
return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:] 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 """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 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( 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" "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() ).fetchall()
seen_hostnames = set() seen_hostnames = set()
reservations = [] reservations = []
out_of_subnet = 0
for row in rows: 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"]) hostname = _dhcp_safe_hostname(row["name"], row["mac"])
# Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem # Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem
# Namen) auflösen, statt zwei Host-Blöcke mit demselben Bezeichner # Namen) auflösen, statt zwei Reservierungen mit demselben
# zu erzeugen (das würde dhcpd -t als Fehler ablehnen). # Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
base_hostname, suffix = hostname, 2 base_hostname, suffix = hostname, 2
while hostname in seen_hostnames: while hostname in seen_hostnames:
hostname = f"{base_hostname}-{suffix}" hostname = f"{base_hostname}-{suffix}"
suffix += 1 suffix += 1
seen_hostnames.add(hostname) seen_hostnames.add(hostname)
reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]}) 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): def _dhcp_option_defs(conn):
lines = [ return conn.execute("SELECT id, code, name, type, description FROM dhcp_option_defs ORDER BY code ASC").fetchall()
"# 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 —", def _dhcp_option_values(conn):
"# per \"include\" aus der eigentlichen dhcpd.conf einbinden.", """{option_def_id: {"": <globaler Wert>, "<mac>": <Override>, ...}}
"", — device_mac='' ist der globale Wert (siehe create_db.py)."""
f"subnet {cfg['dhcp_subnet']} netmask {cfg['dhcp_netmask']} {{", values = {}
f" option routers {cfg['dhcp_dns'].split(',')[0].strip() if cfg['dhcp_dns'] else cfg['dhcp_subnet']};", for row in conn.execute("SELECT option_def_id, device_mac, value FROM dhcp_option_values"):
f" option domain-name-servers {cfg['dhcp_dns']};", values.setdefault(row["option_def_id"], {})[row["device_mac"]] = row["value"]
f" option domain-name \"{cfg['dhcp_domain']}\";", return values
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_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: for r in reservations:
lines += [ res_option_data = []
f"host {r['hostname']} {{", for d in option_defs:
f" hardware ethernet {r['mac']};", override = option_values.get(d["id"], {}).get(r["mac"])
f" fixed-address {r['ip']};", if override:
f" option host-name \"{r['hostname']}\";", res_option_data.append({"name": d["name"], "data": override})
"}", entry = {
] "hw-address": r["mac"].lower(),
return "\n".join(lines) + "\n" "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"]) @app.route("/settings/dhcp", methods=["GET", "POST"])
@@ -1327,43 +1465,156 @@ def settings_dhcp():
log_action("settings.update", "DHCP-Konfiguration") log_action("settings.update", "DHCP-Konfiguration")
flash("DHCP-Konfiguration gespeichert.", "success") 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: elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings() cfg = _load_dhcp_settings()
reservations = _dhcp_reservation_candidates(conn) reservations, _ = _dhcp_reservation_candidates(conn, cfg)
content = _render_dhcp_config(cfg, reservations) 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"] output_path = cfg["dhcp_output_path"]
try: try:
os.makedirs(os.path.dirname(output_path), exist_ok=True) os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f: 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") log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen")
flash( flash(
f"{len(reservations)} Reservierungen nach {output_path} geschrieben. " f"{len(reservations)} Reservierungen nach {output_path} geschrieben. "
f"Der DHCP-Dienst übernimmt die Änderung erst nach einem manuellen " f"Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
f"„sudo systemctl reload isc-dhcp-server“ — das führt die App bewusst nicht automatisch aus.", f"das führt die App bewusst nicht automatisch mit aus.",
"success", "success",
) )
except OSError as e: except OSError as e:
flash(f"Konnte Datei nicht schreiben: {e}", "danger") 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() conn.close()
return redirect(url_for("settings_dhcp")) return redirect(url_for("settings_dhcp"))
cfg = _load_dhcp_settings() 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( skipped_count = conn.execute(
"SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND (mac IS NULL OR rpi_ip IS NULL)" "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND (mac IS NULL OR rpi_ip IS NULL)"
).fetchone()["n"] ).fetchone()["n"]
conn.close() conn.close()
status = _detect_dhcp_status() 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( return render_template(
"settings_dhcp.html", "settings_dhcp.html",
cfg=cfg, cfg=cfg,
status=status, status=status,
reservations=reservations, reservations=reservations,
skipped_count=skipped_count, skipped_count=skipped_count,
out_of_subnet_count=out_of_subnet_count,
preview=preview, preview=preview,
option_defs=option_defs,
option_values=option_values,
option_types=DHCP_OPTION_TYPES,
dhcp_package=DHCP_PACKAGE,
dhcp_service=DHCP_SERVICE,
) )
+29
View File
@@ -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 # Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE
# PoE-Neustarts (die stehen im Live-Log von poe.sh). # PoE-Neustarts (die stehen im Live-Log von poe.sh).
c.execute(""" c.execute("""
+169 -18
View File
@@ -2,7 +2,7 @@
{% set active_page = "settings_dhcp" %} {% set active_page = "settings_dhcp" %}
{% set can_edit = current_user.has_permission('settings_dhcp.edit') %} {% set can_edit = current_user.has_permission('settings_dhcp.edit') %}
{% block page_title %}DHCP{% endblock %} {% block page_title %}DHCP{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reservierungen aus den Client-Stammdaten generieren</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
{% block content %} {% block content %}
<div class="settings-grid"> <div class="settings-grid">
@@ -11,11 +11,11 @@
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">DHCP-Server-Status</h2> <h2 style="font-size:16px;">DHCP-Server-Status</h2>
<div class="hint">Rein lesende Erkennung — die App installiert oder startet nie selbst einen DHCP-Dienst.</div> <div class="hint">Rein lesende Erkennung — Installation und Dienststeuerung laufen ausschließlich über die Buttons unten, nie automatisch.</div>
</div> </div>
</div> </div>
{% if status.installed %} {% if status.installed %}
<div class="flex gap-2" style="align-items:center; margin-bottom:10px;"> <div class="flex gap-2" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
<span class="pill online">Installiert</span> <span class="pill online">Installiert</span>
{% if status.active is sameas true %} {% if status.active is sameas true %}
<span class="pill online">Dienst aktiv</span> <span class="pill online">Dienst aktiv</span>
@@ -24,19 +24,34 @@
{% else %} {% else %}
<span class="pill unknown">Dienst-Status unbekannt</span> <span class="pill unknown">Dienst-Status unbekannt</span>
{% endif %} {% endif %}
{% if status.enabled is sameas true %}<span class="pill user">Autostart an</span>{% endif %}
</div>
{% if status.version %}<div class="text-faint mono" style="font-size:12px; margin-bottom:12px;">{{ status.version }}</div>{% endif %}
{% if can_edit %}
<div class="flex gap-2" style="flex-wrap:wrap;">
<form method="post" data-confirm="{{ dhcp_service }} aktivieren und (neu) starten? Übernimmt die zuletzt geschriebene Konfiguration.">
<input type="hidden" name="dhcp_service_action" value="enable_restart">
<button type="submit" class="btn btn-secondary btn-sm">Aktivieren &amp; (neu) starten</button>
</form>
{% if status.active is sameas true %}
<form method="post" data-confirm="{{ dhcp_service }} stoppen? Der DHCP-Dienst vergibt danach keine Adressen mehr.">
<input type="hidden" name="dhcp_service_action" value="stop">
<button type="submit" class="btn btn-secondary btn-sm" style="color:var(--danger);">Stoppen</button>
</form>
{% endif %}
</div> </div>
{% if status.version %}<div class="text-faint mono" style="font-size:12px;">{{ status.version }}</div>{% endif %}
{% if status.active is not sameas true %}
<p class="text-faint" style="font-size:11.5px; margin-top:10px;">
Dienst manuell starten: <code>sudo systemctl enable --now isc-dhcp-server</code>
</p>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="pill disabled">Nicht installiert</span> <span class="pill disabled">Nicht installiert</span>
<p class="text-faint" style="font-size:11.5px; margin-top:10px;"> <p class="text-faint" style="font-size:11.5px; margin:10px 0;">
Kein <code>dhcpd</code> auf diesem Host gefunden. Installation bewusst manuell, außerhalb der App:<br> Kein <code>kea-dhcp4</code> auf diesem Host gefunden.
<code>sudo apt-get install isc-dhcp-server</code>
</p> </p>
{% if can_edit %}
<form method="post" data-confirm="{{ dhcp_package }} jetzt installieren (apt-get)? Startet den Dienst noch nicht.">
<input type="hidden" name="install_dhcp_package" value="1">
<button type="submit" class="btn btn-primary btn-sm">{{ dhcp_package }} installieren</button>
</form>
{% endif %}
{% endif %} {% endif %}
</div> </div>
@@ -67,7 +82,7 @@
</div> </div>
<div class="field"><label>DNS-Server</label> <div class="field"><label>DNS-Server</label>
<input type="text" name="dhcp_dns" value="{{ cfg.dhcp_dns }}"> <input type="text" name="dhcp_dns" value="{{ cfg.dhcp_dns }}">
<div class="field-hint">Kommagetrennt, falls mehrere.</div> <div class="field-hint">Kommagetrennt, falls mehrere. Der erste Eintrag wird auch als Router/Gateway verwendet.</div>
</div> </div>
<div class="field"><label>Domain</label> <div class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}"> <input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
@@ -78,9 +93,9 @@
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label> <div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}"> <input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
</div> </div>
<div class="field"><label>Ausgabepfad (Reservierungsdatei)</label> <div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono"> <input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Separate Include-Datei — nicht die aktive dhcpd.conf. Per <code>include "{{ cfg.dhcp_output_path }}";</code> einmalig manuell einbinden.</div> <div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Nach dem Schreiben den Dienst per Button neu starten, damit die Änderung wirksam wird.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
@@ -92,17 +107,77 @@
{% endif %} {% endif %}
</div> </div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Eigene DHCP-Options</h2>
<div class="hint">Zusätzliche/herstellerspezifische Options (z.B. Terminal-URL) — global setzbar, pro Client über „Optionen“ bei den Reservierungen unten überschreibbar.</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Option
</button>
{% endif %}
</div>
{% if option_defs %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for d in option_defs %}
<tr>
<td class="mono">{{ d.code }}</td>
<td class="mono">{{ d.name }}</td>
<td>{{ d.type }}</td>
<td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td>
<td>
{% if can_edit %}
<form method="post" class="flex gap-2">
<input type="hidden" name="save_dhcp_option_global" value="1">
<input type="hidden" name="option_def_id" value="{{ d.id }}">
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
</form>
{% else %}
{{ option_values.get(d.id, {}).get('', '—') }}
{% endif %}
</td>
<td>
{% if can_edit %}
<form method="post" data-confirm="Option „{{ d.name }}“ inkl. aller Werte/Overrides löschen?">
<input type="hidden" name="delete_dhcp_option" value="{{ d.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine eigenen Options definiert.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Reservierungen aus den Clients</h2> <h2 style="font-size:16px;">Reservierungen aus den Clients</h2>
<div class="hint"> <div class="hint">
{{ 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 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 %}
</div> </div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<form method="post" data-confirm="Reservierungsdatei nach „{{ cfg.dhcp_output_path }}“ schreiben? Der DHCP-Dienst übernimmt die Änderung erst nach einem manuellen Reload."> <form method="post" data-confirm="Konfiguration nach „{{ cfg.dhcp_output_path }}“ schreiben? Der Dienst übernimmt die Änderung erst nach einem Neustart.">
<input type="hidden" name="write_dhcp_file" value="1"> <input type="hidden" name="write_dhcp_file" value="1">
<button type="submit" class="btn btn-secondary"> <button type="submit" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
@@ -116,7 +191,7 @@
<div class="table-wrap" style="margin-bottom:18px;"> <div class="table-wrap" style="margin-bottom:18px;">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table">
<thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Client-Name</th></tr></thead> <thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Client-Name</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody> <tbody>
{% for r in reservations %} {% for r in reservations %}
<tr> <tr>
@@ -124,7 +199,42 @@
<td class="mono">{{ r.mac }}</td> <td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</td> <td class="mono">{{ r.ip }}</td>
<td>{{ r.name }}</td> <td>{{ r.name }}</td>
<td>
{% if option_defs and can_edit %}
<button class="icon-btn" title="DHCP-Optionen für diesen Client" data-open-modal="deviceOptionsModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/></svg>
</button>
{% endif %}
</td>
</tr> </tr>
{% if option_defs and can_edit %}
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
<div class="modal">
<form method="post">
<input type="hidden" name="save_dhcp_device_options" value="1">
<input type="hidden" name="mac" value="{{ r.mac }}">
<div class="modal-header">
<h3>DHCP-Optionen — {{ r.name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">Leer lassen, um den globalen Wert zu übernehmen.</p>
{% for d in option_defs %}
<div class="field">
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
<input type="text" name="opt_{{ d.id }}" value="{{ option_values.get(d.id, {}).get(r.mac, '') }}"
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
</div>
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -134,9 +244,50 @@
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p> <p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
{% endif %} {% endif %}
<div class="permission-group-title">Generierte Konfiguration (Vorschau)</div> <div class="permission-group-title">Generierte Konfiguration (Vorschau, Kea-JSON)</div>
<pre class="code-preview">{{ preview }}</pre> <pre class="code-preview">{{ preview }}</pre>
</div> </div>
</div> </div>
{% if can_edit %}
<div class="modal-overlay" id="addOptionModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_dhcp_option" value="1">
<div class="modal-header">
<h3>Neue DHCP-Option</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Code</label>
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
</div>
<div class="field"><label>Name</label>
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
</div>
<div class="field"><label>Datentyp</label>
<select name="type">
{% for key, label in option_types %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Beschreibung</label>
<input type="text" name="description" placeholder="z.B. Terminal-Boot-URL für Thin Clients">
</div>
<div class="field"><label>Globaler Wert (optional)</label>
<input type="text" name="initial_value" placeholder="z.B. http://bde/webmmi?M=SGM11">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}