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:
+178
-6
@@ -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():
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user