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:
2026-08-10 21:47:54 +02:00
co-authored by Claude Sonnet 5
parent 9eb9cfdda6
commit c3d69a9870
4 changed files with 557 additions and 103 deletions
+312 -61
View File
@@ -17,7 +17,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime
import base64, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import base64, ipaddress, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
@@ -543,6 +543,28 @@ def _ensure_schema():
if "ssh_port" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER")
# DHCP: eigene Options-Definitionen + Werte (global/per-Client,
# siehe create_db.py für die ausführliche Begründung von device_mac='').
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
description TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
option_def_id INTEGER NOT NULL,
device_mac TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL,
FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id),
UNIQUE (option_def_id, device_mac)
)
""")
# Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten
# (ältere DB-Version) in eigene Credentials-Datensätze überführen.
if "username" in switch_cols and "password" in switch_cols:
@@ -1184,17 +1206,24 @@ def settings_import_export():
# ---------------------------------------------------------------------------
# DHCP-Reservierungen
# DHCP-Reservierungen (Kea DHCP)
#
# Bewusste Beschränkung: die App erkennt nur, ob ein DHCP-Server installiert
# ist und generiert eine reine Reservierungs-Include-Datei aus den
# Client-Daten — sie installiert NIE selbst ein Paket und startet/reloadet
# NIE den DHCP-Dienst. Ein fehlkonfigurierter/unerwartet aktiver DHCP-Server
# kann ein ganzes Netz stören; Installation und Dienst-Reload bleiben daher
# bewusst manuelle, außerhalb der App liegende Admin-Schritte (Anleitung
# dazu wird auf der Seite angezeigt).
# Backend bewusst Kea (kea-dhcp4-server), nicht das ältere isc-dhcp-server:
# Kea wird von ISC selbst aktiv weiterentwickelt (isc-dhcp-server ist seit
# 2022 EOL) und bildet "globaler Wert, pro Client überschreibbar" nativ ab
# (Reservation-Options überschreiben in Kea automatisch gleichnamige
# globale/Subnet-Options) — genau das von den Custom-Options gefordert.
#
# Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen
# (apt-get install / systemctl enable+restart) statt automatisch an
# "Konfiguration speichern" gekoppelt — ein unerwartet aktiver oder
# fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, das
# soll immer ein bewusster, separater Klick bleiben.
# ---------------------------------------------------------------------------
DHCP_PACKAGE = "kea-dhcp4-server"
DHCP_SERVICE = "kea-dhcp4-server"
DHCP_DEFAULTS = {
"dhcp_interface": "eth0",
"dhcp_subnet": "192.168.1.0",
@@ -1205,47 +1234,69 @@ DHCP_DEFAULTS = {
"dhcp_domain": "local",
"dhcp_lease_default": "600",
"dhcp_lease_max": "7200",
"dhcp_output_path": "/etc/dhcp/dhcpd.conf.d/aruba-poe-reservations.conf",
"dhcp_output_path": "/etc/kea/kea-dhcp4.conf",
}
# Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible
# Teilmenge statt der vollen Liste, orientiert an den in der Praxis
# gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex).
DHCP_OPTION_TYPES = [
("string", "Text (string)"),
("ipv4-address", "IP-Adresse (ipv4-address)"),
("uint8", "Zahl, 1 Byte (uint8)"),
("uint16", "Zahl, 2 Byte (uint16)"),
("uint32", "Zahl, 4 Byte (uint32)"),
("boolean", "Wahrheitswert (boolean)"),
("binary", "Hexadezimal/Rohdaten (binary)"),
]
DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES}
def _load_dhcp_settings():
return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()}
def _detect_dhcp_status():
"""Rein lesende Erkennung, ob ein ISC-DHCP-Server auf diesem Host
installiert ist und läuft — führt selbst NIE eine Installation, einen
Start oder einen Reload aus."""
dhcpd_path = shutil.which("dhcpd")
installed = dhcpd_path is not None
"""Rein lesende Erkennung, ob Kea auf diesem Host installiert ist und
läuft — löst selbst nichts aus. Installation/Start laufen ausschließlich
über die separaten, bestätigten Aktionen weiter unten."""
kea_path = shutil.which("kea-dhcp4")
installed = kea_path is not None
active = None
enabled = None
version = None
if installed:
try:
version_out = subprocess.run(
["dhcpd", "--version"], capture_output=True, text=True, timeout=5
["kea-dhcp4", "-v"], capture_output=True, text=True, timeout=5
)
version = (version_out.stdout or version_out.stderr or "").strip().splitlines()[0] if (version_out.stdout or version_out.stderr) else None
out = (version_out.stdout or version_out.stderr or "").strip()
version = out.splitlines()[0] if out else None
except Exception:
version = None
try:
status = subprocess.run(
["systemctl", "is-active", "isc-dhcp-server"],
capture_output=True, text=True, timeout=5,
["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
)
active = status.stdout.strip() == "active"
except Exception:
active = None
return {"installed": installed, "path": dhcpd_path, "active": active, "version": version}
try:
enabled_out = subprocess.run(
["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
)
enabled = enabled_out.stdout.strip() == "enabled"
except Exception:
enabled = None
return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version}
def _dhcp_safe_hostname(name, mac):
"""Wandelt einen Client-Namen in einen für DHCP-Host-Deklarationen und
option host-name gültigen Bezeichner um (nur a-z0-9-, keine
führenden/abschließenden/doppelten Bindestriche). Fällt bei leerem
Ergebnis auf die letzten 4 Byte der MAC zurück, damit nie ein leerer
oder doppelter Bezeichner entsteht."""
"""Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/
doppelten Bindestriche). Fällt bei leerem Ergebnis auf die letzten 4
Byte der MAC zurück, damit nie ein leerer oder doppelter Bezeichner
entsteht."""
slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if slug:
@@ -1253,55 +1304,142 @@ def _dhcp_safe_hostname(name, mac):
return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:]
def _dhcp_reservation_candidates(conn):
def _dhcp_reservation_candidates(conn, cfg):
"""Aktive Geräte mit gültiger MAC + IP, aus denen sich eine feste
DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden
übersprungen statt eine kaputte Reservierung zu erzeugen."""
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
übersprungen: Geräte, deren IP außerhalb des konfigurierten Subnets
liegt — Kea lehnt eine Reservierung außerhalb ihres subnet4-Blocks als
Konfigurationsfehler ab (mit einem Live-Test gegen echtes Kea
verifiziert), eine solche Reservierung würde also nie eine gültige
Konfiguration ergeben."""
try:
network = ipaddress.IPv4Network(f"{cfg['dhcp_subnet']}/{_dhcp_prefix_length(cfg['dhcp_netmask'])}", strict=False)
except ValueError:
network = None
rows = conn.execute(
"SELECT mac, rpi_ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC"
).fetchall()
seen_hostnames = set()
reservations = []
out_of_subnet = 0
for row in rows:
try:
in_subnet = network is not None and ipaddress.IPv4Address(row["rpi_ip"]) in network
except ValueError:
in_subnet = False
if not in_subnet:
out_of_subnet += 1
continue
hostname = _dhcp_safe_hostname(row["name"], row["mac"])
# Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem
# Namen) auflösen, statt zwei Host-Blöcke mit demselben Bezeichner
# zu erzeugen (das würde dhcpd -t als Fehler ablehnen).
# Namen) auflösen, statt zwei Reservierungen mit demselben
# Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
base_hostname, suffix = hostname, 2
while hostname in seen_hostnames:
hostname = f"{base_hostname}-{suffix}"
suffix += 1
seen_hostnames.add(hostname)
reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]})
return reservations
return reservations, out_of_subnet
def _render_dhcp_config(cfg, reservations):
lines = [
"# Automatisch generiert vom PoE Manager — Änderungen werden beim",
"# nächsten Schreiben aus den Geräte-Stammdaten überschrieben.",
"# Diese Datei enthält NUR die Subnet-/Reservierungs-Deklaration —",
"# per \"include\" aus der eigentlichen dhcpd.conf einbinden.",
"",
f"subnet {cfg['dhcp_subnet']} netmask {cfg['dhcp_netmask']} {{",
f" option routers {cfg['dhcp_dns'].split(',')[0].strip() if cfg['dhcp_dns'] else cfg['dhcp_subnet']};",
f" option domain-name-servers {cfg['dhcp_dns']};",
f" option domain-name \"{cfg['dhcp_domain']}\";",
f" range {cfg['dhcp_range_start']} {cfg['dhcp_range_end']};",
f" default-lease-time {cfg['dhcp_lease_default']};",
f" max-lease-time {cfg['dhcp_lease_max']};",
"}",
"",
def _dhcp_option_defs(conn):
return conn.execute("SELECT id, code, name, type, description FROM dhcp_option_defs ORDER BY code ASC").fetchall()
def _dhcp_option_values(conn):
"""{option_def_id: {"": <globaler Wert>, "<mac>": <Override>, ...}}
— device_mac='' ist der globale Wert (siehe create_db.py)."""
values = {}
for row in conn.execute("SELECT option_def_id, device_mac, value FROM dhcp_option_values"):
values.setdefault(row["option_def_id"], {})[row["device_mac"]] = row["value"]
return values
def _dhcp_prefix_length(netmask):
"""Wandelt eine punktierte Netzmaske in die CIDR-Bitlänge für Keas
"subnet"-Notation um. Fällt bei ungültiger Eingabe auf /24 zurück,
statt die Konfigurationserzeugung mit einer Exception abzubrechen."""
try:
return ipaddress.IPv4Network(f"0.0.0.0/{netmask}").prefixlen
except (ValueError, ipaddress.NetmaskValueError):
return 24
def _render_kea_config(cfg, reservations, option_defs, option_values):
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
Globale Options landen im Top-Level option-data (gilt für alle Subnetze),
Client-Overrides im option-data der jeweiligen Reservierung — Kea
überschreibt damit automatisch gleichnamige globale/Subnet-Options für
genau diesen Client, ohne dass wir das selbst zusammenrechnen müssen."""
prefix = _dhcp_prefix_length(cfg["dhcp_netmask"])
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip())
router = cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else ""
option_def_list = [
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
for d in option_defs
]
global_option_data = []
if router:
global_option_data.append({"name": "routers", "data": router})
if dns_servers:
global_option_data.append({"name": "domain-name-servers", "data": dns_servers})
if cfg["dhcp_domain"]:
global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]})
for d in option_defs:
global_value = option_values.get(d["id"], {}).get("")
if global_value:
global_option_data.append({"name": d["name"], "data": global_value})
kea_reservations = []
for r in reservations:
lines += [
f"host {r['hostname']} {{",
f" hardware ethernet {r['mac']};",
f" fixed-address {r['ip']};",
f" option host-name \"{r['hostname']}\";",
"}",
]
return "\n".join(lines) + "\n"
res_option_data = []
for d in option_defs:
override = option_values.get(d["id"], {}).get(r["mac"])
if override:
res_option_data.append({"name": d["name"], "data": override})
entry = {
"hw-address": r["mac"].lower(),
"ip-address": r["ip"],
"hostname": r["hostname"],
}
if res_option_data:
entry["option-data"] = res_option_data
kea_reservations.append(entry)
return {
"Dhcp4": {
"interfaces-config": {"interfaces": [cfg["dhcp_interface"]]},
"lease-database": {"type": "memfile", "lfc-interval": 3600},
"valid-lifetime": int(cfg["dhcp_lease_default"] or 600),
"max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200),
"option-def": option_def_list,
"option-data": global_option_data,
"subnet4": [
{
"id": 1,
"subnet": f"{cfg['dhcp_subnet']}/{prefix}",
"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}],
"reservations": kea_reservations,
}
],
}
}
def _dhcp_run_privileged(args, timeout):
"""Führt einen Installations-/Dienst-Befehl aus und fasst das Ergebnis
einheitlich zusammen — wirft nie, damit der aufrufende Code immer eine
Flash-Meldung statt eines 500ers bekommt."""
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0, (result.stdout or "") + (result.stderr or "")
except Exception as e:
return False, str(e)
@app.route("/settings/dhcp", methods=["GET", "POST"])
@@ -1327,43 +1465,156 @@ def settings_dhcp():
log_action("settings.update", "DHCP-Konfiguration")
flash("DHCP-Konfiguration gespeichert.", "success")
elif "add_dhcp_option" in request.form:
code_raw = request.form.get("code", "").strip()
name = request.form.get("name", "").strip()
opt_type = request.form.get("type", "").strip()
description = request.form.get("description", "").strip()
initial_value = request.form.get("initial_value", "").strip()
if not code_raw.isdigit() or not (1 <= int(code_raw) <= 254):
flash("Code muss eine Zahl zwischen 1 und 254 sein.", "danger")
elif not re.match(r"^[a-z][a-z0-9-]*$", name):
flash("Name muss mit einem Kleinbuchstaben beginnen und darf nur a-z, 0-9 und - enthalten.", "danger")
elif opt_type not in DHCP_OPTION_TYPE_KEYS:
flash("Ungültiger Datentyp.", "danger")
else:
try:
cur = conn.execute(
"INSERT INTO dhcp_option_defs (code, name, type, description) VALUES (?, ?, ?, ?)",
(int(code_raw), name, opt_type, description or None),
)
if initial_value:
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?)",
(cur.lastrowid, initial_value),
)
conn.commit()
log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}")
flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success")
except sqlite3.IntegrityError:
flash("Code oder Name wird bereits verwendet.", "danger")
elif "delete_dhcp_option" in request.form:
option_def_id = request.form.get("delete_dhcp_option")
option_def = conn.execute("SELECT name FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone()
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,))
conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,))
conn.commit()
log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id)
flash("Option gelöscht.", "success")
elif "save_dhcp_option_global" in request.form:
option_def_id = request.form.get("option_def_id")
value = request.form.get("value", "").strip()
if value:
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?) "
"ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
(option_def_id, value),
)
else:
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=''", (option_def_id,))
conn.commit()
log_action("settings.dhcp_option_value", option_def_id, "global")
flash("Globaler Wert gespeichert.", "success")
elif "save_dhcp_device_options" in request.form:
mac = request.form.get("mac", "").strip()
for d in _dhcp_option_defs(conn):
value = request.form.get(f"opt_{d['id']}", "").strip()
if value:
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) "
"ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
(d["id"], mac, value),
)
else:
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=?", (d["id"], mac))
conn.commit()
log_action("settings.dhcp_option_value", mac, "Client-Override")
flash(f"DHCP-Optionen für {mac} gespeichert.", "success")
elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings()
reservations = _dhcp_reservation_candidates(conn)
content = _render_dhcp_config(cfg, reservations)
reservations, _ = _dhcp_reservation_candidates(conn, cfg)
option_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(conn)
content = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
output_path = cfg["dhcp_output_path"]
try:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
f.write(content + "\n")
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen")
flash(
f"{len(reservations)} Reservierungen nach {output_path} geschrieben. "
f"Der DHCP-Dienst übernimmt die Änderung erst nach einem manuellen "
f"„sudo systemctl reload isc-dhcp-server“ — das führt die App bewusst nicht automatisch aus.",
f"Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
f"das führt die App bewusst nicht automatisch mit aus.",
"success",
)
except OSError as e:
flash(f"Konnte Datei nicht schreiben: {e}", "danger")
elif "install_dhcp_package" in request.form:
ok_update, _ = _dhcp_run_privileged(["apt-get", "update"], timeout=60)
env_prefix = {"DEBIAN_FRONTEND": "noninteractive"}
try:
result = subprocess.run(
["apt-get", "install", "-y", DHCP_PACKAGE],
capture_output=True, text=True, timeout=180,
env={**os.environ, **env_prefix},
)
ok = result.returncode == 0
output_tail = "\n".join(((result.stdout or "") + (result.stderr or "")).strip().splitlines()[-10:])
except Exception as e:
ok, output_tail = False, str(e)
log_action("settings.dhcp_install", DHCP_PACKAGE, "erfolgreich" if ok else "fehlgeschlagen")
if ok:
flash(f"{DHCP_PACKAGE} installiert.", "success")
else:
flash(f"Installation fehlgeschlagen: {output_tail or 'unbekannter Fehler'}", "danger")
elif "dhcp_service_action" in request.form:
action = request.form.get("dhcp_service_action")
if action == "enable_restart":
ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen"))
if ok_enable and ok_restart:
flash(f"{DHCP_SERVICE} aktiviert und neu gestartet.", "success")
else:
flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger")
elif action == "stop":
ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15)
log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen"))
flash(f"{DHCP_SERVICE} gestoppt." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger")
conn.close()
return redirect(url_for("settings_dhcp"))
cfg = _load_dhcp_settings()
reservations = _dhcp_reservation_candidates(conn)
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, cfg)
option_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(conn)
skipped_count = conn.execute(
"SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND (mac IS NULL OR rpi_ip IS NULL)"
).fetchone()["n"]
conn.close()
status = _detect_dhcp_status()
preview = _render_dhcp_config(cfg, reservations)
preview = json.dumps(_render_kea_config(cfg, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
return render_template(
"settings_dhcp.html",
cfg=cfg,
status=status,
reservations=reservations,
skipped_count=skipped_count,
out_of_subnet_count=out_of_subnet_count,
preview=preview,
option_defs=option_defs,
option_values=option_values,
option_types=DHCP_OPTION_TYPES,
dhcp_package=DHCP_PACKAGE,
dhcp_service=DHCP_SERVICE,
)