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:
@@ -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
|
||||
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 "<Pfad>";`
|
||||
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
|
||||
|
||||
|
||||
+312
-61
@@ -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: {"": <globaler Wert>, "<mac>": <Override>, ...}}
|
||||
— 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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 %}<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 %}
|
||||
<div class="settings-grid">
|
||||
@@ -11,11 +11,11 @@
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<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>
|
||||
{% 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>
|
||||
{% if status.active is sameas true %}
|
||||
<span class="pill online">Dienst aktiv</span>
|
||||
@@ -24,19 +24,34 @@
|
||||
{% else %}
|
||||
<span class="pill unknown">Dienst-Status unbekannt</span>
|
||||
{% 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 & (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>
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
<span class="pill disabled">Nicht installiert</span>
|
||||
<p class="text-faint" style="font-size:11.5px; margin-top:10px;">
|
||||
Kein <code>dhcpd</code> auf diesem Host gefunden. Installation bewusst manuell, außerhalb der App:<br>
|
||||
<code>sudo apt-get install isc-dhcp-server</code>
|
||||
<p class="text-faint" style="font-size:11.5px; margin:10px 0;">
|
||||
Kein <code>kea-dhcp4</code> auf diesem Host gefunden.
|
||||
</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 %}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +82,7 @@
|
||||
</div>
|
||||
<div class="field"><label>DNS-Server</label>
|
||||
<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 class="field"><label>Domain</label>
|
||||
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
|
||||
@@ -78,9 +93,9 @@
|
||||
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
||||
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -92,17 +107,77 @@
|
||||
{% endif %}
|
||||
</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="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Reservierungen aus den Clients</h2>
|
||||
<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 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>
|
||||
{% 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">
|
||||
<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>
|
||||
@@ -116,7 +191,7 @@
|
||||
<div class="table-wrap" style="margin-bottom:18px;">
|
||||
<div style="overflow-x:auto;">
|
||||
<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>
|
||||
{% for r in reservations %}
|
||||
<tr>
|
||||
@@ -124,7 +199,42 @@
|
||||
<td class="mono">{{ r.mac }}</td>
|
||||
<td class="mono">{{ r.ip }}</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>
|
||||
{% 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>×</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 %}
|
||||
</tbody>
|
||||
</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>
|
||||
{% 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>
|
||||
</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>×</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 %}
|
||||
|
||||
Reference in New Issue
Block a user