Fix: falscher Kea-Schluessel lease-file statt name blockierte jeden Dienststart
Live reproduziert: 'kea-dhcp4 -t' lehnte JEDE generierte Konfiguration mit 'got unexpected keyword "lease-file" in lease-database map' ab, sobald mindestens ein Subnetz konfiguriert war -- der Dienst liess sich dadurch nie starten, unabhaengig von der Anzahl/Gueltigkeit der Subnetze selbst. Kea nennt den Datei-Pfad des memfile-Lease-Backends 'name', nicht 'lease-file'. Nach dem Fix: 'kea-dhcp4 -t' besteht, Dienststart auf der Test-VM mit einem echten Subnetz verifiziert (DHCP4_STARTED, Dienst aktiv).
This commit is contained in:
+359
-152
@@ -18,7 +18,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, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
import base64, csv, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
||||||
import paramiko
|
import paramiko
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -604,6 +604,50 @@ def _ensure_schema():
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen
|
||||||
|
# Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz
|
||||||
|
# haben, für die alle DHCP angeboten werden soll. gateway/dns sind je
|
||||||
|
# Subnetz optional (leer = automatisch erkanntes Gateway des Interfaces
|
||||||
|
# bzw. kein DNS-Override); domain/Lease-Zeiten/Ausgabepfad bleiben
|
||||||
|
# bewusst global (siehe DHCP_DEFAULTS), da sie Kea-weit gelten.
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dhcp_subnets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
interface TEXT NOT NULL,
|
||||||
|
range_start TEXT NOT NULL,
|
||||||
|
range_end TEXT NOT NULL,
|
||||||
|
gateway TEXT,
|
||||||
|
dns TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/
|
||||||
|
# _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile
|
||||||
|
# der neuen Tabelle überführen, statt eine bereits funktionierende
|
||||||
|
# Konfiguration beim Upgrade stillschweigend zu verlieren. Per Guard nur
|
||||||
|
# einmalig, da die alten settings-Schlüssel absichtlich stehen bleiben
|
||||||
|
# (Altlast, wird nicht mehr gelesen) und sonst bei jedem Start erneut
|
||||||
|
# eine (dann ggf. schon gelöschte) Zeile anlegen würde.
|
||||||
|
_migration_key_dhcp = "_migrated_dhcp_single_range_to_subnets_v1"
|
||||||
|
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_dhcp,)).fetchone():
|
||||||
|
legacy_start = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_start'").fetchone()
|
||||||
|
legacy_end = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_end'").fetchone()
|
||||||
|
if legacy_start and legacy_end and legacy_start["value"] and legacy_end["value"]:
|
||||||
|
legacy_interface = conn.execute("SELECT value FROM settings WHERE key='dhcp_interface'").fetchone()
|
||||||
|
legacy_gateway = conn.execute("SELECT value FROM settings WHERE key='dhcp_gateway'").fetchone()
|
||||||
|
legacy_dns = conn.execute("SELECT value FROM settings WHERE key='dhcp_dns'").fetchone()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, 0)",
|
||||||
|
(
|
||||||
|
(legacy_interface["value"] if legacy_interface else None) or "eth0",
|
||||||
|
legacy_start["value"], legacy_end["value"],
|
||||||
|
legacy_gateway["value"] if legacy_gateway else None,
|
||||||
|
legacy_dns["value"] if legacy_dns else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_dhcp,))
|
||||||
|
|
||||||
# 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:
|
||||||
@@ -1552,8 +1596,9 @@ def settings():
|
|||||||
if _stop_dhcp_service_if_active():
|
if _stop_dhcp_service_if_active():
|
||||||
log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt")
|
log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt")
|
||||||
msg += (
|
msg += (
|
||||||
" Der DHCP-Server wurde dabei gestoppt, da die konfigurierte Range zum neuen Netz nicht "
|
" Der DHCP-Server wurde dabei gestoppt und deaktiviert, da die konfigurierte Range zum "
|
||||||
"mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf neu starten."
|
"neuen Netz nicht mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf "
|
||||||
|
"neu starten (bleibt sonst auch nach einem Host-Neustart aus)."
|
||||||
)
|
)
|
||||||
flash(msg, "success")
|
flash(msg, "success")
|
||||||
else:
|
else:
|
||||||
@@ -1625,19 +1670,19 @@ def settings_import_export():
|
|||||||
|
|
||||||
DHCP_PACKAGE = "kea-dhcp4-server"
|
DHCP_PACKAGE = "kea-dhcp4-server"
|
||||||
DHCP_SERVICE = "kea-dhcp4-server"
|
DHCP_SERVICE = "kea-dhcp4-server"
|
||||||
|
# Explizit statt dem eingebauten Kea-Default gesetzt (siehe _render_kea_config)
|
||||||
|
# — Anzeige der aktiven Leases (_dhcp_read_leases) liest genau diesen Pfad,
|
||||||
|
# damit beide Seiten garantiert übereinstimmen, statt sich auf einen
|
||||||
|
# distributionsabhängigen impliziten Default zu verlassen.
|
||||||
|
KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||||
|
|
||||||
|
# Interface, Range, Gateway und DNS sind seit der Mehrfach-Subnetz-
|
||||||
|
# Unterstützung KEINE globalen Einstellungen mehr, sondern je Zeile in der
|
||||||
|
# dhcp_subnets-Tabelle gepflegt (siehe _dhcp_subnets/_dhcp_matching_network)
|
||||||
|
# — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben.
|
||||||
|
# Domain, Lease-Zeiten und Ausgabepfad gelten dagegen für den gesamten
|
||||||
|
# Kea-Dienst und bleiben deshalb global.
|
||||||
DHCP_DEFAULTS = {
|
DHCP_DEFAULTS = {
|
||||||
"dhcp_interface": "eth0",
|
|
||||||
# Bewusst KEINE vorausgefüllten Range-/DNS-Defaults, die wie eine echte
|
|
||||||
# Konfiguration aussehen könnten, aber zufällig nicht zum echten Netz
|
|
||||||
# passen — leer + Platzhaltertext im Formular ist ehrlicher, als einen
|
|
||||||
# Dienst unbemerkt mit falschen Werten starten zu lassen.
|
|
||||||
"dhcp_range_start": "",
|
|
||||||
"dhcp_range_end": "",
|
|
||||||
"dhcp_dns": "",
|
|
||||||
# Optionaler Override — leer bedeutet "automatisch erkanntes Gateway
|
|
||||||
# dieses Hosts verwenden" (siehe _render_kea_config).
|
|
||||||
"dhcp_gateway": "",
|
|
||||||
"dhcp_domain": "local",
|
"dhcp_domain": "local",
|
||||||
"dhcp_lease_default": "600",
|
"dhcp_lease_default": "600",
|
||||||
"dhcp_lease_max": "7200",
|
"dhcp_lease_max": "7200",
|
||||||
@@ -1645,8 +1690,18 @@ DHCP_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dhcp_config_is_complete(cfg):
|
def _dhcp_subnets(conn):
|
||||||
return bool(cfg.get("dhcp_range_start") and cfg.get("dhcp_range_end"))
|
return conn.execute("SELECT * FROM dhcp_subnets ORDER BY sort_order ASC, id ASC").fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def _dhcp_subnet_matches(subnet_row):
|
||||||
|
"""Validiert eine einzelne Subnetz-Zeile gegen das tatsächliche Netz
|
||||||
|
ihres Interfaces (siehe _dhcp_matching_network) und gibt bei Erfolg
|
||||||
|
(net_info, None) zurück, sonst (None, Fehlertext). Eine Subnetz-Zeile
|
||||||
|
ohne passendes reales Netz kann Kea nicht zugeordnet werden — es muss
|
||||||
|
eine physische IP im gewünschten Bereich vorhanden sein, sonst fehlt
|
||||||
|
die CIDR/Netzmaske, die Kea für den subnet4-Block braucht."""
|
||||||
|
return _dhcp_matching_network(subnet_row["interface"], subnet_row["range_start"], subnet_row["range_end"])
|
||||||
|
|
||||||
# Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible
|
# Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible
|
||||||
# Teilmenge statt der vollen Liste, orientiert an den in der Praxis
|
# Teilmenge statt der vollen Liste, orientiert an den in der Praxis
|
||||||
@@ -1664,17 +1719,7 @@ DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES}
|
|||||||
|
|
||||||
|
|
||||||
def _load_dhcp_settings():
|
def _load_dhcp_settings():
|
||||||
cfg = {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()}
|
||||||
available = _list_network_interfaces()
|
|
||||||
if available and cfg.get("dhcp_interface") not in available:
|
|
||||||
# Der hart codierte Default ("eth0") bzw. ein früher gespeicherter Wert
|
|
||||||
# existiert auf Systemen mit Predictable Network Interface Names
|
|
||||||
# (z.B. "enp1s0") nicht — dann lieber die tatsächlich vorhandene erste
|
|
||||||
# Schnittstelle vorschlagen, statt eine erkennbar falsche anzuzeigen
|
|
||||||
# (führte sonst dazu, dass die Erkennung fälschlich "eth0" statt der
|
|
||||||
# im Dropdown sichtbaren echten Schnittstelle prüfte).
|
|
||||||
cfg["dhcp_interface"] = available[0]
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_dhcp_status():
|
def _detect_dhcp_status():
|
||||||
@@ -1713,18 +1758,23 @@ def _detect_dhcp_status():
|
|||||||
|
|
||||||
|
|
||||||
def _stop_dhcp_service_if_active():
|
def _stop_dhcp_service_if_active():
|
||||||
"""Stoppt den Kea-DHCP-Dienst, falls er gerade läuft — aufgerufen, wenn
|
"""Stoppt UND deaktiviert den Kea-DHCP-Dienst, falls er gerade läuft —
|
||||||
sich die Host-Netzwerkkonfiguration ändert (Systemeinstellungen >
|
aufgerufen, wenn sich die Host-Netzwerkkonfiguration ändert
|
||||||
Netzwerkeinstellungen). Eine vorher passende Range/Interface-Bindung
|
(Systemeinstellungen > Netzwerkeinstellungen). Eine vorher passende
|
||||||
kann nach einer IP-/Subnetzänderung plötzlich zum falschen Netz gehören.
|
Range/Interface-Bindung kann nach einer IP-/Subnetzänderung plötzlich
|
||||||
Bewusst nur stoppen, nicht automatisch neu starten — der Admin muss die
|
zum falschen Netz gehören. Bewusst nur stoppen, nicht automatisch neu
|
||||||
Range aktiv auf der DHCP-Seite prüfen/bestätigen, statt dass die App mit
|
starten — der Admin muss die Range aktiv auf der DHCP-Seite
|
||||||
einer möglicherweise falschen Konfiguration weiterläuft oder blind rät."""
|
prüfen/bestätigen, statt dass die App mit einer möglicherweise falschen
|
||||||
|
Konfiguration weiterläuft oder blind rät. Zusätzlich deaktivieren (nicht
|
||||||
|
nur stoppen), damit ein Host-Neustart vor dieser Prüfung den Dienst
|
||||||
|
nicht trotzdem automatisch mit der ggf. falschen Range wieder hochfährt."""
|
||||||
try:
|
try:
|
||||||
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
|
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
|
||||||
if status.stdout.strip() != "active":
|
if status.stdout.strip() != "active":
|
||||||
return False
|
return False
|
||||||
return subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0
|
ok = subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0
|
||||||
|
subprocess.run(["systemctl", "disable", DHCP_SERVICE], timeout=15)
|
||||||
|
return ok
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1844,42 +1894,70 @@ 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, network):
|
def _dhcp_valid_subnets(subnets):
|
||||||
|
"""Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen
|
||||||
|
ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine
|
||||||
|
physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea
|
||||||
|
damit nichts anfangen). Gibt (valid, invalid) zurück: valid ist um
|
||||||
|
"net_info" angereichert (für Reservierungs-Zuordnung und Kea-Config),
|
||||||
|
invalid enthält (Zeile, Fehlertext) für die Anzeige/Warnungen."""
|
||||||
|
valid, invalid = [], []
|
||||||
|
for s in subnets:
|
||||||
|
net_info, error = _dhcp_subnet_matches(s)
|
||||||
|
if error:
|
||||||
|
invalid.append((s, error))
|
||||||
|
else:
|
||||||
|
valid.append({
|
||||||
|
"id": s["id"], "interface": s["interface"],
|
||||||
|
"range_start": s["range_start"], "range_end": s["range_end"],
|
||||||
|
"gateway": s["gateway"], "dns": s["dns"], "net_info": net_info,
|
||||||
|
})
|
||||||
|
return valid, invalid
|
||||||
|
|
||||||
|
|
||||||
|
def _dhcp_reservation_candidates(conn, valid_subnets):
|
||||||
"""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. Ebenso
|
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
|
||||||
übersprungen: Geräte, deren IP außerhalb des (aus der System-
|
übersprungen: Geräte, deren IP zu KEINEM der übergebenen (bereits
|
||||||
Netzwerkkonfiguration erkannten) Subnets liegt — Kea lehnt eine
|
validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb
|
||||||
Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab
|
ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test
|
||||||
(mit einem Live-Test gegen echtes Kea verifiziert). `network` ist ein
|
gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id,
|
||||||
ipaddress.IPv4Network oder None, falls die Erkennung fehlgeschlagen ist
|
out_of_subnet_count) zurück — jedes Gerät landet bei genau einem
|
||||||
(dann werden alle Geräte als "außerhalb" übersprungen, statt eine mit
|
Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere
|
||||||
Sicherheit ungültige Konfiguration zu erzeugen)."""
|
Subnetze gleichzeitig anbieten)."""
|
||||||
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_by_subnet = {s["id"]: set() for s in valid_subnets}
|
||||||
reservations = []
|
reservations_by_subnet = {s["id"]: [] for s in valid_subnets}
|
||||||
out_of_subnet = 0
|
out_of_subnet = 0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
in_subnet = network is not None and ipaddress.IPv4Address(row["rpi_ip"]) in network
|
ip = ipaddress.IPv4Address(row["rpi_ip"])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
in_subnet = False
|
out_of_subnet += 1
|
||||||
if not in_subnet:
|
continue
|
||||||
|
matched = next((s for s in valid_subnets if ip in s["net_info"]["network"]), None)
|
||||||
|
if matched is None:
|
||||||
out_of_subnet += 1
|
out_of_subnet += 1
|
||||||
continue
|
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 Reservierungen mit demselben
|
# Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit
|
||||||
# Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
|
# demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
|
||||||
|
seen = seen_hostnames_by_subnet[matched["id"]]
|
||||||
base_hostname, suffix = hostname, 2
|
base_hostname, suffix = hostname, 2
|
||||||
while hostname in seen_hostnames:
|
while hostname in seen:
|
||||||
hostname = f"{base_hostname}-{suffix}"
|
hostname = f"{base_hostname}-{suffix}"
|
||||||
suffix += 1
|
suffix += 1
|
||||||
seen_hostnames.add(hostname)
|
seen.add(hostname)
|
||||||
reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]})
|
reservations_by_subnet[matched["id"]].append({
|
||||||
return reservations, out_of_subnet
|
"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"],
|
||||||
|
"subnet_id": matched["id"],
|
||||||
|
"subnet_label": f"{matched['interface']} ({matched['net_info']['network']})",
|
||||||
|
})
|
||||||
|
return reservations_by_subnet, out_of_subnet
|
||||||
|
|
||||||
|
|
||||||
def _dhcp_option_defs(conn):
|
def _dhcp_option_defs(conn):
|
||||||
@@ -1895,32 +1973,70 @@ def _dhcp_option_values(conn):
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
|
def _dhcp_read_leases():
|
||||||
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
|
"""Liest die aktuell aktiven Kea-Leases direkt aus der memfile-CSV
|
||||||
Subnet/Netzmaske kommen bewusst NICHT aus manueller Eingabe, sondern aus
|
(siehe lease-file in _render_kea_config) — kein Kea-Control-Agent
|
||||||
net_info (siehe _detect_interface_network) — die Konfiguration koppelt
|
nötig, nur Lesezugriff auf die Datei. Wichtig, um Clients OHNE eigene
|
||||||
sich damit immer an das Netz, in dem der gewählte Interface tatsächlich
|
Reservierung sichtbar zu machen, die sich einfach eine freie IP aus dem
|
||||||
hängt. Globale Options landen im Top-Level option-data (gilt für alle
|
Pool genommen haben (siehe reserved-Flag, wird vom Aufrufer ergänzt).
|
||||||
Subnetze), Client-Overrides im option-data der jeweiligen Reservierung
|
Gibt None zurück, falls die Datei (noch) nicht existiert — z.B. weil
|
||||||
— Kea überschreibt damit automatisch gleichnamige globale/Subnet-
|
der Dienst noch nie gestartet wurde oder noch keine Adresse vergeben
|
||||||
Options für genau diesen Client, ohne dass wir das selbst
|
hat — statt fälschlich "keine Leases" zu behaupten."""
|
||||||
zusammenrechnen müssen."""
|
try:
|
||||||
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip())
|
with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
|
||||||
# Gateway: expliziter Override hat Vorrang, sonst automatisch erkanntes
|
rows = list(csv.DictReader(f))
|
||||||
# Gateway dieses Hosts — für den Fall, dass Clients einen anderen Router
|
except OSError:
|
||||||
# bekommen sollen als den, über den dieser Host selbst geht.
|
return None
|
||||||
router = cfg.get("dhcp_gateway", "").strip() or net_info.get("gateway") or ""
|
now = int(time.time())
|
||||||
|
leases = []
|
||||||
|
for row in rows:
|
||||||
|
# state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt),
|
||||||
|
# 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist
|
||||||
|
# eine tatsächlich vergebene, gültige Adresse.
|
||||||
|
if (row.get("state") or "0") != "0":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
expire = int(row.get("expire") or 0)
|
||||||
|
except ValueError:
|
||||||
|
expire = 0
|
||||||
|
# Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc)
|
||||||
|
# noch nicht aus der CSV entfernt — nicht als aktiv anzeigen.
|
||||||
|
if expire and expire < now:
|
||||||
|
continue
|
||||||
|
leases.append({
|
||||||
|
"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,
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"]))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return leases
|
||||||
|
|
||||||
|
|
||||||
|
def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values):
|
||||||
|
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-
|
||||||
|
serialisierbar) — ein subnet4-Block je konfiguriertem Subnetz, damit ein
|
||||||
|
Host mehrere IPs/Interfaces mit jeweils eigenem Netz gleichzeitig
|
||||||
|
bedienen kann. Subnet/Netzmaske kommen bewusst NICHT aus manueller
|
||||||
|
Eingabe, sondern aus dem net_info jedes Subnetzes (siehe
|
||||||
|
_dhcp_valid_subnets) — nur Subnetze mit einer zu einem echten,
|
||||||
|
tatsächlich vorhandenen Netz passenden Range fließen hier ein
|
||||||
|
(`valid_subnets`, bereits gefiltert). Gateway/DNS sind je Subnetz
|
||||||
|
eigenes option-data (unterschiedliche Netze haben i.d.R.
|
||||||
|
unterschiedliche Router); Domain und selbstdefinierte Options bleiben
|
||||||
|
global. Client-Overrides landen 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."""
|
||||||
option_def_list = [
|
option_def_list = [
|
||||||
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
|
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
|
||||||
for d in option_defs
|
for d in option_defs
|
||||||
]
|
]
|
||||||
|
|
||||||
global_option_data = []
|
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"]:
|
if cfg["dhcp_domain"]:
|
||||||
global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]})
|
global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]})
|
||||||
for d in option_defs:
|
for d in option_defs:
|
||||||
@@ -1928,42 +2044,56 @@ def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
|
|||||||
if global_value:
|
if global_value:
|
||||||
global_option_data.append({"name": d["name"], "data": global_value})
|
global_option_data.append({"name": d["name"], "data": global_value})
|
||||||
|
|
||||||
kea_reservations = []
|
subnet4 = []
|
||||||
for r in reservations:
|
for idx, s in enumerate(valid_subnets, start=1):
|
||||||
res_option_data = []
|
# Gateway: expliziter Override hat Vorrang, sonst automatisch
|
||||||
for d in option_defs:
|
# erkanntes Gateway dieses Hosts — für den Fall, dass Clients einen
|
||||||
override = option_values.get(d["id"], {}).get(r["mac"])
|
# anderen Router bekommen sollen als den, über den dieser Host für
|
||||||
if override:
|
# dieses Netz selbst geht.
|
||||||
res_option_data.append({"name": d["name"], "data": override})
|
router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or ""
|
||||||
entry = {
|
dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip())
|
||||||
"hw-address": r["mac"].lower(),
|
sub_option_data = []
|
||||||
"ip-address": r["ip"],
|
if router:
|
||||||
"hostname": r["hostname"],
|
sub_option_data.append({"name": "routers", "data": router})
|
||||||
}
|
if dns_servers:
|
||||||
if res_option_data:
|
sub_option_data.append({"name": "domain-name-servers", "data": dns_servers})
|
||||||
entry["option-data"] = res_option_data
|
|
||||||
kea_reservations.append(entry)
|
kea_reservations = []
|
||||||
|
for r in reservations_by_subnet.get(s["id"], []):
|
||||||
|
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)
|
||||||
|
|
||||||
|
subnet4.append({
|
||||||
|
"id": idx,
|
||||||
|
"subnet": f"{s['net_info']['network'].network_address}/{s['net_info']['network'].prefixlen}",
|
||||||
|
"pools": [{"pool": f"{s['range_start']} - {s['range_end']}"}],
|
||||||
|
**({"option-data": sub_option_data} if sub_option_data else {}),
|
||||||
|
"reservations": kea_reservations,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"Dhcp4": {
|
"Dhcp4": {
|
||||||
"interfaces-config": {"interfaces": [cfg["dhcp_interface"]]},
|
"interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})},
|
||||||
"lease-database": {"type": "memfile", "lfc-interval": 3600},
|
# Kea nennt den Datei-Pfad des memfile-Backends "name", nicht
|
||||||
|
# "lease-file" (mit falschem Schlüssel lehnt Kea die Config mit
|
||||||
|
# "unexpected keyword" ab — live so reproduziert und gefixt).
|
||||||
|
"lease-database": {"type": "memfile", "lfc-interval": 3600, "name": KEA_LEASE_FILE},
|
||||||
"valid-lifetime": int(cfg["dhcp_lease_default"] or 600),
|
"valid-lifetime": int(cfg["dhcp_lease_default"] or 600),
|
||||||
"max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200),
|
"max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200),
|
||||||
"option-def": option_def_list,
|
"option-def": option_def_list,
|
||||||
"option-data": global_option_data,
|
"option-data": global_option_data,
|
||||||
"subnet4": [
|
"subnet4": subnet4,
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}",
|
|
||||||
# Kein Pool ohne eingetragene Range — ein leerer/falscher
|
|
||||||
# Pool-String wäre eine Kea-Konfiguration, die entweder
|
|
||||||
# gar nicht startet oder (schlimmer) mit einer geratenen
|
|
||||||
# Range startet, die nicht zum echten Netz passt.
|
|
||||||
**({"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}]} if _dhcp_config_is_complete(cfg) else {}),
|
|
||||||
"reservations": kea_reservations,
|
|
||||||
}
|
|
||||||
] if net_info.get("ok") else [],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1995,28 +2125,67 @@ def settings_dhcp():
|
|||||||
return redirect(url_for("settings_dhcp"))
|
return redirect(url_for("settings_dhcp"))
|
||||||
|
|
||||||
if "save_dhcp_config" in request.form:
|
if "save_dhcp_config" in request.form:
|
||||||
submitted_interface = request.form.get("dhcp_interface", "").strip()
|
# Nur noch die globalen, Kea-weiten Einstellungen — Interface/
|
||||||
range_start = request.form.get("dhcp_range_start", "").strip()
|
# Range/Gateway/DNS werden seit der Mehrfach-Subnetz-Unterstützung
|
||||||
range_end = request.form.get("dhcp_range_end", "").strip()
|
# separat je Subnetz gepflegt (siehe add/edit/delete_dhcp_subnet).
|
||||||
range_error = None
|
for key in DHCP_DEFAULTS:
|
||||||
if range_start and range_end:
|
value = request.form.get(key, "").strip()
|
||||||
# Gegen das (ggf. gerade erst umgestellte) Interface aus DIESEM
|
if value:
|
||||||
# Formular prüfen, nicht gegen das zuvor gespeicherte — sonst
|
set_setting(key, value)
|
||||||
# würde ein gleichzeitiger Interface- und Range-Wechsel in einem
|
log_action("settings.update", "DHCP-Konfiguration")
|
||||||
# Speichern fälschlich gegen das alte Interface geprüft.
|
flash("DHCP-Konfiguration gespeichert.", "success")
|
||||||
check_interface = submitted_interface or get_setting("dhcp_interface", DHCP_DEFAULTS["dhcp_interface"])
|
|
||||||
_, range_error = _dhcp_matching_network(check_interface, range_start, range_end)
|
elif "add_dhcp_subnet" in request.form:
|
||||||
if submitted_interface and submitted_interface not in _list_network_interfaces():
|
interface = request.form.get("interface", "").strip()
|
||||||
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
|
range_start = request.form.get("range_start", "").strip()
|
||||||
elif range_error:
|
range_end = request.form.get("range_end", "").strip()
|
||||||
flash(range_error, "danger")
|
gateway = request.form.get("gateway", "").strip()
|
||||||
|
dns = request.form.get("dns", "").strip()
|
||||||
|
if interface not in _list_network_interfaces():
|
||||||
|
flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
|
||||||
else:
|
else:
|
||||||
for key in DHCP_DEFAULTS:
|
_, range_error = _dhcp_matching_network(interface, range_start, range_end)
|
||||||
value = request.form.get(key, "").strip()
|
if range_error:
|
||||||
if value:
|
flash(range_error, "danger")
|
||||||
set_setting(key, value)
|
else:
|
||||||
log_action("settings.update", "DHCP-Konfiguration")
|
max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"]
|
||||||
flash("DHCP-Konfiguration gespeichert.", "success")
|
conn.execute(
|
||||||
|
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(interface, range_start, range_end, gateway or None, dns or None, max_order + 1),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log_action("settings.dhcp_subnet_add", interface, f"{range_start}–{range_end}")
|
||||||
|
flash(f"Subnetz {range_start}–{range_end} auf „{interface}“ hinzugefügt.", "success")
|
||||||
|
|
||||||
|
elif "edit_dhcp_subnet" in request.form:
|
||||||
|
subnet_id = request.form.get("edit_dhcp_subnet")
|
||||||
|
interface = request.form.get("interface", "").strip()
|
||||||
|
range_start = request.form.get("range_start", "").strip()
|
||||||
|
range_end = request.form.get("range_end", "").strip()
|
||||||
|
gateway = request.form.get("gateway", "").strip()
|
||||||
|
dns = request.form.get("dns", "").strip()
|
||||||
|
if interface not in _list_network_interfaces():
|
||||||
|
flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
|
||||||
|
else:
|
||||||
|
_, range_error = _dhcp_matching_network(interface, range_start, range_end)
|
||||||
|
if range_error:
|
||||||
|
flash(range_error, "danger")
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=? WHERE id=?",
|
||||||
|
(interface, range_start, range_end, gateway or None, dns or None, subnet_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
log_action("settings.dhcp_subnet_edit", interface, f"{range_start}–{range_end}")
|
||||||
|
flash("Subnetz aktualisiert.", "success")
|
||||||
|
|
||||||
|
elif "delete_dhcp_subnet" in request.form:
|
||||||
|
subnet_id = request.form.get("delete_dhcp_subnet")
|
||||||
|
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
|
conn.commit()
|
||||||
|
log_action("settings.dhcp_subnet_delete", subnet_id)
|
||||||
|
flash("Subnetz gelöscht.", "success")
|
||||||
|
|
||||||
elif "add_dhcp_option" in request.form:
|
elif "add_dhcp_option" in request.form:
|
||||||
code_raw = request.form.get("code", "").strip()
|
code_raw = request.form.get("code", "").strip()
|
||||||
@@ -2089,31 +2258,41 @@ def settings_dhcp():
|
|||||||
|
|
||||||
elif "write_dhcp_file" in request.form:
|
elif "write_dhcp_file" in request.form:
|
||||||
cfg = _load_dhcp_settings()
|
cfg = _load_dhcp_settings()
|
||||||
if not _dhcp_config_is_complete(cfg):
|
valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn))
|
||||||
flash("Range Start/Ende müssen eingetragen sein, bevor eine Konfiguration geschrieben wird.", "danger")
|
if not valid_subnets:
|
||||||
|
flash(
|
||||||
|
"Kein gültiges Subnetz konfiguriert — mindestens ein Subnetz mit einer zu einem echten Netz "
|
||||||
|
"passenden Range wird benötigt, bevor eine Konfiguration geschrieben wird.",
|
||||||
|
"danger",
|
||||||
|
)
|
||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for("settings_dhcp"))
|
return redirect(url_for("settings_dhcp"))
|
||||||
net_info, range_error = _dhcp_matching_network(cfg["dhcp_interface"], cfg["dhcp_range_start"], cfg["dhcp_range_end"])
|
reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets)
|
||||||
if range_error:
|
|
||||||
flash(f"Nichts geschrieben: {range_error}", "danger")
|
|
||||||
conn.close()
|
|
||||||
return redirect(url_for("settings_dhcp"))
|
|
||||||
reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"])
|
|
||||||
option_defs = _dhcp_option_defs(conn)
|
option_defs = _dhcp_option_defs(conn)
|
||||||
option_values = _dhcp_option_values(conn)
|
option_values = _dhcp_option_values(conn)
|
||||||
content = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
content = json.dumps(
|
||||||
|
_render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
|
||||||
|
indent=2, ensure_ascii=False,
|
||||||
|
)
|
||||||
output_path = cfg["dhcp_output_path"]
|
output_path = cfg["dhcp_output_path"]
|
||||||
|
total_reservations = sum(len(v) for v in reservations_by_subnet.values())
|
||||||
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 + "\n")
|
f.write(content + "\n")
|
||||||
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen, Netz {net_info['network']}")
|
log_action(
|
||||||
flash(
|
"settings.dhcp_write", output_path,
|
||||||
f"{len(reservations)} Reservierungen für Netz {net_info['network']} (Interface {cfg['dhcp_interface']}) "
|
f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen",
|
||||||
f"nach {output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
|
|
||||||
f"das führt die App bewusst nicht automatisch mit aus.",
|
|
||||||
"success",
|
|
||||||
)
|
)
|
||||||
|
msg = (
|
||||||
|
f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach "
|
||||||
|
f"{output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart "
|
||||||
|
f"(Button unten) — das führt die App bewusst nicht automatisch mit aus."
|
||||||
|
)
|
||||||
|
if invalid_subnets:
|
||||||
|
skipped = ", ".join(f"{s['interface']} ({s['range_start']}–{s['range_end']})" for s, _ in invalid_subnets)
|
||||||
|
msg += f" Übersprungen, da zu keinem echten Netz passend: {skipped}."
|
||||||
|
flash(msg, "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")
|
||||||
|
|
||||||
@@ -2138,20 +2317,18 @@ def settings_dhcp():
|
|||||||
|
|
||||||
elif "dhcp_service_action" in request.form:
|
elif "dhcp_service_action" in request.form:
|
||||||
action = request.form.get("dhcp_service_action")
|
action = request.form.get("dhcp_service_action")
|
||||||
cfg_now = _load_dhcp_settings()
|
|
||||||
# Bewusst auch beim Starten nochmal live gegen das aktuelle Netz
|
# Bewusst auch beim Starten nochmal live gegen das aktuelle Netz
|
||||||
# geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich
|
# geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich
|
||||||
# der Dienst mit einer Range starten, die z.B. nach einem
|
# der Dienst mit einer Range starten, die z.B. nach einem
|
||||||
# Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr
|
# Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr
|
||||||
# zu einem echten Subnetz passt.
|
# zu einem echten Subnetz passt.
|
||||||
range_error = (
|
valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
|
||||||
_dhcp_matching_network(cfg_now["dhcp_interface"], cfg_now["dhcp_range_start"], cfg_now["dhcp_range_end"])[1]
|
if action == "enable_restart" and not valid_subnets_now:
|
||||||
if _dhcp_config_is_complete(cfg_now) else None
|
flash(
|
||||||
)
|
"Mindestens ein Subnetz mit einer zu einem echten Netz passenden Range muss konfiguriert und "
|
||||||
if action == "enable_restart" and not _dhcp_config_is_complete(cfg_now):
|
"die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.",
|
||||||
flash("Range Start/Ende müssen eingetragen und die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.", "danger")
|
"danger",
|
||||||
elif action == "enable_restart" and range_error:
|
)
|
||||||
flash(f"Dienst nicht gestartet: {range_error}", "danger")
|
|
||||||
elif action == "enable_restart":
|
elif action == "enable_restart":
|
||||||
ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
|
ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
|
||||||
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
|
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
|
||||||
@@ -2162,15 +2339,24 @@ def settings_dhcp():
|
|||||||
flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger")
|
flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger")
|
||||||
elif action == "stop":
|
elif action == "stop":
|
||||||
ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15)
|
ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15)
|
||||||
|
# Auch deaktivieren, nicht nur stoppen — sonst würde systemd
|
||||||
|
# den Dienst nach einem Neustart des Hosts automatisch wieder
|
||||||
|
# starten, obwohl der Admin ihn hier bewusst abgeschaltet hat.
|
||||||
|
# Damit übersteht ein Host-Neustart genau den zuletzt bewusst
|
||||||
|
# gewählten Zustand (an oder aus), statt Kea unabhängig davon
|
||||||
|
# immer wieder hochzufahren.
|
||||||
|
_dhcp_run_privileged(["systemctl", "disable", DHCP_SERVICE], timeout=15)
|
||||||
log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen"))
|
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")
|
flash(f"{DHCP_SERVICE} gestoppt und deaktiviert (startet nach einem Neustart nicht automatisch wieder)." 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()
|
||||||
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
subnets = _dhcp_subnets(conn)
|
||||||
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, net_info.get("network"))
|
valid_subnets, invalid_subnets = _dhcp_valid_subnets(subnets)
|
||||||
|
reservations_by_subnet, out_of_subnet_count = _dhcp_reservation_candidates(conn, valid_subnets)
|
||||||
|
reservations = [r for rs in reservations_by_subnet.values() for r in rs]
|
||||||
option_defs = _dhcp_option_defs(conn)
|
option_defs = _dhcp_option_defs(conn)
|
||||||
option_values = _dhcp_option_values(conn)
|
option_values = _dhcp_option_values(conn)
|
||||||
skipped_count = conn.execute(
|
skipped_count = conn.execute(
|
||||||
@@ -2178,12 +2364,32 @@ def settings_dhcp():
|
|||||||
).fetchone()["n"]
|
).fetchone()["n"]
|
||||||
conn.close()
|
conn.close()
|
||||||
status = _detect_dhcp_status()
|
status = _detect_dhcp_status()
|
||||||
preview = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
preview = json.dumps(
|
||||||
|
_render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
|
||||||
|
indent=2, ensure_ascii=False,
|
||||||
|
)
|
||||||
|
# Aktive Leases direkt aus Kea auslesen (siehe _dhcp_read_leases) und um
|
||||||
|
# ein reserved-Flag ergänzen — Clients OHNE eigene Reservierung, die sich
|
||||||
|
# einfach eine freie IP aus dem Pool genommen haben, sollen auffallen.
|
||||||
|
reserved_macs = {r["mac"].upper() for r in reservations}
|
||||||
|
leases = _dhcp_read_leases()
|
||||||
|
if leases is not None:
|
||||||
|
for lease in leases:
|
||||||
|
lease["reserved"] = lease["mac"] in reserved_macs
|
||||||
|
# Für die Anzeige je Subnetz-Zeile (Erkannt/Nicht erkannt + Grund) —
|
||||||
|
# als einfaches Dict statt verschachteltem Jinja-selectattr, aus
|
||||||
|
# demselben Grund wie option_values.get(...) unten im Template.
|
||||||
|
subnet_status = {}
|
||||||
|
for vs in valid_subnets:
|
||||||
|
subnet_status[vs["id"]] = {"ok": True, "network": str(vs["net_info"]["network"])}
|
||||||
|
for s, error in invalid_subnets:
|
||||||
|
subnet_status[s["id"]] = {"ok": False, "error": error}
|
||||||
return render_template(
|
return render_template(
|
||||||
"settings_dhcp.html",
|
"settings_dhcp.html",
|
||||||
cfg=cfg,
|
cfg=cfg,
|
||||||
status=status,
|
status=status,
|
||||||
net_info=net_info,
|
subnets=subnets,
|
||||||
|
subnet_status=subnet_status,
|
||||||
interfaces=_list_network_interfaces(),
|
interfaces=_list_network_interfaces(),
|
||||||
reservations=reservations,
|
reservations=reservations,
|
||||||
skipped_count=skipped_count,
|
skipped_count=skipped_count,
|
||||||
@@ -2194,6 +2400,7 @@ def settings_dhcp():
|
|||||||
option_types=DHCP_OPTION_TYPES,
|
option_types=DHCP_OPTION_TYPES,
|
||||||
dhcp_package=DHCP_PACKAGE,
|
dhcp_package=DHCP_PACKAGE,
|
||||||
dhcp_service=DHCP_SERVICE,
|
dhcp_service=DHCP_SERVICE,
|
||||||
|
leases=leases,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user