diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 08fa72f..347aab6 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -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, 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 yaml import ssl @@ -3285,7 +3285,14 @@ def _dhcp_read_leases(): except OSError: return None 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: # state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt), # 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. if expire and expire < now: 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({ - "ip": row.get("address") or "", + "ip": ip, "mac": (row.get("hwaddr") or "").upper(), "hostname": row.get("hostname") or "", # Absolut UND als Epoch-ms für einen live tickenden Countdown im @@ -3320,6 +3335,58 @@ def _dhcp_read_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): """Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON- serialisierbar) — ein subnet4-Block je konfiguriertem Subnetz, damit ein @@ -3452,6 +3519,49 @@ def _current_timezone(): 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"]) @login_required def settings_dhcp(): @@ -3477,6 +3587,7 @@ def settings_dhcp(): set_setting(key, value) log_action("settings.update", "DHCP-Konfiguration") flash("DHCP-Konfiguration gespeichert.", "success") + _dhcp_write_and_restart(conn) elif "add_dhcp_subnet" in request.form: interface = request.form.get("interface", "").strip() @@ -3500,6 +3611,7 @@ def settings_dhcp(): 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") + _dhcp_write_and_restart(conn) elif "edit_dhcp_subnet" in request.form: subnet_id = request.form.get("edit_dhcp_subnet") @@ -3522,6 +3634,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_subnet_edit", interface, f"{range_start}–{range_end}") flash("Subnetz aktualisiert.", "success") + _dhcp_write_and_restart(conn) elif "delete_dhcp_subnet" in request.form: subnet_id = request.form.get("delete_dhcp_subnet") @@ -3529,6 +3642,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_subnet_delete", subnet_id) flash("Subnetz gelöscht.", "success") + _dhcp_write_and_restart(conn) elif "toggle_dhcp_subnet" in request.form: 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.", "success", ) + _dhcp_write_and_restart(conn) elif "add_dhcp_option" in request.form: code_raw = request.form.get("code", "").strip() @@ -3570,6 +3685,7 @@ def settings_dhcp(): 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") + _dhcp_write_and_restart(conn) except sqlite3.IntegrityError: flash("Code oder Name wird bereits verwendet.", "danger") @@ -3584,6 +3700,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id) flash("Option gelöscht.", "success") + _dhcp_write_and_restart(conn) elif "save_dhcp_option_global" in request.form: option_def_id = request.form.get("option_def_id") @@ -3599,6 +3716,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_option_value", option_def_id, "global") flash("Globaler Wert gespeichert.", "success") + _dhcp_write_and_restart(conn) elif "save_dhcp_device_options" in request.form: mac = request.form.get("mac", "").strip() @@ -3615,6 +3733,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_option_value", mac, "Client-Override") flash(f"DHCP-Optionen für {mac} gespeichert.", "success") + _dhcp_write_and_restart(conn) elif "add_dhcp_reservation" in request.form: mac = request.form.get("mac", "").strip().upper() @@ -3643,6 +3762,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})") flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success") + _dhcp_write_and_restart(conn) except sqlite3.IntegrityError: flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger") @@ -3653,6 +3773,7 @@ def settings_dhcp(): conn.commit() log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id) flash("Manuelle Reservierung gelöscht.", "success") + _dhcp_write_and_restart(conn) elif "write_dhcp_file" in request.form: cfg = _load_dhcp_settings() @@ -3682,15 +3803,25 @@ def settings_dhcp(): "settings.dhcp_write", output_path, 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 = ( 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." + f"{output_path} geschrieben" + + (" 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: 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") + flash(msg, "success" if (not active or restart_ok) else "danger") except OSError as e: 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"]) @login_required def settings_ldap(): diff --git a/srv/tesm/templates/settings_dhcp.html b/srv/tesm/templates/settings_dhcp.html index bcec094..3e398b3 100644 --- a/srv/tesm/templates/settings_dhcp.html +++ b/srv/tesm/templates/settings_dhcp.html @@ -24,10 +24,17 @@ {% else %} Dienst-Status unbekannt {% endif %} - {% if status.enabled is sameas true %}Autostart an{% endif %} + {% if not can_edit and status.enabled is sameas true %}Autostart an{% endif %} {% if status.version %}