DHCP: manuelle Reservierungen, Options-Schutz+Dropdown-UI, Lease-Countdown-Fix

1. Manuelle Reservierungen (dhcp_manual_reservations) fuer Geraete, die
   nicht als Client in dieser App gepflegt werden -- bewusst eine eigene,
   explizite Aktion (MAC/IP/Name), damit kein externes Geraet unueberwacht
   automatisch eine feste IP bekommt. _dhcp_reservation_candidates fuehrt
   automatische (aus devices) und manuelle Reservierungen jetzt zusammen,
   case-insensitiv nach MAC dedupliziert (automatisch gewinnt bei Konflikt).
   Automatische Reservierungen sind in der Tabelle nicht loeschbar
   (Schloss-Symbol statt Button, sie entstehen/verschwinden mit den
   Client-Stammdaten), manuelle schon. Live verifiziert: Hinzufuegen,
   doppelte MAC ablehnen, ungueltige MAC ablehnen, generierte Kea-Config
   mit gemischten Auto-/manuellen Reservierungen besteht kea-dhcp4 -t.

2. Standard-DHCP-Optionen um 10 weitere verifizierte Optionen erweitert
   (u.a. host-name/12 -- Kea-Name mit Bindestrich, NICHT "hostname", live
   gegen echtes Kea geprueft) -- jetzt 57 insgesamt. Standard-Optionen sind
   nicht mehr loeschbar (Backend lehnt es ab, UI zeigt ein Schloss-Symbol
   statt Loeschen-Button) -- vorher liess sich eine vorbefuellte Options-
   Definition versehentlich entfernen.

3. Options-Tabellen (global UND pro Reservierung) zeigen jetzt nur noch
   tatsaechlich genutzte Options -- eigene immer, Standard-Optionen nur mit
   gesetztem Wert. Weitere Standard-Optionen kommen ueber ein Dropdown
   "+ Option hinzufuegen" dazu statt permanent alle 57 als leere Felder zu
   zeigen. Das neu eingeblendete Feld wird automatisch in den sichtbaren
   Bereich gescrollt und fokussiert (bei bis zu 57 moeglichen Optionen kann
   das Feld sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen
   und es wirkt so, als waere nichts passiert).

