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:
2026-08-11 14:42:07 +02:00
parent 9660258940
commit efda77f813
+339 -132
View File
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime
import base64, 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 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
# (ältere DB-Version) in eigene Credentials-Datensätze überführen.
if "username" in switch_cols and "password" in switch_cols:
@@ -1552,8 +1596,9 @@ def settings():
if _stop_dhcp_service_if_active():
log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt")
msg += (
" Der DHCP-Server wurde dabei gestoppt, da die konfigurierte Range zum neuen Netz nicht "
"mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf neu starten."
" Der DHCP-Server wurde dabei gestoppt und deaktiviert, da die konfigurierte Range zum "
"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")
else:
@@ -1625,19 +1670,19 @@ def settings_import_export():
DHCP_PACKAGE = "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_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_lease_default": "600",
"dhcp_lease_max": "7200",
@@ -1645,8 +1690,18 @@ DHCP_DEFAULTS = {
}
def _dhcp_config_is_complete(cfg):
return bool(cfg.get("dhcp_range_start") and cfg.get("dhcp_range_end"))
def _dhcp_subnets(conn):
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
# 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():
cfg = {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
return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()}
def _detect_dhcp_status():
@@ -1713,18 +1758,23 @@ def _detect_dhcp_status():
def _stop_dhcp_service_if_active():
"""Stoppt den Kea-DHCP-Dienst, falls er gerade läuft — aufgerufen, wenn
sich die Host-Netzwerkkonfiguration ändert (Systemeinstellungen >
Netzwerkeinstellungen). Eine vorher passende Range/Interface-Bindung
kann nach einer IP-/Subnetzänderung plötzlich zum falschen Netz gehören.
Bewusst nur stoppen, nicht automatisch neu starten — der Admin muss die
Range aktiv auf der DHCP-Seite prüfen/bestätigen, statt dass die App mit
einer möglicherweise falschen Konfiguration weiterläuft oder blind rät."""
"""Stoppt UND deaktiviert den Kea-DHCP-Dienst, falls er gerade läuft —
aufgerufen, wenn sich die Host-Netzwerkkonfiguration ändert
(Systemeinstellungen > Netzwerkeinstellungen). Eine vorher passende
Range/Interface-Bindung kann nach einer IP-/Subnetzänderung plötzlich
zum falschen Netz gehören. Bewusst nur stoppen, nicht automatisch neu
starten — der Admin muss die Range aktiv auf der DHCP-Seite
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:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
if status.stdout.strip() != "active":
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:
return False
@@ -1844,42 +1894,70 @@ def _dhcp_safe_hostname(name, mac):
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
DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
übersprungen: Geräte, deren IP außerhalb des (aus der System-
Netzwerkkonfiguration erkannten) Subnets liegt — Kea lehnt eine
Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab
(mit einem Live-Test gegen echtes Kea verifiziert). `network` ist ein
ipaddress.IPv4Network oder None, falls die Erkennung fehlgeschlagen ist
(dann werden alle Geräte als "außerhalb" übersprungen, statt eine mit
Sicherheit ungültige Konfiguration zu erzeugen)."""
übersprungen: Geräte, 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
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"
).fetchall()
seen_hostnames = set()
reservations = []
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:
try:
in_subnet = network is not None and ipaddress.IPv4Address(row["rpi_ip"]) in network
ip = ipaddress.IPv4Address(row["rpi_ip"])
except ValueError:
in_subnet = False
if not in_subnet:
out_of_subnet += 1
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
continue
hostname = _dhcp_safe_hostname(row["name"], row["mac"])
# Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem
# Namen) auflösen, statt zwei Reservierungen mit demselben
# Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
# Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit
# demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
seen = seen_hostnames_by_subnet[matched["id"]]
base_hostname, suffix = hostname, 2
while hostname in seen_hostnames:
while hostname in seen:
hostname = f"{base_hostname}-{suffix}"
suffix += 1
seen_hostnames.add(hostname)
reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]})
return reservations, out_of_subnet
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"],
"subnet_label": f"{matched['interface']} ({matched['net_info']['network']})",
})
return reservations_by_subnet, out_of_subnet
def _dhcp_option_defs(conn):
@@ -1895,32 +1973,70 @@ def _dhcp_option_values(conn):
return values
def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
Subnet/Netzmaske kommen bewusst NICHT aus manueller Eingabe, sondern aus
net_info (siehe _detect_interface_network) — die Konfiguration koppelt
sich damit immer an das Netz, in dem der gewählte Interface tatsächlich
hängt. 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."""
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip())
# Gateway: expliziter Override hat Vorrang, sonst automatisch erkanntes
# Gateway dieses Hosts — für den Fall, dass Clients einen anderen Router
# bekommen sollen als den, über den dieser Host selbst geht.
router = cfg.get("dhcp_gateway", "").strip() or net_info.get("gateway") or ""
def _dhcp_read_leases():
"""Liest die aktuell aktiven Kea-Leases direkt aus der memfile-CSV
(siehe lease-file in _render_kea_config) — kein Kea-Control-Agent
nötig, nur Lesezugriff auf die Datei. Wichtig, um Clients OHNE eigene
Reservierung sichtbar zu machen, die sich einfach eine freie IP aus dem
Pool genommen haben (siehe reserved-Flag, wird vom Aufrufer ergänzt).
Gibt None zurück, falls die Datei (noch) nicht existiert — z.B. weil
der Dienst noch nie gestartet wurde oder noch keine Adresse vergeben
hat — statt fälschlich "keine Leases" zu behaupten."""
try:
with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
except OSError:
return None
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 = [
{"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:
@@ -1928,8 +2044,22 @@ def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
if global_value:
global_option_data.append({"name": d["name"], "data": global_value})
subnet4 = []
for idx, s in enumerate(valid_subnets, start=1):
# Gateway: expliziter Override hat Vorrang, sonst automatisch
# erkanntes Gateway dieses Hosts — für den Fall, dass Clients einen
# anderen Router bekommen sollen als den, über den dieser Host für
# dieses Netz selbst geht.
router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or ""
dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip())
sub_option_data = []
if router:
sub_option_data.append({"name": "routers", "data": router})
if dns_servers:
sub_option_data.append({"name": "domain-name-servers", "data": dns_servers})
kea_reservations = []
for r in 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"])
@@ -1944,26 +2074,26 @@ def _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
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 {
"Dhcp4": {
"interfaces-config": {"interfaces": [cfg["dhcp_interface"]]},
"lease-database": {"type": "memfile", "lfc-interval": 3600},
"interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})},
# 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),
"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"{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 [],
"subnet4": subnet4,
}
}
@@ -1995,22 +2125,9 @@ def settings_dhcp():
return redirect(url_for("settings_dhcp"))
if "save_dhcp_config" in request.form:
submitted_interface = request.form.get("dhcp_interface", "").strip()
range_start = request.form.get("dhcp_range_start", "").strip()
range_end = request.form.get("dhcp_range_end", "").strip()
range_error = None
if range_start and range_end:
# Gegen das (ggf. gerade erst umgestellte) Interface aus DIESEM
# Formular prüfen, nicht gegen das zuvor gespeicherte — sonst
# würde ein gleichzeitiger Interface- und Range-Wechsel in einem
# Speichern fälschlich gegen das alte Interface geprüft.
check_interface = submitted_interface or get_setting("dhcp_interface", DHCP_DEFAULTS["dhcp_interface"])
_, range_error = _dhcp_matching_network(check_interface, range_start, range_end)
if submitted_interface and submitted_interface not in _list_network_interfaces():
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
elif range_error:
flash(range_error, "danger")
else:
# Nur noch die globalen, Kea-weiten Einstellungen — Interface/
# Range/Gateway/DNS werden seit der Mehrfach-Subnetz-Unterstützung
# separat je Subnetz gepflegt (siehe add/edit/delete_dhcp_subnet).
for key in DHCP_DEFAULTS:
value = request.form.get(key, "").strip()
if value:
@@ -2018,6 +2135,58 @@ def settings_dhcp():
log_action("settings.update", "DHCP-Konfiguration")
flash("DHCP-Konfiguration gespeichert.", "success")
elif "add_dhcp_subnet" in request.form:
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:
max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"]
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:
code_raw = request.form.get("code", "").strip()
name = request.form.get("name", "").strip()
@@ -2089,31 +2258,41 @@ def settings_dhcp():
elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings()
if not _dhcp_config_is_complete(cfg):
flash("Range Start/Ende müssen eingetragen sein, bevor eine Konfiguration geschrieben wird.", "danger")
valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn))
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()
return redirect(url_for("settings_dhcp"))
net_info, range_error = _dhcp_matching_network(cfg["dhcp_interface"], cfg["dhcp_range_start"], cfg["dhcp_range_end"])
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"])
reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets)
option_defs = _dhcp_option_defs(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"]
total_reservations = sum(len(v) for v in reservations_by_subnet.values())
try:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content + "\n")
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen, Netz {net_info['network']}")
flash(
f"{len(reservations)} Reservierungen für Netz {net_info['network']} (Interface {cfg['dhcp_interface']}) "
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",
log_action(
"settings.dhcp_write", output_path,
f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen",
)
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:
flash(f"Konnte Datei nicht schreiben: {e}", "danger")
@@ -2138,20 +2317,18 @@ def settings_dhcp():
elif "dhcp_service_action" in request.form:
action = request.form.get("dhcp_service_action")
cfg_now = _load_dhcp_settings()
# Bewusst auch beim Starten nochmal live gegen das aktuelle Netz
# geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich
# der Dienst mit einer Range starten, die z.B. nach einem
# Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr
# zu einem echten Subnetz passt.
range_error = (
_dhcp_matching_network(cfg_now["dhcp_interface"], cfg_now["dhcp_range_start"], cfg_now["dhcp_range_end"])[1]
if _dhcp_config_is_complete(cfg_now) else None
valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
if action == "enable_restart" and not valid_subnets_now:
flash(
"Mindestens ein Subnetz mit einer zu einem echten Netz passenden Range muss konfiguriert und "
"die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.",
"danger",
)
if action == "enable_restart" and not _dhcp_config_is_complete(cfg_now):
flash("Range Start/Ende müssen eingetragen und die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.", "danger")
elif action == "enable_restart" and range_error:
flash(f"Dienst nicht gestartet: {range_error}", "danger")
elif 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)
@@ -2162,15 +2339,24 @@ def settings_dhcp():
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)
# 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"))
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()
return redirect(url_for("settings_dhcp"))
cfg = _load_dhcp_settings()
net_info = _detect_interface_network(cfg["dhcp_interface"])
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, net_info.get("network"))
subnets = _dhcp_subnets(conn)
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_values = _dhcp_option_values(conn)
skipped_count = conn.execute(
@@ -2178,12 +2364,32 @@ def settings_dhcp():
).fetchone()["n"]
conn.close()
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(
"settings_dhcp.html",
cfg=cfg,
status=status,
net_info=net_info,
subnets=subnets,
subnet_status=subnet_status,
interfaces=_list_network_interfaces(),
reservations=reservations,
skipped_count=skipped_count,
@@ -2194,6 +2400,7 @@ def settings_dhcp():
option_types=DHCP_OPTION_TYPES,
dhcp_package=DHCP_PACKAGE,
dhcp_service=DHCP_SERVICE,
leases=leases,
)