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
|
`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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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("""
|
||||||
|
|||||||
@@ -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 & (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>×</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>×</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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user