4. Lease-Countdown-Fix: die Ablaufzeit wird jetzt explizit als UTC
   ausgewiesen (Kea arbeitet intern in UTC-Epoch) und zusaetzlich als live
   tickender Countdown angezeigt (gleiches Prinzip wie der bestehende
   "Naechste Pruefung"-Timer). Vorher wirkte eine reine Absolutzeit auf der
   nicht selbst aktualisierenden Seite bei den kurzen Standard-Lease-Zeiten
   (600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen war, bis
   man tatsaechlich hinschaut -- live im echten Betrieb genau als dieses
   Missverstaendnis beobachtet und anhand der rohen Kea-CSV verifiziert,
   dass die richtige Spalte (expire) korrekt gelesen wird.

5. UI-Feinschliff: "In Datei schreiben" bei den Reservierungen heisst jetzt
   "Speichern" und ist der primaere (orange) Button, "Reservierung
   hinzufuegen" sekundaer -- das Hinzufuegen legt nur einen Datensatz an,
   erst "Speichern" schreibt die tatsaechlich wirksame Kea-Config.

Alles live auf der Test-VM deployt und verifiziert (Playwright fuer die
Dropdown-Sichtbarkeit/Fokus-Interaktion, curl fuer die Backend-Validierung,
kea-dhcp4 -t fuer die generierte Konfiguration), keine Fehler im
journalctl-Log.
This commit is contained in:
2026-08-11 15:52:52 +02:00
parent 9885a579ea
commit 1a0b55ed44
5 changed files with 371 additions and 77 deletions
+130 -20
View File
@@ -496,8 +496,13 @@ def get_db_connection():
# Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen.
# (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/
# Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte
# Felder und deshalb bewusst nicht enthalten. Alle 47 Namen live gegen eine
# echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln).
# Felder und deshalb bewusst nicht enthalten. Client-/Protokoll-interne
# Optionen (message-type, parameter-request-list, requested-address,
# client-identifier, server-identifier, ...) ebenfalls bewusst nicht
# enthalten — die sind serverseitig nicht sinnvoll setzbar (auch Windows
# Server listet sie nicht unter "Predefined Options"). Alle 57 Namen live
# gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option
# einzeln) — u.a. "host-name" (Kea-Name mit Bindestrich, NICHT "hostname").
DHCP_STANDARD_OPTIONS = [
(4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
(7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
@@ -505,11 +510,19 @@ DHCP_STANDARD_OPTIONS = [
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(12, "host-name", "string", "Hostname (für Reservierung erzwingbar)"),
(13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
(14, "merit-dump", "string", "Crash-Dump-Datei"),
(16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"),
(18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
(23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
(27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
(28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
@@ -532,6 +545,7 @@ DHCP_STANDARD_OPTIONS = [
(47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
(48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
(49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
(60, "vendor-class-identifier", "string", "Vendor Class Identifier"),
(64, "nisplus-domain-name", "string", "NIS+-Domain"),
(65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
(66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
@@ -545,6 +559,7 @@ DHCP_STANDARD_OPTIONS = [
(74, "irc-server", "ipv4-address", "IRC-Server"),
(75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
(76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
(118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"),
(119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
]
@@ -687,6 +702,34 @@ def _ensure_schema():
(code, name, opt_type, description),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,))
# v2: Liste um weitere verifizierte Standard-Optionen erweitert (u.a.
# host-name/12) — eigener Guard, damit bereits auf v1 migrierte
# Installationen die neu hinzugekommenen Optionen ebenfalls bekommen,
# ohne die gesamte Seed-Logik erneut über alle Zeilen laufen zu lassen.
_migration_key_std_opts_v2 = "_seeded_dhcp_standard_options_v2"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v2,)).fetchone():
for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
conn.execute(
"INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
"VALUES (?, ?, ?, ?, 1)",
(code, name, opt_type, description),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v2,))
# DHCP: manuelle Reservierungen — im Unterschied zu den automatisch aus
# den Clients (devices-Tabelle) erzeugten Reservierungen für Geräte, die
# NICHT als Client in dieser App gepflegt werden (z.B. ein fremdes/
# externes Gerät, dem bewusst und mit voller Kontrolle eine feste IP
# gegeben werden soll, statt es unüberwacht automatisch anzubinden).
# Können im Gegensatz zu automatischen Reservierungen gelöscht werden.
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_manual_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL,
name TEXT NOT NULL
)
""")
# DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen
# Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz
@@ -2025,25 +2068,44 @@ def _dhcp_valid_subnets(subnets):
def _dhcp_reservation_candidates(conn, valid_subnets):
"""Aktive Geräte mit gültiger MAC + IP, aus denen sich eine feste
DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden
"""Baut die Liste der Kea-Reservierungen aus ZWEI Quellen: automatisch
aus aktiven Geräten mit gültiger MAC + IP (Clients dieser App) sowie
zusätzlich aus manuell angelegten Reservierungen (dhcp_manual_reservations
— für Geräte, die nicht als Client gepflegt werden, z.B. ein externes
Gerät, dem bewusst eine feste IP gegeben werden soll). Bei einer MAC in
beiden Quellen gewinnt die automatische (echte Client-Daten vor einer
ggf. veralteten manuellen Zeile). Geräte ohne rpi_ip/mac werden
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
übersprungen: Gete, deren IP zu KEINEM der übergebenen (bereits
übersprungen: Eintge, deren IP zu KEINEM der übergebenen (bereits
validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb
ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test
gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id,
out_of_subnet_count) zurück — jedes Gerät landet bei genau einem
out_of_subnet_count) zurück — jeder Eintrag landet bei genau einem
Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere
Subnetze gleichzeitig anbieten)."""
rows = conn.execute(
"SELECT mac, rpi_ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC"
auto_rows = conn.execute(
"SELECT mac, rpi_ip AS ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC"
).fetchall()
manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall()
# Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können
# unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer.
candidates = {} # MAC (upper) -> {mac, ip, name, source, manual_id}
for row in manual_rows:
candidates[row["mac"].upper()] = {
"mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "manual", "manual_id": row["id"],
}
for row in auto_rows:
candidates[row["mac"].upper()] = {
"mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "auto", "manual_id": None,
}
seen_hostnames_by_subnet = {s["id"]: set() for s in valid_subnets}
reservations_by_subnet = {s["id"]: [] for s in valid_subnets}
out_of_subnet = 0
for row in rows:
for c in sorted(candidates.values(), key=lambda c: c["name"]):
try:
ip = ipaddress.IPv4Address(row["rpi_ip"])
ip = ipaddress.IPv4Address(c["ip"])
except ValueError:
out_of_subnet += 1
continue
@@ -2051,7 +2113,7 @@ def _dhcp_reservation_candidates(conn, valid_subnets):
if matched is None:
out_of_subnet += 1
continue
hostname = _dhcp_safe_hostname(row["name"], row["mac"])
hostname = _dhcp_safe_hostname(c["name"], c["mac"])
# Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem
# Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit
# demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
@@ -2062,8 +2124,8 @@ def _dhcp_reservation_candidates(conn, valid_subnets):
suffix += 1
seen.add(hostname)
reservations_by_subnet[matched["id"]].append({
"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"],
"subnet_id": matched["id"],
"mac": c["mac"], "ip": c["ip"], "hostname": hostname, "name": c["name"], "source": c["source"],
"manual_id": c["manual_id"], "subnet_id": matched["id"],
"subnet_label": f"{matched['interface']} ({matched['net_info']['network']})",
})
return reservations_by_subnet, out_of_subnet
@@ -2118,7 +2180,14 @@ def _dhcp_read_leases():
"ip": row.get("address") or "",
"mac": (row.get("hwaddr") or "").upper(),
"hostname": row.get("hostname") or "",
"expires_at": datetime.fromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None,
# Absolut UND als Epoch-ms für einen live tickenden Countdown im
# Browser (siehe initLeaseCountdowns in app.js) — eine reine
# Absolutzeit beim Seitenaufruf wirkt bei kurzen Lease-Zeiten
# (Default 600s) schnell "schon abgelaufen", sobald etwas Zeit
# vergangen ist, bis man sie tatsächlich anschaut. Explizit als
# UTC ausgewiesen, da Kea intern in UTC-Epoch arbeitet.
"expires_at": datetime.utcfromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None,
"expire_epoch_ms": expire * 1000 if expire else None,
})
try:
leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"]))
@@ -2347,12 +2416,15 @@ def settings_dhcp():
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")
option_def = conn.execute("SELECT name, is_standard FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone()
if option_def and option_def["is_standard"]:
flash("Standard-Optionen können nicht gelöscht werden — leeren Wert speichern, um sie auszublenden.", "danger")
else:
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")
@@ -2385,6 +2457,44 @@ def settings_dhcp():
log_action("settings.dhcp_option_value", mac, "Client-Override")
flash(f"DHCP-Optionen für {mac} gespeichert.", "success")
elif "add_dhcp_reservation" in request.form:
mac = request.form.get("mac", "").strip().upper()
ip = request.form.get("ip", "").strip()
name = request.form.get("name", "").strip()
if not re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac):
flash("Ungültige MAC-Adresse (Format xx:xx:xx:xx:xx:xx).", "danger")
elif not name:
flash("Name darf nicht leer sein.", "danger")
else:
try:
ipaddress.IPv4Address(ip)
except ValueError:
flash("Ungültige IPv4-Adresse.", "danger")
else:
existing_device = conn.execute(
"SELECT 1 FROM devices WHERE is_active=1 AND UPPER(mac)=?", (mac,)
).fetchone()
if existing_device:
flash(f"MAC {mac} gehört bereits zu einem Client — dafür entsteht die Reservierung automatisch.", "danger")
else:
try:
conn.execute(
"INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?)", (mac, ip, name)
)
conn.commit()
log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})")
flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger")
elif "delete_dhcp_reservation" in request.form:
reservation_id = request.form.get("delete_dhcp_reservation")
row = conn.execute("SELECT mac FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)).fetchone()
conn.execute("DELETE FROM dhcp_manual_reservations WHERE id=?", (reservation_id,))
conn.commit()
log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id)
flash("Manuelle Reservierung gelöscht.", "success")
elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings()
valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn))