DHCP: Auto-Write+Neustart nach Änderungen, Lease löschen, Dedup, Autostart-Slider

- Automatisches Schreiben+Neustart: jede relevante Änderung (Reservierung,
  Option, Subnetz, globale Einstellung) ruft jetzt _dhcp_write_and_restart()
  direkt nach dem Speichern auf -- schreibt die Kea-Config neu und startet
  den Dienst neu, FALLS er gerade aktiv ist (kein ungefragtes Hochfahren
  eines bewusst gestoppten Dienstes). Ein manuelles "Datei schreiben" +
  "Neu starten" kann dadurch nicht mehr vergessen werden. Der bisherige
  "Speichern"-Button (write_dhcp_file) restartet jetzt ebenfalls
  automatisch statt nur zu schreiben.
- Leases-Übersicht dedupliziert: Keas memfile-Backend hängt bei jeder
  Verlängerung eine neue Zeile an, statt die alte zu ersetzen -- dieselbe
  Adresse stand oft dutzendfach in der CSV. _dhcp_read_leases() zeigt jetzt
  nur noch die jeweils neueste (per "expire") Zeile pro IP.
- Einzelne Leases löschen (neuer Button + Route): entfernt alle Zeilen
  einer Adresse aus der Lease-CSV und startet den Dienst neu, damit Kea sie
  beim Neuladen nicht aus dem Speicher zurückschreibt.
- Eigenständiger Autostart-Slider (wie beim Aktivieren/Deaktivieren von
  Clients) -- steuert nur systemctl enable/disable, entkoppelt vom
  aktuellen Start/Stopp-Zustand.
- KRITISCH, live gefunden und behoben: die erste Version von
  _dhcp_delete_lease() hat Kea beim Neustart zum Absturz gebracht --
  Pythons csv-Modul schreibt standardmäßig "\r\n"-Zeilenenden, Kea erwartet
  aber reines "\n" und lehnte die Datei mit "invalid column: 'pool_id^M'"
  komplett ab. Zusätzlich hätte das Neuanlegen der Datei als root (der
  App-Prozess) sie root:root statt dem Kea-Dienstnutzer gehören lassen,
  wodurch Kea keine neuen Leases mehr hätte schreiben können. Beides
  gefixt (lineterminator="\n", Eigentümer/Rechte der Originaldatei nach dem
  Schreiben wiederhergestellt) und auf POETEST bestätigt: Dienst übersteht
  den Neustart jetzt, Datei bleibt _kea:_kea, keine ^M mehr.
This commit is contained in:
2026-08-13 19:11:34 +02:00
parent 4c13127679
commit 93bcf4f6b6
2 changed files with 220 additions and 10 deletions
+178 -6
View File
@@ -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, timedelta from datetime import datetime, timedelta
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
import paramiko import paramiko
import yaml import yaml
import ssl import ssl
@@ -3285,7 +3285,14 @@ def _dhcp_read_leases():
except OSError: except OSError:
return None return None
now = int(time.time()) now = int(time.time())
leases = [] # Kea's memfile-Backend HÄNGT bei jeder Verlängerung eine neue Zeile an,
# statt die alte zu ersetzen -- dieselbe Adresse steht oft dutzendfach
# in der Datei, mit jeweils weiter in die Zukunft geschobenem "expire".
# Da Kea eine Lease schon bei ~50% der Laufzeit erneuert (nicht erst bei
# Ablauf), sind kurz nach einer Verlängerung sowohl die alte als auch
# die neue Zeile noch "nicht abgelaufen" -- ohne Deduplizierung nach
# Adresse würde dieselbe Lease doppelt in der Übersicht auftauchen.
by_ip = {}
for row in rows: for row in rows:
# state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt), # state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt),
# 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist # 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist
@@ -3300,8 +3307,16 @@ def _dhcp_read_leases():
# noch nicht aus der CSV entfernt — nicht als aktiv anzeigen. # noch nicht aus der CSV entfernt — nicht als aktiv anzeigen.
if expire and expire < now: if expire and expire < now:
continue continue
ip = row.get("address") or ""
existing = by_ip.get(ip)
if existing is not None and (existing.get("expire") or 0) >= expire:
continue # bereits eine neuere (oder gleich neue) Zeile für diese IP gesehen
by_ip[ip] = {"expire": expire, "row": row}
leases = []
for ip, entry in by_ip.items():
row, expire = entry["row"], entry["expire"]
leases.append({ leases.append({
"ip": row.get("address") or "", "ip": ip,
"mac": (row.get("hwaddr") or "").upper(), "mac": (row.get("hwaddr") or "").upper(),
"hostname": row.get("hostname") or "", "hostname": row.get("hostname") or "",
# Absolut UND als Epoch-ms für einen live tickenden Countdown im # Absolut UND als Epoch-ms für einen live tickenden Countdown im
@@ -3320,6 +3335,58 @@ def _dhcp_read_leases():
return leases return leases
def _dhcp_delete_lease(ip):
"""Entfernt ALLE Zeilen einer Adresse direkt aus der Kea-Lease-CSV
(memfile-Backend, siehe _dhcp_read_leases für dieselbe Datei) und
startet den laufenden Dienst neu, damit Kea die Datei beim Hochfahren
frisch neu einliest -- ein bloßes Editieren bei laufendem Dienst würde
sonst von Keas eigenem, periodischem Lease-File-Cleanup irgendwann
wieder mit dem alten Stand aus dem Speicher überschrieben. Startet den
Dienst nur neu, wenn er gerade aktiv ist (kein ungefragtes Hochfahren
eines bewusst gestoppten Dienstes). Gibt (ok, message) zurück."""
try:
original_stat = os.stat(KEA_LEASE_FILE)
with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
rows = list(reader)
except OSError:
return False, "Keine Lease-Datei gefunden."
remaining = [r for r in rows if (r.get("address") or "") != ip]
if len(remaining) == len(rows):
return False, f"Keine Lease für {ip} in der Datei gefunden."
try:
# lineterminator="\n" ist entscheidend: der csv-Modul-Default ist
# "\r\n" (RFC 4180), Keas eigener Writer schreibt aber reines "\n" --
# ohne diese explizite Angabe interpretiert Kea beim nächsten Start
# das "\r" als Teil des letzten Spaltennamens ("pool_id\r") und
# lehnt die ganze Datei mit "invalid column" ab (live reproduziert).
with open(KEA_LEASE_FILE, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, lineterminator="\n")
writer.writeheader()
writer.writerows(remaining)
# Diese App läuft als root, öffnet die Datei aber mit "w" -- das
# legt sie neu an und würde sie root:root statt dem Kea-Dienstnutzer
# gehören lassen. Kea (i.d.R. eigener Systemnutzer, z.B. "_kea")
# kann dann zwar noch lesen, aber keine neuen Leases mehr
# hineinschreiben. Eigentümer/Rechte der Originaldatei deshalb
# explizit wiederherstellen (live reproduziert).
os.chown(KEA_LEASE_FILE, original_stat.st_uid, original_stat.st_gid)
os.chmod(KEA_LEASE_FILE, stat.S_IMODE(original_stat.st_mode))
except OSError as e:
return False, f"Lease-Datei konnte nicht geschrieben werden: {e}"
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
active = status.stdout.strip() == "active"
except Exception:
active = False
if active:
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
if not ok_restart:
return False, f"Lease für {ip} aus der Datei entfernt, Neustart des Dienstes fehlgeschlagen: {restart_out}"
return True, f"Lease für {ip} gelöscht."
def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values): def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values):
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON- """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-
serialisierbar) ein subnet4-Block je konfiguriertem Subnetz, damit ein serialisierbar) ein subnet4-Block je konfiguriertem Subnetz, damit ein
@@ -3452,6 +3519,49 @@ def _current_timezone():
return out.strip() if ok else None return out.strip() if ok else None
def _dhcp_write_and_restart(conn):
"""Schreibt die Kea-Konfiguration aus dem aktuellen DB-Stand neu und
startet den Dienst neu, FALLS er gerade aktiv ist -- aufgerufen direkt
nach jeder Änderung, die sich auf die geschriebene Konfiguration
auswirkt (Reservierung, Option, Subnetz, globale Einstellung), damit
eine Änderung sofort wirksam wird und ein manuelles "Datei schreiben" +
"Neu starten" nicht mehr vergessen werden kann. Schreibt nur, wenn
mindestens ein gültiges Subnetz konfiguriert ist (sonst gäbe es nichts
Sinnvolles zu schreiben); startet nur neu, wenn der Dienst bereits
läuft -- ein bewusst gestoppter Dienst wird dadurch nicht ungefragt
wieder hochgefahren. Flasht nur bei einem tatsächlichen Fehler etwas
Zusätzliches, ein stiller Erfolg reiht sich in die ohnehin schon
gezeigte Erfolgsmeldung der jeweiligen Aktion ein."""
cfg = _load_dhcp_settings()
valid_subnets, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
if not valid_subnets:
return
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, valid_subnets, reservations_by_subnet, 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 + "\n")
except OSError as e:
flash(f"Konfiguration konnte nicht automatisch geschrieben werden: {e}", "danger")
return
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
active = status.stdout.strip() == "active"
except Exception:
active = False
if active:
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
if not ok_restart:
flash(f"Konfiguration geschrieben, Neustart des DHCP-Dienstes fehlgeschlagen: {restart_out}", "danger")
@app.route("/settings/dhcp", methods=["GET", "POST"]) @app.route("/settings/dhcp", methods=["GET", "POST"])
@login_required @login_required
def settings_dhcp(): def settings_dhcp():
@@ -3477,6 +3587,7 @@ def settings_dhcp():
set_setting(key, value) set_setting(key, value)
log_action("settings.update", "DHCP-Konfiguration") log_action("settings.update", "DHCP-Konfiguration")
flash("DHCP-Konfiguration gespeichert.", "success") flash("DHCP-Konfiguration gespeichert.", "success")
_dhcp_write_and_restart(conn)
elif "add_dhcp_subnet" in request.form: elif "add_dhcp_subnet" in request.form:
interface = request.form.get("interface", "").strip() interface = request.form.get("interface", "").strip()
@@ -3500,6 +3611,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_subnet_add", interface, f"{range_start}{range_end}") log_action("settings.dhcp_subnet_add", interface, f"{range_start}{range_end}")
flash(f"Subnetz {range_start}{range_end} auf „{interface}“ hinzugefügt.", "success") flash(f"Subnetz {range_start}{range_end} auf „{interface}“ hinzugefügt.", "success")
_dhcp_write_and_restart(conn)
elif "edit_dhcp_subnet" in request.form: elif "edit_dhcp_subnet" in request.form:
subnet_id = request.form.get("edit_dhcp_subnet") subnet_id = request.form.get("edit_dhcp_subnet")
@@ -3522,6 +3634,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_subnet_edit", interface, f"{range_start}{range_end}") log_action("settings.dhcp_subnet_edit", interface, f"{range_start}{range_end}")
flash("Subnetz aktualisiert.", "success") flash("Subnetz aktualisiert.", "success")
_dhcp_write_and_restart(conn)
elif "delete_dhcp_subnet" in request.form: elif "delete_dhcp_subnet" in request.form:
subnet_id = request.form.get("delete_dhcp_subnet") subnet_id = request.form.get("delete_dhcp_subnet")
@@ -3529,6 +3642,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_subnet_delete", subnet_id) log_action("settings.dhcp_subnet_delete", subnet_id)
flash("Subnetz gelöscht.", "success") flash("Subnetz gelöscht.", "success")
_dhcp_write_and_restart(conn)
elif "toggle_dhcp_subnet" in request.form: elif "toggle_dhcp_subnet" in request.form:
subnet_id = request.form.get("toggle_dhcp_subnet") subnet_id = request.form.get("toggle_dhcp_subnet")
@@ -3543,6 +3657,7 @@ def settings_dhcp():
"Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.", "Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.",
"success", "success",
) )
_dhcp_write_and_restart(conn)
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()
@@ -3570,6 +3685,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}") log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}")
flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success") flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success")
_dhcp_write_and_restart(conn)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
flash("Code oder Name wird bereits verwendet.", "danger") flash("Code oder Name wird bereits verwendet.", "danger")
@@ -3584,6 +3700,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id) log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id)
flash("Option gelöscht.", "success") flash("Option gelöscht.", "success")
_dhcp_write_and_restart(conn)
elif "save_dhcp_option_global" in request.form: elif "save_dhcp_option_global" in request.form:
option_def_id = request.form.get("option_def_id") option_def_id = request.form.get("option_def_id")
@@ -3599,6 +3716,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_option_value", option_def_id, "global") log_action("settings.dhcp_option_value", option_def_id, "global")
flash("Globaler Wert gespeichert.", "success") flash("Globaler Wert gespeichert.", "success")
_dhcp_write_and_restart(conn)
elif "save_dhcp_device_options" in request.form: elif "save_dhcp_device_options" in request.form:
mac = request.form.get("mac", "").strip() mac = request.form.get("mac", "").strip()
@@ -3615,6 +3733,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_option_value", mac, "Client-Override") log_action("settings.dhcp_option_value", mac, "Client-Override")
flash(f"DHCP-Optionen für {mac} gespeichert.", "success") flash(f"DHCP-Optionen für {mac} gespeichert.", "success")
_dhcp_write_and_restart(conn)
elif "add_dhcp_reservation" in request.form: elif "add_dhcp_reservation" in request.form:
mac = request.form.get("mac", "").strip().upper() mac = request.form.get("mac", "").strip().upper()
@@ -3643,6 +3762,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})") log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})")
flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success") flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success")
_dhcp_write_and_restart(conn)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger") flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger")
@@ -3653,6 +3773,7 @@ def settings_dhcp():
conn.commit() conn.commit()
log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id) log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id)
flash("Manuelle Reservierung gelöscht.", "success") flash("Manuelle Reservierung gelöscht.", "success")
_dhcp_write_and_restart(conn)
elif "write_dhcp_file" in request.form: elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings() cfg = _load_dhcp_settings()
@@ -3682,15 +3803,25 @@ def settings_dhcp():
"settings.dhcp_write", output_path, "settings.dhcp_write", output_path,
f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen", f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen",
) )
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
active = status.stdout.strip() == "active"
except Exception:
active = False
restart_ok, restart_out = (True, "")
if active:
restart_ok, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
msg = ( msg = (
f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach " 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"{output_path} geschrieben"
f"(Button unten) — das führt die App bewusst nicht automatisch mit aus." + (" und Dienst neu gestartet." if active and restart_ok else "." if not active else "")
) )
if active and not restart_ok:
msg += f" Neustart des Dienstes fehlgeschlagen: {restart_out}"
if invalid_subnets: if invalid_subnets:
skipped = ", ".join(f"{s['interface']} ({s['range_start']}{s['range_end']})" for s, _ in 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}." msg += f" Übersprungen, da zu keinem echten Netz passend: {skipped}."
flash(msg, "success") flash(msg, "success" if (not active or restart_ok) else "danger")
except OSError as e: except OSError as e:
flash(f"Konnte Datei nicht schreiben: {e}", "danger") flash(f"Konnte Datei nicht schreiben: {e}", "danger")
@@ -3802,6 +3933,47 @@ def settings_dhcp():
) )
@app.route("/settings/dhcp/lease/delete", methods=["POST"])
@login_required
def delete_dhcp_lease():
if not current_user.has_permission("settings_dhcp.edit"):
flash("Keine Berechtigung, Leases zu löschen.", "danger")
return redirect(url_for("settings_dhcp"))
ip = request.form.get("ip", "").strip()
ok, message = _dhcp_delete_lease(ip)
log_action("settings.dhcp_lease_delete", ip, message)
flash(message, "success" if ok else "danger")
return redirect(url_for("settings_dhcp"))
@app.route("/settings/dhcp/autostart/toggle", methods=["POST"])
@login_required
def toggle_dhcp_autostart():
"""Eigenständiger Autostart-Schalter, entkoppelt vom aktuellen
Start/Stopp-Zustand -- steuert ausschließlich, ob systemd den Dienst
bei einem Host-Neustart automatisch mitstartet (systemctl enable/
disable), OHNE ihn hier auch tatsächlich zu (de)starten."""
if not current_user.has_permission("settings_dhcp.edit"):
return {"success": False, "msg": "Keine Berechtigung."}, 403
try:
result = subprocess.run(["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
currently_enabled = result.stdout.strip() == "enabled"
except Exception:
currently_enabled = False
action = "disable" if currently_enabled else "enable"
ok, out = _dhcp_run_privileged(["systemctl", action, DHCP_SERVICE], timeout=15)
if not ok:
verb = "deaktivieren" if currently_enabled else "aktivieren"
return {"success": False, "msg": f"Autostart konnte nicht {verb} werden: {out}"}
new_enabled = not currently_enabled
log_action("settings.dhcp_autostart", DHCP_SERVICE, "aktiviert" if new_enabled else "deaktiviert")
return {
"success": True,
"msg": f"Autostart {'aktiviert' if new_enabled else 'deaktiviert'}.",
"new_status": 1 if new_enabled else 0,
}
@app.route("/settings/ldap", methods=["GET", "POST"]) @app.route("/settings/ldap", methods=["GET", "POST"])
@login_required @login_required
def settings_ldap(): def settings_ldap():
+42 -4
View File
@@ -24,10 +24,17 @@
{% else %} {% else %}
<span class="pill unknown">Dienst-Status unbekannt</span> <span class="pill unknown">Dienst-Status unbekannt</span>
{% endif %} {% endif %}
{% if status.enabled is sameas true %}<span class="pill user">Autostart an</span>{% endif %} {% if not can_edit and status.enabled is sameas true %}<span class="pill user">Autostart an</span>{% endif %}
</div> </div>
{% if status.version %}<div class="text-faint mono" style="font-size:12px; margin-bottom:12px;">{{ status.version }}</div>{% endif %} {% if status.version %}<div class="text-faint mono" style="font-size:12px; margin-bottom:12px;">{{ status.version }}</div>{% endif %}
{% if can_edit %} {% if can_edit %}
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
<label class="switch-check">
<input type="checkbox" id="dhcpAutostartToggle" {% if status.enabled %}checked{% endif %} onchange="toggleDhcpAutostart(this)">
<span class="track"></span>
</label>
<span style="font-size:13px;">Autostart beim Hochfahren des Hosts</span>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;"> <div class="flex gap-2" style="flex-wrap:wrap;">
<form method="post" data-confirm="{{ dhcp_service }} aktivieren und (neu) starten? Übernimmt die zuletzt geschriebene Konfiguration."> <form method="post" data-confirm="{{ dhcp_service }} aktivieren und (neu) starten? Übernimmt die zuletzt geschriebene Konfiguration.">
<input type="hidden" name="dhcp_service_action" value="enable_restart"> <input type="hidden" name="dhcp_service_action" value="enable_restart">
@@ -76,7 +83,7 @@
</div> </div>
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label> <div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono"> <input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Nach dem Schreiben den Dienst per Button neu starten, damit die Änderung wirksam wird.</div> <div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
@@ -281,7 +288,7 @@
<div class="table-wrap"> <div class="table-wrap">
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table">
<thead><tr><th>IP-Adresse</th><th>MAC</th><th>Hostname</th><th>Läuft ab</th><th>Status</th></tr></thead> <thead><tr><th>IP-Adresse</th><th>MAC</th><th>Hostname</th><th>Läuft ab</th><th>Status</th>{% if can_edit %}<th style="width:1%;">Aktionen</th>{% endif %}</tr></thead>
<tbody> <tbody>
{% for l in leases %} {% for l in leases %}
<tr> <tr>
@@ -305,6 +312,16 @@
</span> </span>
{% endif %} {% endif %}
</td> </td>
{% if can_edit %}
<td>
<form method="post" action="{{ url_for('delete_dhcp_lease') }}" data-confirm="Lease {{ l.ip }} ({{ l.mac }}) löschen? Der DHCP-Dienst wird dabei kurz neu gestartet, falls er gerade läuft.">
<input type="hidden" name="ip" value="{{ l.ip }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Lease löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6h16z"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</form>
</td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -332,7 +349,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Reservierung hinzufügen Reservierung hinzufügen
</button> </button>
<form method="post" data-confirm="Konfiguration nach „{{ cfg.dhcp_output_path }}“ schreiben? Der Dienst übernimmt die Änderung erst nach einem Neustart."> <form method="post" data-confirm="Konfiguration nach „{{ cfg.dhcp_output_path }}“ schreiben? Läuft der Dienst gerade, wird er dabei automatisch neu gestartet, damit die Änderung sofort wirksam wird.">
<input type="hidden" name="write_dhcp_file" value="1"> <input type="hidden" name="write_dhcp_file" value="1">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
@@ -633,3 +650,24 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block scripts %}
<script>
function toggleDhcpAutostart(checkbox) {
checkbox.disabled = true;
fetch("{{ url_for('toggle_dhcp_autostart') }}", { method: "POST" })
.then(r => r.json())
.then(data => {
checkbox.disabled = false;
if (data.success) {
checkbox.checked = data.new_status === 1;
showToast(data.msg, "success");
} else {
checkbox.checked = !checkbox.checked;
showToast(data.msg, "danger");
}
})
.catch(() => { checkbox.disabled = false; checkbox.checked = !checkbox.checked; showToast("Fehler beim Umschalten.", "danger"); });
}
</script>
{% endblock %}