# TESM — Knowledge Base (aus dem Code entfernte Kommentare)

Diese Datei sammelt alle erklärenden `#`/`//`/`{# #}`/`<!-- -->`-Kommentare,
die am 2026-08-14 aus dem Code entfernt wurden, um den Code selbst schlank
zu halten. Docstrings (`"""..."""`) wurden NICHT angetastet und stehen
weiterhin direkt im Code.

Organisiert nach Datei, innerhalb von `app.py`/`create_db.py` zusätzlich
nach der Funktion, in der der Kommentar stand (Zeilennummer bezieht sich
auf den Stand VOR dem Entfernen, siehe Git-Historie).

---

## app.py

### Modulebene
- **Zeile 29** (srv/tesm/app.py): LDAP_MATCHING_RULE_IN_CHAIN — Microsofts OID für rekursive Gruppen- mitgliedschaft (löst auch verschachtelte Gruppen auf), statt nur das flache, nicht-rekursive memberOf-Attribut eines Benutzers zu lesen.

### `def patched_init`
- **Zeile 81** (srv/tesm/app.py): Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults passend zum produktiven Deployment unter /srv/tesm)
- **Zeile 91** (srv/tesm/app.py): Alle App-eigenen Logs leben unter einem gemeinsamen Verzeichnis statt verstreut in /var/log — vier getrennte Dateien, damit Live-Check, Änderungen, App-Fehler und Kea-DHCP sich nicht gegenseitig zutexten. Das Verzeichnis wird bewusst weltweit beschreibbar angelegt (siehe unten): tesm.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R. als eigener, unprivilegierter Systembenutzer (_kea) — beide müssen in dasselbe Verzeichnis schreiben können, ohne dass wir hier exakt den richtigen UID/GID-Abgleich nachbilden müssen.
- **Zeile 103** (srv/tesm/app.py): NICHT unter TESM_LOG_DIR: kea-dhcp4s AppArmor-Profil (/etc/apparmor.d/usr.sbin.kea-dhcp4) erlaubt Schreibzugriff exklusiv auf genau diesen Pfad (Dateiname hart einprogrammiert, kein Verzeichnis- Wildcard) — ein anderer Pfad wird mit "DENIED ... mknod ... kea-dhcp.log.lock" abgelehnt (live so reproduziert). Bewusst NICHT das AppArmor-Profil aufgeweicht, um die Sandbox eines Systemdienstes auf einem geteilten Host nicht zu schwächen — die App arbeitet stattdessen mit Keas eigenem, bereits erlaubtem Pfad.
- **Zeile 118** (srv/tesm/app.py): Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall explizit eingetragen werden muss.
- **Zeile 124** (srv/tesm/app.py): Zugangsdaten-Kategorien — legt der Admin explizit fest (siehe _ensure_schema()-Kommentar für die Begründung gegen automatische Ping-basierte Erkennung). Nur "linux" ist aktuell für eine Aktion nutzbar (SSH-Update unter "Wartung"); die anderen sind reine Einordnung, z.B. für spätere Erweiterungen (Windows/PowerShell folgt separat).
- **Zeile 136** (srv/tesm/app.py): Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird.
- **Zeile 142** (srv/tesm/app.py): Eigene, rotierte Datei für App-seitige Fehler/Warnungen ("Syslog"-Äquivalent der App) — vorher landete das nur im journalctl-Output von tesm.service, ohne eigene, von der App aus kontrollierbare Aufbewahrung. Bewusst nur WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose, kein Access-Log.

### `def _load_or_create_fernet`
- **Zeile 192** (srv/tesm/app.py): Rechtesystem (Gruppen): Admins (is_admin=1) dürfen immer alles — inkl. Benutzer-/Gruppen-/Settingsverwaltung, die es nur für Admins gibt. Für normale Benutzer lassen sich einzelne Verwaltungsrechte über Gruppen gezielt vergeben, ohne sie zu Admins zu machen.
- **Zeile 197** (srv/tesm/app.py): Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte (Spalten in der Gruppen-Tabelle). Jeder Unterpunkt liefert "rows": eine Teilmenge der vier kanonischen Rechte-Zeilen (view/create/edit/delete = L/S/Ä/D, siehe PERMISSION_ROW_TYPES) — fehlt eine Zeile für einen Unterpunkt (z.B. "delete" bei Systemeinstellungen), wird die Zelle in der Tabelle grau und nicht setzbar dargestellt statt einfach zu fehlen, damit alle Spalten eines Bereichs bündig nebeneinander in einer kompakten Tabelle stehen.
- **Zeile 221** (srv/tesm/app.py): "Ändern" deckt Löschen überall mit ab, auch hier — es gibt nirgends ein eigenes Löschen-Recht. Die vierte Spalte (D) ist bei Clients KEIN Löschen, sondern das Recht, einen PoE-Neustart über das Dashboard-Popup auszulösen (automatische Neustarts bei Ausfall bleiben davon unberührt).
- **Zeile 235** (srv/tesm/app.py): Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab.
- **Zeile 252** (srv/tesm/app.py): Kein eigenes Löschen — "Ändern" (E) steht hier für das Ausführen von Wartungsaktionen (SSH-Update, SSH-Neustart), nicht für Löschen. Getrennt von devices.restart (D oben): das ist ausschließlich der PoE-Neustart über den Switch.
- **Zeile 298** (srv/tesm/app.py): R = Seite ansehen (wie überall). E = Import ausführen (verändert Bestand, passt zu "Ändern"). X = Export ausführen (eigene Zeile, NICHT an E gekoppelt) -- die Export-Datei enthält Passwörter im Klartext, daher bewusst als eigenständiges, separat vergebbares Recht.
- **Zeile 311** (srv/tesm/app.py): L = Konfiguration/Status/Vorschau ansehen, Ä = Konfiguration speichern und die generierte Reservierungsdatei schreiben.
- **Zeile 323** (srv/tesm/app.py): Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt.
- **Zeile 354** (srv/tesm/app.py): Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht gesetzt, greift KEIN darunterliegendes Recht mehr — auch wenn es einzeln gewährt wurde (Kill-Switch, siehe User.has_permission). So lässt sich z.B. der gesamte "Geräte"-Bereich für eine Gruppe mit einem Klick sperren, ohne jedes Kind-Recht einzeln zurücknehmen zu müssen.
- **Zeile 370** (srv/tesm/app.py): Welche Spalten (R/W/E/D) für einen Bereich überhaupt vorkommen — z.B. hat nur "Geräte" (wegen Clients' Dashboard-Neustart) eine D-Spalte, Logs/ Einstellungen brauchen sie gar nicht erst anzuzeigen. Auf der Gruppen- Seite bekommt so jeder Bereich nur die Spalten, die er tatsächlich nutzt, statt überall gleich viele (teils komplett ungenutzte) Spalten zu zeigen.
- **Zeile 389** (srv/tesm/app.py): Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar (siehe /account, gespeichert als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.

### `def can_view_settings_importexport`
- **Zeile 648** (srv/tesm/app.py): Jedes der drei Rechte (Seite ansehen/Import/Export) muss für sich allein zu Seiten-/Nav-Sichtbarkeit führen -- sonst wäre z.B. ein Nutzer mit ausschließlich dem Export-Recht (ohne .view) nie in der Lage, die Seite überhaupt zu erreichen, um sein Recht zu nutzen.

### `def get_db_connection`
- **Zeile 686** (srv/tesm/app.py): Kuratierte Standard-DHCPv4-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) — vorbefüllt in _ensure_schema(), siehe dort für die Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen. (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/ Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte Felder und deshalb bewusst nicht enthalten. Client-/Protokoll-interne Optionen (message-type, parameter-request-list, requested-address, client-identifier, server-identifier, ...) ebenfalls bewusst nicht enthalten — die sind serverseitig nicht sinnvoll setzbar (auch Windows Server listet sie nicht unter "Predefined Options"). "host-name" (12) ebenfalls bewusst NICHT enthalten: Kea beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch mit deren eigenem "hostname"-Feld (aus dem Namen der Reservierung abgeleitet, siehe _dhcp_safe_hostname) — ein zusätzlicher option-data-Override für dieselbe Option in derselben Reservierung wäre eine mit der Kea-Doku belegte Dopplung/Quelle für Widersprüche, kein zusätzlicher Nutzen. Alle restlichen Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln).

### `def _ensure_schema`
- **Zeile 770** (srv/tesm/app.py): Für den Migrations-Einmal-Guard weiter unten (settings existiert normalerweise schon durch create_db.py, hier nur zur Absicherung defensiv angelegt).
- **Zeile 792** (srv/tesm/app.py): Migration: 'is_default'-/'is_system'-Spalte nachrüsten, falls die Tabelle noch aus einer älteren Version ohne diese Spalten stammt. is_system sperrt Name UND Rechte einer Gruppe komplett gegen Änderungen über die UI (aktuell nur die Standardgruppe 'Benutzer') — getrennt von is_default (steuert nur die automatische Zuordnung neuer Benutzer), damit künftig auch andere, nicht-default Systemgruppen denkbar wären.
- **Zeile 804** (srv/tesm/app.py): Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten.
- **Zeile 812** (srv/tesm/app.py): 'local' (Passwort in dieser DB, bcrypt) oder 'ldap' (Konto stammt aus einem beim ersten erfolgreichen LDAP/AD-Login automatisch angelegten Eintrag -- das Passwort wird NIE lokal geprüft, siehe _ldap_authenticate() bzw. den Login-Handler; das gespeicherte "password" ist dafür nur ein nie verwendeter Platzhalter-Hash, um die NOT-NULL-Spalte zu befüllen).
- **Zeile 819** (srv/tesm/app.py): E-Mail-Adresse — für lokale Konten frei editierbar (Login zusätzlich per E-Mail statt nur Benutzername möglich), für LDAP/AD-Konten aus dem "mail"-Attribut übernommen und bei jedem Login aufgefrischt.
- **Zeile 824** (srv/tesm/app.py): Sperrt einen Login unabhängig vom Passwort/AD-Status -- der einzige Weg, einem AD-Konto den Zugriff wirklich zu entziehen, ohne es in AD selbst zu deaktivieren (ein simples Löschen des lokalen Schatten- Kontos reicht dafür NICHT: bei erneuter gültiger AD-Anmeldung würde es sonst automatisch neu angelegt).
- **Zeile 832** (srv/tesm/app.py): Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt das alte (siehe save_ldap-Handler: INSERT ... ON CONFLICT DO UPDATE).
- **Zeile 847** (srv/tesm/app.py): AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer App-Gruppe zugeordnet werden. Bei Login/Provisionierung wird geprüft, in welchen dieser AD-Gruppen ein Benutzer (auch über verschachtelte Gruppen) Mitglied ist, und die entsprechenden App-Gruppen werden ihm zugewiesen (additiv, siehe _ldap_resolve_app_groups) -- passt zu keine dieser Gruppen, greift die Standardgruppe (ldap_default_group).
- **Zeile 863** (srv/tesm/app.py): Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne PoE-Neustarts (die stehen bereits im Live-Log von poe.sh).
- **Zeile 876** (srv/tesm/app.py): Migration: rpi_ip -> ip (Namensrelikt aus einer früheren, Raspberry-Pi- spezifischen Version -- Geräte sind längst nicht mehr auf Pis beschränkt). RENAME COLUMN erhält bestehende Werte unverändert.
- **Zeile 884** (srv/tesm/app.py): "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log.
- **Zeile 890** (srv/tesm/app.py): Zugangsdaten (Credentials): eigenständige, wiederverwendbare SSH-Logins für Switche, statt Username/Passwort direkt am Switch.
- **Zeile 901** (srv/tesm/app.py): Kategorie (Switch/Linux-Client/Windows-Client/Router-Sonstiges) — bestimmt u.a., ob für ein Gerät mit diesen Zugangsdaten die SSH-Update-Aktion unter "Wartung" angeboten wird (siehe DEVICE_MAINTENANCE_CATEGORY). Bewusst KEINE automatische Erkennung per Ping o.ä. — ein reiner TTL-Hinweis ist keine verlässliche Grundlage dafür, unbeaufsichtigt Systembefehle auszuführen; der Admin legt die Kategorie explizit fest.
- **Zeile 918** (srv/tesm/app.py): SSH-Port je Switch (falls nicht Standard 22) — NULL/leer bedeutet überall "22 verwenden" (siehe SWITCH_DEFAULT_SSH_PORT-Fallback).
- **Zeile 923** (srv/tesm/app.py): Optionale SSH-Anbindung eines Clients (devices) — analog zu Switchen, damit z.B. ein Linux-Client für die Wartungs-/Update-Aktion erreichbar ist. NULL/kein credential_id = kein SSH-Zugriff für dieses Gerät.
- **Zeile 932** (srv/tesm/app.py): Papierkorb: "Löschen" setzt für Clients/Switche/Zugangsdaten/lokale Benutzer/Custom-Gruppen nur noch deleted_at statt die Zeile wirklich zu entfernen -- Wiederherstellen setzt es zurück auf NULL. Endgültiges Löschen (manuell über den Papierkorb oder automatisch nach Ablauf der Aufbewahrungsfrist, siehe trash_retention_days) führt DANN erst das echte DELETE aus. AD/LDAP-Benutzer durchlaufen den Papierkorb nicht (siehe users()-Route delete_user) -- ihr Konto wird beim nächsten gültigen Login ohnehin automatisch neu angelegt.
- **Zeile 945** (srv/tesm/app.py): DHCP: eigene Options-Definitionen + Werte (global/per-Client, siehe create_db.py für die ausführliche Begründung von device_mac='').
- **Zeile 957** (srv/tesm/app.py): Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus einer Version ohne vorbefüllte Standard-Optionen stammt.
- **Zeile 972** (srv/tesm/app.py): Einmalig eine kuratierte Auswahl an Standard-DHCP-Optionen vorbefüllen (analog den "Predefined Options" eines Windows-DHCP-Servers) — Router/ DNS-Server/Domain-Name/Lease-Zeiten sind bereits eigene, dedizierte Felder (siehe dhcp_subnets/DHCP_DEFAULTS) und deshalb hier bewusst ausgenommen. Alle Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t) — Kea kennt diese Standard-Optionen bereits nativ, sie bekommen deshalb (anders als eigene Options) KEIN eigenes option-def in der generierten Konfiguration (siehe _render_kea_config), sonst würde die eingebaute Definition dupliziert. Per Guard nur einmalig, damit ein bewusst vom Admin gelöschter Eintrag nach einem Neustart nicht stillschweigend wieder auftaucht.
- **Zeile 992** (srv/tesm/app.py): v2: Liste um weitere verifizierte Standard-Optionen erweitert — eigener Guard, damit bereits auf v1 migrierte Installationen die neu hinzugekommenen Optionen ebenfalls bekommen, ohne die gesamte Seed-Logik erneut über alle Zeilen laufen zu lassen.
- **Zeile 1005** (srv/tesm/app.py): v3: "host-name" (12) als Standard-Option wieder entfernt — Kea beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch über deren eigenes "hostname"-Feld; ein zusätzlicher option-data-Override dafür ist laut Kea-Doku eine Dopplung/Quelle für Widersprüche (live an einer echten Reservierung nachvollzogen, wo beide gleichzeitig gesetzt waren). Löscht auch bereits gesetzte Werte für diese Option mit, da sie ohnehin nie sinnvoll gewirkt hätten.
- **Zeile 1021** (srv/tesm/app.py): DHCP: manuelle Reservierungen — im Unterschied zu den automatisch aus den Clients (devices-Tabelle) erzeugten Reservierungen für Geräte, die NICHT als Client in dieser App gepflegt werden (z.B. ein fremdes/ externes Gerät, dem bewusst und mit voller Kontrolle eine feste IP gegeben werden soll, statt es unüberwacht automatisch anzubinden). Können im Gegensatz zu automatischen Reservierungen gelöscht werden.
- **Zeile 1036** (srv/tesm/app.py): 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.
- **Zeile 1054** (srv/tesm/app.py): Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus einer Version ohne Deaktivieren-Funktion stammt.
- **Zeile 1059** (srv/tesm/app.py): 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.
- **Zeile 1086** (srv/tesm/app.py): Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten (ältere DB-Version) in eigene Credentials-Datensätze überführen.
- **Zeile 1107** (srv/tesm/app.py): Migration (granularer Rechtebaum): devices.toggle -> devices.edit, switches.* -> gespiegelte credentials.*, sowie Backfill des neuen "Bereich sichtbar"-Rechts für Gruppen mit bereits gesetzten Kind-Rechten. WICHTIG: nur EINMALIG (Guard über settings) — sonst würde das Bereichs-Recht bei jedem Neustart automatisch neu gesetzt und den Kill-Switch (siehe User.has_permission) wirkungslos machen.
- **Zeile 1171** (srv/tesm/app.py): Migration v2: "Löschen" als eigenes Recht wieder abgeschafft — "Ändern" deckt das Löschen jetzt überall mit ab (auch bei Clients, keine Ausnahme mehr). Gruppen, die bisher nur *.delete ohne *.edit hatten, würden sonst durch das Update ihr Löschen-Recht stillschweigend verlieren — deshalb erst *.edit nachtragen, dann die veralteten *.delete-Zeilen entfernen. Ebenfalls einmalig per Guard, aus demselben Grund wie bei v1 (kein Zurückschreiben bei jedem Neustart nötig, da *.delete als Recht komplett entfällt und nie wieder vorkommen kann).
- **Zeile 1203** (srv/tesm/app.py): Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu angelegten rpi-<timestamp>.log-Dateien unter /var/log sind seit der Umstellung auf eine einzige, per logrotate rotierte Datei (TESM_LIVE_LOG_PATH unter /var/log/tesm/) verwaist — poe.sh räumt sie nicht mehr selbst auf (das war vorher sein eigener 30-Tage-`find -delete`), und die neue logrotate-Konfiguration überwacht ein anderes Verzeichnis. Ohne diese Migration blieben sie für immer liegen.
- **Zeile 1220** (srv/tesm/app.py): Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in der UI ohnehin komplett gegen Umbenennen/Rechteänderung sperrt — Mitgliedschaft bleibt weiterhin frei verwaltbar.
- **Zeile 1239** (srv/tesm/app.py): Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen durch die Einführung des Rechtesystems keinen Zugriff verlieren).

### `def _build_user`
- **Zeile 1263** (srv/tesm/app.py): deleted_at IS NULL: Mitglied einer in den Papierkorb verschobenen Gruppe verliert deren Rechte sofort (nicht erst nach Wiederherstellen/ endgültigem Löschen) -- einziger Ort, der geändert werden muss, damit das app-weit für jede has_permission()-Prüfung greift.

### `def load_user`
- **Zeile 1442** (srv/tesm/app.py): deleted_at IS NULL: eine in den Papierkorb verschobene Benutzerin verliert damit sofort jede bereits laufende Sitzung, nicht erst beim nächsten Login-Versuch.
- **Zeile 1453** (srv/tesm/app.py): LDAP / Active Directory — optionale Anmeldung mit dem Domänen-Passwort zusätzlich zu lokalen Konten (siehe Systemeinstellungen → LDAP).
- **Zeile 1456** (srv/tesm/app.py): WICHTIG: das ist "gleiches Passwort" (Same Sign-On), KEIN automatisches, promptloses Single Sign-On -- der Benutzer tippt sein AD-Passwort weiterhin auf der Login-Seite ein, es wird nur live gegen AD statt gegen ein lokal gespeichertes Passwort geprüft. Echtes automatisches SSO bräuchte zusätzlich Kerberos/SPNEGO (eigenes SPN + Keytab vom AD-Admin) oder SAML/OIDC über Entra ID -- beides technisch klar unterschiedliche, deutlich aufwändigere Bausteine, die sich später darauf aufsetzen ließen.
- **Zeile 1464** (srv/tesm/app.py): Search+Bind-Muster statt eines direkten UPN-Binds: zuerst mit dem konfigurierten Service-Konto verbinden, den Benutzer per konfigurierbarem Attribut (Standard sAMAccountName, für generisches LDAP z.B. "uid") suchen, dann eine ZWEITE, separate Verbindung mit dessen exakter DN + dem eingegebenen Passwort aufbauen, um es zu prüfen. Das funktioniert unabhängig davon, ob sAMAccountName und der UPN-Präfix übereinstimmen, und damit auch mit generischem (Nicht-AD-)LDAP.

### `def _ldap_settings`
- **Zeile 1513** (srv/tesm/app.py): Fallback für Benutzer, die zu KEINER konfigurierten AD-Gruppen- Zuordnung passen (siehe ldap_group_mappings/_ldap_resolve_app_groups) -- leer = Systemstandardgruppe.

### `def _ldap_connect_service`
- **Zeile 1533** (srv/tesm/app.py): tls nur relevant, wenn use_ssl gesetzt ist -- ldap3 ignoriert es sonst ohnehin, aber so bleibt die Absicht im Code eindeutig.

### `def _attr`
- **Zeile 1633** (srv/tesm/app.py): ACCOUNTDISABLE-Bit, nur bei AD vorhanden
- **Zeile 1641** (srv/tesm/app.py): "E-Mail" meint hier bewusst userPrincipalName (z.B. "user@ad.firma.local"), nicht das separate "mail"-Attribut -- in AD ist der UPN die praxisübliche Login-/Kontaktadresse, "mail" ist oft gar nicht gepflegt (genau wie beim Test-Account, der zu dieser Entscheidung geführt hat) und wird deshalb hier gar nicht erst abgefragt.

### `def _ldap_group_mappings`
- **Zeile 1791** (srv/tesm/app.py): LEFT JOIN mit deleted_at-Filter: eine inzwischen in den Papierkorb verschobene (oder endgültig gelöschte) Rechtegruppe lässt die Zuordnung sichtbar als "(gelöscht)" statt sie stillschweigend so anzuzeigen, als würde sie noch normal funktionieren.

### `def _ldap_authenticate`
- **Zeile 1853** (srv/tesm/app.py): Welche App-Gruppen dem Benutzer laut AD-Gruppenmitgliedschaft zustehen (siehe ldap_group_mappings) -- ohne konfigurierte Zuordnung dürfen sich weiterhin alle Domänen-Benutzer anmelden (Standardgruppe greift dann beim Provisionieren).
- **Zeile 1879** (srv/tesm/app.py): Auth

### `def _assign_ldap_groups`
- **Zeile 1892** (srv/tesm/app.py): Zielgruppe könnte inzwischen in den Papierkorb verschoben oder sogar endgültig gelöscht worden sein -- dann keine (dann sinnlose oder gar auf nichts mehr zeigende) Mitgliedschaft eintragen.

### `def _ldap_default_group_id`
- **Zeile 1903** (srv/tesm/app.py): Konfigurierte Gruppe könnte inzwischen in den Papierkorb verschoben worden sein -- dann auf den Systemstandard zurückfallen, statt eine (dann möglicherweise sogar endgültig gelöschte) Gruppen-ID stumpf weiterzuverwenden.

### `def login`
- **Zeile 1924** (srv/tesm/app.py): 1) Exakter Benutzername-Treffer (Groß-/Kleinschreibung wie eingegeben) -- deckt lokale Konten UNVERÄNDERT wie bisher ab, und bereits per LDAP angelegte Konten, wenn der Benutzername konsistent geschrieben wird. deleted_at IS NULL bei allen drei Lookups: ein in den Papierkorb verschobenes lokales Konto darf sich nicht mehr anmelden UND soll als "nicht gefunden" behandelt werden (nicht z.B. mit einer extra Fehlermeldung, die verriete, dass der Name existiert).
- **Zeile 1936** (srv/tesm/app.py): 2) Kein exakter Treffer -- per E-Mail suchen (case-insensitiv). Deckt sowohl lokale Konten mit hinterlegter E-Mail als auch per LDAP angelegte Konten ab (deren E-Mail-Spalte den UPN enthält). Login-Logik umfasst damit genau vier Fälle: lokaler Benutzername, lokale E-Mail, AD sAMAccountName, AD userPrincipalName.
- **Zeile 1948** (srv/tesm/app.py): 3) Immer noch nichts -- unter den PER LDAP angelegten Konten zusätzlich case-insensitiv nach dem Benutzernamen suchen (AD-Benutzernamen sind case-insensitiv; ohne das könnte "JDoe" und "jdoe" sonst zwei verschiedene lokale Schatten-Konten erzeugen). Wirkt sich NIE auf lokale Konten aus.
- **Zeile 1960** (srv/tesm/app.py): Gesperrt (siehe Benutzerverwaltung) -- unabhängig von Passwort/AD-Status. Der einzige zuverlässige Weg, einem AD-Konto den Zugriff über die App wirklich zu entziehen, ohne es in AD selbst zu deaktivieren.
- **Zeile 1969** (srv/tesm/app.py): Passwort wird NIE lokal geprüft (der gespeicherte Hash ist nur ein nie verwendeter Platzhalter) -- bei JEDEM Login live gegen AD verifiziert, damit eine Passwortänderung in AD sofort greift, ohne dass hier etwas synchronisiert werden müsste.
- **Zeile 1975** (srv/tesm/app.py): Name/E-Mail bei jedem Login aus AD auffrischen (können sich dort ändern), und ggf. neu hinzugekommene AD-Gruppen- Zuordnungen zusätzlich zuweisen (nie etwas wegnehmen).
- **Zeile 1999** (srv/tesm/app.py): Kein lokales Konto mit diesem Namen/dieser E-Mail -- bei aktivierter LDAP-Anmeldung gegen AD prüfen (per Benutzername, UPN oder E-Mail, siehe _ldap_find_entry) und beim ersten erfolgreichen Login automatisch ein lokales Konto anlegen.

### `def logout`
- **Zeile 2044** (srv/tesm/app.py): Landet auf dem (auch ohne Login erreichbaren) Dashboard statt auf /login -- wer sich abmeldet, will i.d.R. trotzdem noch den Online/Offline-Status sehen können, ohne sich extra erneut einloggen zu müssen, nur um zur Übersicht zu gelangen.
- **Zeile 2052** (srv/tesm/app.py): Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"- Bereich, der andere Benutzer verwaltet. Die Navbar-Reihenfolge (siehe save_nav_order()) konfigurieren Admins unter Systemeinstellungen, nicht hier.

### `def profile`
- **Zeile 2083** (srv/tesm/app.py): Das gespeicherte Passwort ist nur ein nie geprüfter Platzhalter (siehe Login-Handler) -- der bcrypt-Vergleich unten würde ohnehin nie zutreffen, aber mit einer irreführenden "aktuelles Passwort falsch"-Meldung statt einer klaren Ansage.
- **Zeile 2132** (srv/tesm/app.py): Log-Auswertung (Erreichbarkeits-Status je Gerät)

### `def get_device_status`
- **Zeile 2290** (srv/tesm/app.py): Dashboard

### `def by_name`
- **Zeile 2319** (srv/tesm/app.py): für Kompatibilität/Zähler

### `def _restart_check_service`
- **Zeile 2367** (srv/tesm/app.py): z.B. lokale Entwicklungsumgebung ohne systemd

### `def run_check_now`
- **Zeile 2385** (srv/tesm/app.py): Kurz warten, bis der frisch gestartete Dienst den ersten Durchlauf ins neue Logfile geschrieben hat — dann kann die Antwort den aktuellen Zeitpunkt direkt mitliefern, ohne dass das Frontend dafür einen zweiten Request/Endpunkt braucht (Flask läuft threaded, blockiert also keine anderen Anfragen währenddessen).
- **Zeile 2400** (srv/tesm/app.py): Settings
- **Zeile 2404** (srv/tesm/app.py): System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht der Kea-DHCP-*Server* für Client-Geräte weiter unten). Eine falsche Änderung kann die Erreichbarkeit kappen, daher: Backend-Erkennung statt Annahme (unbekannt -> rein lesend), Backup vor jeder Änderung, und ein Sicherheits-Timer (analog "netplan try") der ohne explizite Bestätigung innerhalb von NETWORK_REVERT_SECONDS automatisch zurückrollt.
- **Zeile 2413** (srv/tesm/app.py): {"timer": Timer, "backend":..., "interface":..., "backup": {...}} Eigene netplan-Override-Datei (siehe _apply_network_config) — bewusst nicht die von cloud-init/dem Distributor mitgelieferte Datei, damit ein Rollback nie versehentlich fremden Inhalt überschreibt.

### `def _detect_network_backend`
- **Zeile 2432** (srv/tesm/app.py): Standard-Ubuntu (Server- wie Cloud-Images) verwaltet sein Netz inzwischen über netplan + systemd-networkd statt NetworkManager oder dhcpcd — mit Abstand der häufigste Fall auf einem "normalen" frisch installierten Ubuntu-Host (live an einer echten Ubuntu-24.04-VM verifiziert, die ihre IP per DHCP bezieht).

### `def _read_configured_dns`
- **Zeile 2470** (srv/tesm/app.py): Nur die erste Zeile trägt das "Link N (iface):"-Label; bei mehr Servern, als auf eine Zeile passen, folgen reine Fortsetzungszeilen OHNE Label -- ein naives split(":", 1) auf JEDER Zeile würde bei einer IPv6-Fortsetzungszeile (die selbst Doppelpunkte enthält) fälschlich deren Anfang abschneiden (live reproduziert: "2003:fe:7714:..." wurde zu "fe:7714:...").
- **Zeile 2485** (srv/tesm/app.py): Nur IPv4: diese Seite verwaltet ausschließlich die IPv4-Konfiguration des Hosts (IP-Adresse/Prefix/Gateway sind alles IPv4-Felder) — per IPv6-Router-Advertisement gelernte DNS-Server (die resolvectl ungefragt mit auflistet) gehören nicht zu dieser Konfiguration und wirken hier nur verwirrend/falsch neben den IPv4-Werten.

### `def _read_network_state`
- **Zeile 2533** (srv/tesm/app.py): Netplan/systemd-networkd markiert eine per DHCP bezogene Adresse im Kernel mit "dynamic" — zuverlässiger als die YAML-Dateien unter /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern).

### `def _backup_network_config`
- **Zeile 2566** (srv/tesm/app.py): Eigene Override-Datei sichern statt der von cloud-init/dem Distributor mitgelieferten Dateien — None heißt "existierte vorher nicht", dann löscht der Revert sie wieder statt sie leer zu lassen.

### `def _apply_network_config`
- **Zeile 2638** (srv/tesm/app.py): Ohne diesen Override würde networkd die eigenen DNS-Server mit den per DHCP gelieferten zusammen verwenden, statt sie wie bei den anderen Backends (ipv4.ignore-auto-dns / dhcpcd) vollständig zu ersetzen.
- **Zeile 2645** (srv/tesm/app.py): Bestehende Datei einlesen und nur den Block DIESES Interfaces ersetzen/ergänzen -- ein Host kann mehrere Interfaces haben, jedes über einen eigenen Aufruf dieser Seite konfiguriert. Ein kompletter Neuaufbau des Dokuments (wie ursprünglich) hätte bei jedem Speichern alle ANDEREN, bereits konfigurierten Interfaces aus der Datei geworfen -- deren Konfiguration fiele dadurch auf die Distributor-Datei zurück (i.d.R. DHCP), obwohl an ihnen gar nichts geändert wurde (live reproduziert: zweites Interface eingerichtet, erstes verlor dadurch seine statische IP).
- **Zeile 2662** (srv/tesm/app.py): Eigene, niedrig priorisierte Datei statt der von cloud-init mitgelieferten (z.B. 50-cloud-init.yaml) — netplan wendet Dateien in alphabetischer Reihenfolge an und lässt spätere Werte pro Schlüssel gewinnen, das ist hier der etablierte Weg für eigene Overrides statt die Distributor-Datei zu editieren.

### `def _revert_network_config`
- **Zeile 2701** (srv/tesm/app.py): Datei existierte vor der Änderung nicht (erste Netplan- Änderung über diese Seite) — dann muss sie beim Rollback auch wieder verschwinden statt leer zurückzubleiben.
- **Zeile 2709** (srv/tesm/app.py): Das Rollback ändert die Netzwerkkonfiguration erneut — falls zwischenzeitlich (z.B. manuell auf der DHCP-Seite) wieder gestartet, aus demselben Grund wie beim ursprünglichen Anwenden vorsorglich stoppen statt mit einer ggf. nicht mehr passenden Range weiterlaufen zu lassen.

### `def settings`
- **Zeile 2767** (srv/tesm/app.py): nur eine ausstehende Änderung gleichzeitig
- **Zeile 2770** (srv/tesm/app.py): Damit die Seite beim nächsten Laden das zuletzt bearbeitete statt immer nur das alphabetisch erste Interface zeigt -- gerade bei mehreren Interfaces sonst verwirrend, siehe net_interface-Fallback weiter unten.
- **Zeile 2780** (srv/tesm/app.py): Eine vorher passende DHCP-Range kann nach einer IP-/Subnetzänderung plötzlich zum falschen Netz gehören — der Dienst wird deshalb vorsorglich gestoppt statt (ggf. falsch) weiterzulaufen. Bewusst kein automatischer Neustart: die Range muss der Admin aktiv auf der DHCP-Seite prüfen/bestätigen, siehe Kommentar oben am Kea-Block.
- **Zeile 2815** (srv/tesm/app.py): Ohne tzset() würde diese bereits laufende tesm.service- Instanz die neue Zeitzone für datetime.now() (Änderungslog, App-Log) erst nach einem Neustart übernehmen -- glibc cached die Zeitzone sonst ab dem ersten Zugriff im Prozess. poe.sh/date liest sie dagegen ohnehin bei jedem Aufruf frisch, da es ein neuer Prozess pro Durchlauf ist.
- **Zeile 2867** (srv/tesm/app.py): LDAP/Active Directory hat seit dem Umzug auf eine eigene Unterseite (Übersichtlichkeit) eine eigene Route -- siehe settings_ldap().
- **Zeile 2877** (srv/tesm/app.py): Übersicht ganz oben zeigt JEDES tatsächlich aktive Interface (eigene IPv4-Adresse vorhanden) gleichzeitig, nicht nur das gerade im Formular ausgewählte -- bei mehreren Interfaces sonst leicht der Eindruck, das zweite existiere für die App gar nicht (live so gemeldet: die Übersicht zeigte nach dem Umschalten der Auswahl weiterhin nur die Werte des ersten Interfaces).

### `def settings_network_state`
- **Zeile 2935** (srv/tesm/app.py): "network" ist ein ipaddress.IPv4Network-Objekt (siehe _detect_interface_network) -- nicht JSON-serialisierbar und vom Frontend hier auch nicht benötigt (nur ip/prefix/gateway/dns/mode).

### `def settings_import_export`
- **Zeile 2956** (srv/tesm/app.py): DHCP-Reservierungen (Kea DHCP)
- **Zeile 2958** (srv/tesm/app.py): Backend bewusst Kea statt isc-dhcp-server (seit 2022 EOL) — Kea bildet "globaler Wert, pro Client überschreibbar" nativ über Reservation-Options ab. Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen statt an "Konfiguration speichern" gekoppelt, da ein unerwarteter DHCP-Server ein Netzsegment stören kann.
- **Zeile 2967** (srv/tesm/app.py): 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.
- **Zeile 2973** (srv/tesm/app.py): 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.

### `def _dhcp_subnet_matches`
- **Zeile 3000** (srv/tesm/app.py): 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).

### `def _detect_interface_networks`
- **Zeile 3106** (srv/tesm/app.py): Fallback: irgendeine Default-Route, falls keine speziell an dieses Interface gebunden ist (typisch bei Hosts mit nur einer aktiven Schnittstelle).
- **Zeile 3122** (srv/tesm/app.py): Der Kernel markiert eine per DHCP bezogene Adresse mit "dynamic" — unabhängig vom Backend eine zuverlässige Quelle für den aktuellen DHCP/Statisch-Modus (genutzt von der netplan-Erkennung unten).

### `def _dhcp_reservation_candidates`
- **Zeile 3239** (srv/tesm/app.py): Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer. MAC (upper) -> {mac, ip, name, source, manual_id}
- **Zeile 3265** (srv/tesm/app.py): Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).

### `def _dhcp_read_leases`
- **Zeile 3312** (srv/tesm/app.py): 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.
- **Zeile 3321** (srv/tesm/app.py): 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.
- **Zeile 3330** (srv/tesm/app.py): Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc) noch nicht aus der CSV entfernt — nicht als aktiv anzeigen.
- **Zeile 3337** (srv/tesm/app.py): bereits eine neuere (oder gleich neue) Zeile für diese IP gesehen
- **Zeile 3346** (srv/tesm/app.py): Absolut UND als Epoch-ms für einen live tickenden Countdown im Browser (siehe initLeaseCountdowns in app.js) — eine reine Absolutzeit beim Seitenaufruf wirkt bei kurzen Lease-Zeiten (Default 600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen ist, bis man sie tatsächlich anschaut. Explizit als UTC ausgewiesen, da Kea intern in UTC-Epoch arbeitet.

### `def _dhcp_delete_lease`
- **Zeile 3383** (srv/tesm/app.py): 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).
- **Zeile 3392** (srv/tesm/app.py): 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).

### `def _render_kea_config`
- **Zeile 3429** (srv/tesm/app.py): Standard-Optionen (is_standard) bekommen KEIN eigenes option-def — Kea kennt sie bereits nativ, eine Neudefinition würde die eingebaute duplizieren (siehe DHCP_STANDARD_OPTIONS). Nur eigene Options brauchen ein option-def, damit Kea Name/Typ/Code überhaupt kennt.
- **Zeile 3448** (srv/tesm/app.py): 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.
- **Zeile 3487** (srv/tesm/app.py): 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).
- **Zeile 3496** (srv/tesm/app.py): Eigene Log-Datei statt Keas Distributions-Default (meist Syslog) — damit sie sich zusammen mit den anderen drei App-Logs (Live, Änderungen, App) über dieselbe logrotate-Konfiguration verwalten lässt. Bewusst OHNE maxsize/maxver (Keas eigene, größenbasierte Rotation) — die externe, zeitbasierte logrotate-Rotation (siehe _write_logrotate_config) soll die einzige Quelle für alle vier Logs sein, nicht zwei parallele, unterschiedliche Schemata. "loggers" gehört NACH Live-Test gegen eine echte Kea-Instanz zwingend UNTER "Dhcp4" -- ein Top-Level- "Logging"-Geschwisterobjekt (wie in älterer/anderer Dokumentation zu finden) lehnt Kea 2.4.1 mit einem Syntax-Fehler ab.

### `def settings_dhcp`
- **Zeile 3605** (srv/tesm/app.py): 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).
- **Zeile 3873** (srv/tesm/app.py): 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.
- **Zeile 3895** (srv/tesm/app.py): 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.
- **Zeile 3924** (srv/tesm/app.py): 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.
- **Zeile 3932** (srv/tesm/app.py): 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.

### `def settings_ldap`
- **Zeile 4027** (srv/tesm/app.py): Bind-Konto liegt in der eigenen service_accounts-Tabelle (siehe _ldap_settings()), nicht in settings -- ein neu eingegebenes Konto ERSETZT das alte komplett (purpose ist UNIQUE). Passwort nur überschreiben, wenn tatsächlich ein neues eingegeben wurde; Username kann auch ohne neues Passwort aktualisiert werden (dann bleibt das alte, verschlüsselte Passwort bestehen).
- **Zeile 4059** (srv/tesm/app.py): Prüft die AKTUELL im Formular eingetragenen Werte (auch wenn noch nicht gespeichert) -- gleiches Prinzip wie "Verbindung testen" bei Switchen/Geräten, das ebenfalls die Formularwerte statt gespeicherter Zugangsdaten testet.
- **Zeile 4082** (srv/tesm/app.py): Ohne Bind-Konto kann LDAP nicht funktionieren -- vorsorglich mit deaktivieren, statt eine "aktivierte", aber unbenutzbare Konfiguration stehen zu lassen.

### `def save_nav_order`
- **Zeile 4149** (srv/tesm/app.py): Unterpunkt-Reihenfolge je Gruppe — ein Feld "nav_child_order_<group_key>" pro Gruppe mit Kindern, Werte in der gewünschten Reihenfolge.
- **Zeile 4167** (srv/tesm/app.py): Import/Export — für Umzug auf eine neue Umgebung. Wählbare Kategorien (Clients, Switche, Zugangsdaten, Benutzer, Custom-Gruppen, LDAP/AD, DHCP, Logs) werden als EIN mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt. Der Import läuft zweistufig: 1) Datei+Passphrase hochladen -> entschlüsseln, Vorschau zeigt NUR die in    der Datei tatsächlich enthaltenen Kategorien samt Anzahl. 2) Aus dieser Vorschau gezielt einzelne Kategorien zum Einspielen auswählen    (siehe _pending_imports/import_apply) — eine Vollsicherung muss also    nicht komplett eingespielt werden.
- **Zeile 4192** (srv/tesm/app.py): Diese Kategorien betreffen Konten/Rechte/Zugangsdaten für den gesamten Betrieb (können u.a. neue Admin-Konten anlegen oder das LDAP-Bind-Konto überschreiben) — Auswahl bleibt echten Admins vorbehalten, unabhängig vom sonst delegierbaren "Im-/Export"-Recht (gleiches Eskalationsschutz-Prinzip wie bei group_id=="admin" an anderer Stelle im Code).

### `def _export_devices`
- **Zeile 4207** (srv/tesm/app.py): Papierkorb-Einträge werden nie mitexportiert -- ein Restore/Purge ist eine lokale Aufräum-Entscheidung dieser Instanz, kein Bestand, der auf eine andere Umgebung übertragen werden soll.

### `def _export_users`
- **Zeile 4229** (srv/tesm/app.py): Nur lokale Konten -- AD/LDAP-Konten werden nie exportiert: Konto und Passwort werden über Windows/Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login ohnehin automatisch wieder an (siehe login()).

### `def _export_dhcp`
- **Zeile 4302** (srv/tesm/app.py): unveränderte Standard-Option -- wird auf dem Ziel automatisch angelegt

### `def _import_devices`
- **Zeile 4328** (srv/tesm/app.py): WHERE deleted_at IS NULL an der DO-UPDATE-Klausel: ein Import darf ein im Papierkorb liegendes Gerät nicht stillschweigend wiederbeleben -- die MAC bleibt dann einfach reserviert, bis es wiederhergestellt oder endgültig gelöscht wird (siehe Papierkorb-Doku).

### `def _import_users`
- **Zeile 4378** (srv/tesm/app.py): AD/LDAP-Konten mit gleichem Benutzernamen werden nie überschrieben -- deren Konto/Passwort wird über Active Directory/Windows verwaltet, nicht über einen Import hier.
- **Zeile 4389** (srv/tesm/app.py): Im Papierkorb liegender Benutzername bleibt reserviert -- ein Import darf ihn nicht stillschweigend wiederbeleben (siehe Wartung-Seite: erst wiederherstellen oder endgültig löschen).

### `def _import_groups`
- **Zeile 4420** (srv/tesm/app.py): Systemgruppen (aktuell nur "Benutzer") nie über Import überschreiben, ebenso wenig im Papierkorb liegende Gruppen -- der Name bleibt dann reserviert, bis wiederhergestellt oder endgültig gelöscht wird.

### `def _import_ldap`
- **Zeile 4451** (srv/tesm/app.py): set_setting() bewusst NICHT verwendet: das öffnet eine EIGENE sqlite3-Verbindung -- import_apply() ruft mehrere IMPORT_APPLIERS nacheinander auf DERSELBEN, bereits offenen "conn" auf und committet erst ganz am Ende. Ist zu diesem Zeitpunkt (z.B. durch eine vorher im selben Import verarbeitete Kategorie) bereits eine Schreib-Transaktion auf "conn" offen, blockiert set_settings()' eigene Verbindung darauf, bis der SQLite-Standard-Timeout zuschlägt -- "sqlite3.OperationalError: database is locked", live in Produktion reproduziert. Direkt auf der übergebenen "conn" schreiben vermeidet die zweite Verbindung komplett.

### `def _import_dhcp`
- **Zeile 4485** (srv/tesm/app.py): set_setting() bewusst NICHT verwendet -- siehe ausführlicher Kommentar in _import_ldap() (dieselbe "database is locked"-Falle bei mehreren IMPORT_APPLIERS auf derselben offenen Transaktion).
- **Zeile 4496** (srv/tesm/app.py): Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen.

### `def _import_logs`
- **Zeile 4550** (srv/tesm/app.py): Entschlüsselte Import-Vorschau zwischen Upload (import_data) und tatsächlichem Einspielen (import_apply) -- analog _pending_network_revert: In-Memory, token-basiert, TTL-begrenzt, damit die Passphrase nicht ein zweites Mal im Browser landen muss, um gezielt einzelne Kategorien auszuwählen.

### `def export_data`
- **Zeile 4569** (srv/tesm/app.py): Eigenes, von Import getrenntes Recht (settings_importexport.export, eigene X-Spalte in der Gruppentabelle) -- Export enthält entschlüsselte Passwörter im Klartext, daher bewusst nicht an das generische "Ändern"-Recht gekoppelt. Admins haben es über has_permission() automatisch, für andere Gruppen entscheidet ein Admin gezielt.

### `def import_data`
- **Zeile 4646** (srv/tesm/app.py): Legacy-Exporte (version 1, vor der Sektionsauswahl) hatten Devices/ Switches/Zugangsdaten direkt unter denselben Schlüsseln wie heute -- Struktur ist identisch, daher ohne Migration weiterverwendbar.

### `def import_apply`
- **Zeile 4724** (srv/tesm/app.py): Devices

### `def devices`
- **Zeile 4764** (srv/tesm/app.py): SSH-Anbindung optional — die meisten Geräte sind keine per SSH erreichbaren Hosts, nur wer bewusst Zugangsdaten auswählt/anlegt bekommt sie zugeordnet (siehe Wartungsseite).
- **Zeile 4803** (srv/tesm/app.py): --- Nur Switch-Zuordnung ändern (Modal "Switch ändern") ---
- **Zeile 4826** (srv/tesm/app.py): --- Normales Gerät bearbeiten ---
- **Zeile 4868** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht).
- **Zeile 4879** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen.

### `def toggle_device`
- **Zeile 4915** (srv/tesm/app.py): Aktivieren/Deaktivieren zählt jetzt zu "Ändern" (devices.edit) statt eines eigenen devices.toggle-Rechts.
- **Zeile 4940** (srv/tesm/app.py): Switches

### `def delete_switch`
- **Zeile 5077** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht).
- **Zeile 5092** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen.
- **Zeile 5106** (srv/tesm/app.py): Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche, damit nicht jeder Switch sein eigenes Login braucht. Eigene credentials.*- Rechte, unabhängig von switches.* (bewusst entkoppelt).

### `def credentials`
- **Zeile 5177** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht).
- **Zeile 5195** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen.
- **Zeile 5221** (srv/tesm/app.py): Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients.
- **Zeile 5223** (srv/tesm/app.py): Bewusst komplett getrennt vom PoE-Neustart auf dem Dashboard: der Dashboard-Button (devices.restart) löst IMMER einen PoE-Reset über den zugeordneten Switch aus. Die Aktionen hier laufen IMMER per SSH direkt auf dem Gerät selbst -- unabhängig davon, ob/wie es an einen Switch angeschlossen ist. Job-Status lebt bewusst nur im Prozessspeicher (wie das SSH-Terminal auch keine Sitzung persistiert) -- ein Neustart des tesm.service verwirft laufende Job-Anzeigen, nicht aber die zugrundeliegende SSH-Aktion selbst (die läuft serverseitig auf dem Zielgerät unabhängig vom Anzeigezustand hier weiter).
- **Zeile 5235** (srv/tesm/app.py): mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error",         "message": str, "output": str, "started": str, "finished": str|None}

### `def _maintenance_devices`
- **Zeile 5271** (srv/tesm/app.py): Papierkorb — Clients/Switche/Zugangsdaten/Benutzer (nur lokale Konten)/ Custom-Gruppen landen beim "Löschen" nur noch hier (siehe delete_device/ delete_switch/delete_credential/delete_user/delete_group weiter oben), bis sie wiederhergestellt oder endgültig gelöscht werden -- manuell über die Buttons auf der Wartung-Seite, oder automatisch nach Ablauf von trash_retention_days (siehe Systemeinstellungen). AD/LDAP-Benutzer durchlaufen den Papierkorb NICHT (siehe delete_user) -- sie legen sich bei jedem gültigen AD-Login ohnehin automatisch neu an.

### `def _purge_device`
- **Zeile 5324** (srv/tesm/app.py): Per-Client-DHCP-Options sind an die MAC gekoppelt (kein FK, siehe dhcp_option_values.device_mac) -- werden hier mit aufgeräumt, statt nach einem endgültigen Löschen verwaist stehen zu bleiben.

### `def _purge_group`
- **Zeile 5347** (srv/tesm/app.py): AD-Gruppenzuordnungen, die auf genau diese Gruppe zeigten, würden sonst dauerhaft ins Leere laufen (siehe _ldap_group_mappings()-Anzeige "(gelöscht)" für den Fall, dass das trotzdem irgendwo übersehen wird).

### `def maintenance`
- **Zeile 5564** (srv/tesm/app.py): Abgeschlossene ERFOLGREICHE Aktionen werden bei jedem (erneuten) Laden der Seite zurückgesetzt (Status wieder "Noch keine Aktion") -- ein Erfolg ist nur so lange relevant, wie man ihn noch live über das Polling mitverfolgt; ein späterer Aufruf der Seite soll nicht dauerhaft eine ggf. Stunden alte Erfolgsmeldung zeigen. Fehlgeschlagene Aktionen bleiben dagegen bewusst sichtbar, bis eine neue Aktion sie überschreibt -- ein Fehler soll nicht durch einen einfachen Seiten-Reload versehentlich übersehen werden.

### `def papierkorb`
- **Zeile 5589** (srv/tesm/app.py): Vor dem Laden der Papierkorb-Listen abgelaufene Einträge endgültig aufräumen -- kein eigener Scheduler nötig (analog dem Import-Vorschau- Token-Aufräumen), die Seite wird ohnehin regelmäßig aufgerufen.

### `def _maintenance_run_update`
- **Zeile 5649** (srv/tesm/app.py): Zeilen kommen live rein statt erst am Ende komplett -- Grundlage für die automatisch mitscrollende Live-Ausgabe im Frontend. Jede Zeile bekommt ihren eigenen Zeitstempel (wann sie tatsächlich ankam, nicht nur Start/Ende des gesamten Updates). Puffer bewusst auf die letzten _MAINTENANCE_OUTPUT_MAX_LINES begrenzt (ganze Zeilen, kein hartes Zeichen-Limit mehr), damit ein sehr langes Update den Prozessspeicher nicht unbegrenzt wachsen lässt.

### `def on_line`
- **Zeile 5664** (srv/tesm/app.py): overall_timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp; der bleibt für den reinen Verbindungsaufbau bestehen.
- **Zeile 5673** (srv/tesm/app.py): Eindeutiger Abschluss-Marker als letzte Zeile -- vorher endete die Ausgabe einfach mitten im rohen apt-Output, ohne erkennbar zu machen, ob das Update tatsächlich fertig oder nur abgeschnitten war.
- **Zeile 5695** (srv/tesm/app.py): Nach einem ausgelösten Neustart blieb der Job-Status bisher für immer auf "Neustart ausgelöst" stehen -- der eigentlich interessante Zeitpunkt (ist das Gerät wieder erreichbar?) wurde nie erfasst. Gleiche Ping-Invocation wie das bestehende Online/Offline-Monitoring in poe.sh ("ping -c 1 -W 2 <ip>"), für konsistentes Verhalten mit dem Dashboard.

### `def maintenance_reboot`
- **Zeile 5818** (srv/tesm/app.py): Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
- **Zeile 5820** (srv/tesm/app.py): Öffnet eine echte interaktive SSH-Sitzung im Browser, damit Host-Keys beim Erstkontakt bestätigt und Zugangsdaten geprüft werden können, bevor der Switch überhaupt gespeichert ist. Implementiert mit paramiko (reines Python) statt eines lokalen pty + System-`ssh`-Client — das läuft auf jedem Betriebssystem (Windows, Linux, macOS), auf dem die Flask-App läuft, und die SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, niemals vom Browser/Client aus. Nur für eingeloggte Admins.

### `def _send_and_close`
- **Zeile 5845** (srv/tesm/app.py): Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall.

### `def _terminal_read_line`
- **Zeile 5881** (srv/tesm/app.py): Strg+C

### `def _authenticate_interactive`
- **Zeile 5955** (srv/tesm/app.py): "password" bewusst VOR "keyboard-interactive" versuchen, auch wenn der Server beides anbietet: viele Switches/APs (u.a. diverse ArubaOS- Varianten) bieten zwar "keyboard-interactive" an, deren Server-seitige Umsetzung ist aber teils nicht robust — z. B. eine erste Runde ganz ohne Prompts (laut RFC 4256 gültig, aber unsichtbar für den Nutzer), gefolgt von sofortigem Scheitern statt einer zweiten Runde mit dem eigentlichen Passwort-Prompt. Das erklärt exakt das beobachtete Symptom: im Terminal erscheint NIE ein Prompt, nur direkt "Zugangs- daten falsch". "password" ist der einfachere, zuverlässiger unterstützte Pfad (Ende-zu-Ende gegen einen echten Host verifiziert) und wird deshalb bevorzugt, wenn der Server ihn überhaupt anbietet.

### `def handler`
- **Zeile 5975** (srv/tesm/app.py): title/instructions zusätzlich anzeigen: manche Geräte legen den eigentlichen Hinweistext ("Password:") dort statt in prompt_list ab. Leere prompt_text-Strings (ebenfalls bei manchen Geräten üblich) bekommen einen sinnvollen Fallback, damit dem Nutzer nie eine Eingabeaufforderung ganz ohne sichtbaren Text präsentiert wird.

### `def ssh_terminal`
- **Zeile 6001** (srv/tesm/app.py): Fängt unerwartete Fehler ab und loggt sie, statt sie in flask-sock's Verbindungs-Handling verschwinden zu lassen.

### `def _ssh_terminal_inner`
- **Zeile 6032** (srv/tesm/app.py): Bewusst die Low-Level-Transport-API statt SSHClient.connect(): eine Passwortabfrage MITTEN im Verbindungsaufbau (nach Host-Key-Bestätigung, vor der eigentlichen Shell) ist mit der High-Level-API nicht möglich, da connect() Host-Key-Prüfung UND Authentifizierung in einem blockierenden Aufruf erledigt. Vorher führte das dazu, dass nie ein Passwort abgefragt wurde und jeder Login mit "No authentication methods available" scheiterte, sobald kein Passwort im initialen Payload mitgeschickt wurde (was nie der Fall war/ist).

### `def pump_output`
- **Zeile 6140** (srv/tesm/app.py): Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart)
- **Zeile 6142** (srv/tesm/app.py): Bewusst getrennt von _ssh_terminal_inner() oben: dort kann bei einem unbekannten Host-Key interaktiv "yes/no" nachgefragt werden (wie ein echtes ssh-CLI). Hier läuft nichts vor einem Menschen ab (Bulk-Aktion über mehrere Geräte per Thread) -- ein unbekannter/nicht vorher bestätigter Host-Key wird deshalb hart abgelehnt (RejectPolicy) statt automatisch akzeptiert (AutoAddPolicy wäre hier ein MITM-Risiko ohne jede Prüfung). Ein Host muss also immer zuerst einmal über "Verbindung testen" (das interaktive Terminal) bestätigt worden sein, bevor Wartungsaktionen für ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
- **Zeile 6153** (srv/tesm/app.py): Jeder Schritt einzeln benannt (statt nur mit "&&" verkettet), damit nach jedem Schritt ein eigener "Schritt N -> erfolgreich/fehlgeschlagen"-Marker ins Live-Log geschrieben werden kann (siehe _apt_upgrade_command()) -- apt/apt-get statt der literal vorgegebenen "apt"-Aufrufe, weil apt-get eine stabile, für Skripte gedachte Schnittstelle hat (die apt(8)-Manpage warnt ausdrücklich: "the apt command is intended to be pleasant for end users and does not need to be backward compatible" -- also nichts für Automatisierung).

### `def drain`
- **Zeile 6311** (srv/tesm/app.py): Ein letzter, nicht mit "\n" abgeschlossener Rest zählt noch als Zeile (apt beendet die letzte Ausgabezeile nicht immer mit Zeilenumbruch).

### `def _run_ssh_reboot`
- **Zeile 6442** (srv/tesm/app.py): Verbindungsabbruch durch den Neustart selbst ist erwartet
- **Zeile 6454** (srv/tesm/app.py): Logs

### `def get_log`
- **Zeile 6470** (srv/tesm/app.py): Dateiname als Header mitschicken, damit die Anzeige auch nach einem Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload aktuell bleibt.

### `def activity_log`
- **Zeile 6562** (srv/tesm/app.py): Manueller PoE-Neustart

### `def restart_device`
- **Zeile 6583** (srv/tesm/app.py): Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen (siehe poe.sh: der SSH-Restart-Block wird dort übersprungen) — dann lieber gar nicht erst einen "Neustart gestartet"-Erfolg vorgaukeln.
- **Zeile 6602** (srv/tesm/app.py): Users

### `def users`
- **Zeile 6625** (srv/tesm/app.py): Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl "admin" macht den Benutzer zum Admin, jede andere Auswahl ist eine normale Gruppe (oder keine). Nur echte Admins dürfen jemanden direkt als Admin anlegen (Eskalationsschutz) — delegierte "Benutzer anlegen"-Rechte reichen dafür nicht.
- **Zeile 6662** (srv/tesm/app.py): Nur Stammdaten + optional Passwort — Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert (s.u.).
- **Zeile 6665** (srv/tesm/app.py): Admin-Konten dürfen nur von echten Admins bearbeitet werden (u.a. Passwort-Reset) — verhindert, dass ein delegiertes "Benutzer bearbeiten"-Recht zur Übernahme eines Admin-Kontos missbraucht wird.
- **Zeile 6676** (srv/tesm/app.py): AD/LDAP-Konten werden vollständig über AD verwaltet (Name, E-Mail/UPN, Passwort) — hier bearbeiten würde nur mit dem nächsten Login wieder überschrieben werden. Einzig zulässige Aktion für solche Konten ist Sperren/Entsperren und die Gruppenzuweisung (siehe toggle_lock/assign_group).
- **Zeile 6718** (srv/tesm/app.py): Sowohl das Zuweisen ZUR Admin-Gruppe als auch das Ändern der Gruppe eines BESTEHENDEN Admins bleibt echten Admins vorbehalten (Eskalationsschutz) — ein delegiertes "Gruppe zuweisen"-Recht darf niemanden zum Admin machen oder einem Admin die Rechte entziehen.
- **Zeile 6730** (srv/tesm/app.py): Mindestens ein Admin muss bestehen bleiben.
- **Zeile 6755** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht).
- **Zeile 6764** (srv/tesm/app.py): Admin-Konten dürfen nur von echten Admins gelöscht werden.
- **Zeile 6770** (srv/tesm/app.py): AD/LDAP-Konten durchlaufen den Papierkorb nicht -- sie sind nur ein Schatten-Eintrag, der sich bei jedem gültigen AD-Login ohnehin automatisch neu anlegt. Ein "wiederherzu- stellender" Papierkorb-Eintrag wäre hier ohne Nutzen.
- **Zeile 6780** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- Mitgliedschaften bleiben dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen soll denselben Stand wiederherstellen wie vor dem Löschen).
- **Zeile 6792** (srv/tesm/app.py): Einzige Möglichkeit, einem AD/LDAP-Konto den Zugriff zu entziehen, ohne es in AD selbst zu sperren -- funktioniert aber genauso für lokale Konten.
- **Zeile 6827** (srv/tesm/app.py): Vorab-Zuweisung: ein AD-Benutzer wird gesucht (siehe /users/ldap_search) und hier -- noch VOR seinem ersten Login -- mit einer Gruppe angelegt. Ruft _ldap_lookup erneut auf statt den übermittelten Anzeigenamen zu vertrauen, damit Name/E-Mail garantiert frisch aus AD stammen.

### `def users_ldap_search`
- **Zeile 6902** (srv/tesm/app.py): Nur LEBENDE Konten ausblenden -- ein im Papierkorb liegender Name soll weiterhin gefunden werden (führt beim Hinzufügen zu einer klaren Fehlermeldung statt stillschweigend zu fehlen, siehe ldap_add_user).
- **Zeile 6914** (srv/tesm/app.py): Gruppen (Rechtesystem) — nur Admins dürfen Gruppen und deren Berechtigungen/Mitgliedschaften verwalten.

### `def groups`
- **Zeile 6936** (srv/tesm/app.py): Rechte direkt beim Anlegen setzbar (dieselbe Tabelle wie beim Bearbeiten, hier ohne eigenes "_submitted"-Flag, da eine neue Gruppe ohnehin noch keine Rechte hat).
- **Zeile 6966** (srv/tesm/app.py): Systemgruppen (aktuell nur die Standardgruppe 'Benutzer') sind standardmäßig in Name und Rechten fix. Ein Admin kann die Rechte über den expliziten "Freischalten"-Button in der UI trotzdem ändern (unlock_system_group=1) -- der Name bleibt IMMER fix, da DEFAULT_GROUP_NAME an anderer Stelle im Code über den Namen nach dieser Gruppe sucht (Umbenennen würde diese Lookups brechen). Mitgliedschaft (members_submitted) war schon immer frei verwaltbar.
- **Zeile 6988** (srv/tesm/app.py): Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind zwei getrennte Formulare, damit das Speichern des einen nicht versehentlich den Stand des anderen zurücksetzt. Die verstecken *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt wurde (leere Checkbox-Listen wären sonst nicht von "nichts ausgewählt" zu unterscheiden).
- **Zeile 7025** (srv/tesm/app.py): Bleibt bewusst admin-exklusiv, unabhängig von groups.edit — wer Admin ist/wird, darf nicht über ein delegierbares Recht gesteuert werden können.
- **Zeile 7040** (srv/tesm/app.py): Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt in Admin- und z.B. Standardgruppe auftauchen.
- **Zeile 7052** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht).
- **Zeile 7063** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- Rechte/Mitgliedschaften bleiben dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen soll denselben Stand wie vorher liefern).
- **Zeile 7106** (srv/tesm/app.py): Virtuelle "Admin"-Gruppe: rein informativ, damit auf einen Blick sichtbar ist, wer alles darf — Mitgliedschaft/Rechte werden weiterhin ausschließlich über den is_admin-Schalter auf der Users-Seite gesteuert, hier gibt es daher bewusst kein Formular.
- **Zeile 7138** (srv/tesm/app.py): threaded=True ist für das SSH-Web-Terminal (WebSocket) notwendig: die Verbindung hält den Thread offen, ohne Threading würde der einzelne Worker der Dev-Umgebung blockieren und WebSocket-Frames verstümmeln.

---

## create_db.py

### Modulebene
- **Zeile 23** (srv/tesm/create_db.py): Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich dieselben teilen; Passwort wird verschlüsselt gespeichert)
- **Zeile 36** (srv/tesm/create_db.py): Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie direkt zu speichern
- **Zeile 51** (srv/tesm/create_db.py): Devices
- **Zeile 69** (srv/tesm/create_db.py): Benutzer
- **Zeile 86** (srv/tesm/create_db.py): Gruppen (Rechtesystem) — Admins besitzen ohnehin alle Rechte; Gruppen dienen dazu, normalen Benutzern gezielt einzelne Verwaltungsrechte für Devices/Switches zu geben, ohne sie zu Admins zu machen.
- **Zeile 99** (srv/tesm/create_db.py): Welche Berechtigungen eine Gruppe gewährt (siehe app.py: PERMISSIONS)
- **Zeile 109** (srv/tesm/create_db.py): Gruppenmitgliedschaft (ein Benutzer kann in mehreren Gruppen sein; effektive Rechte = Vereinigung aller Gruppen)
- **Zeile 121** (srv/tesm/create_db.py): Settings (z.B. Prüfintervall)
- **Zeile 129** (srv/tesm/create_db.py): DHCP: eigene Options-Definitionen (analog "Vordefinierte Optionen und Werte" bei einem Windows-DHCP-Server) — Code/Name/Typ/Beschreibung. is_standard markiert die vorbefüllten Standard-Optionen (Router/DNS/…) -- die können vom Admin nicht gelöscht werden, nur ihr Wert geleert (die tatsächliche Vorbefüllung übernimmt app.py: _ensure_schema()).
- **Zeile 145** (srv/tesm/create_db.py): DHCP: Werte je Option — device_mac='' bedeutet global (gilt für den ganzen Scope), ein gesetzter device_mac überschreibt den globalen Wert für genau dieses Gerät. Bewusst '' statt NULL für den globalen Fall, da SQLite NULL in UNIQUE-Constraints nicht als gleich behandelt (zwei NULL-Zeilen wären sonst kein Konflikt) — mit '' funktioniert ON CONFLICT zuverlässig für den Upsert des globalen Werts.
- **Zeile 162** (srv/tesm/create_db.py): DHCP: konfigurierte Subnetze (Interface + Range + optionales Gateway/DNS) -- ein Host kann mehrere Subnetze gleichzeitig bedienen, je eines pro Interface/Netz. sort_order steuert die Anzeige-/Verarbeitungsreihenfolge, enabled erlaubt ein Subnetz vorübergehend aus der geschriebenen Konfiguration auszuschließen, ohne es zu löschen.
- **Zeile 180** (srv/tesm/create_db.py): DHCP: manuelle Reservierungen für Geräte OHNE eigenen Client-Datensatz (Geräte mit MAC+IP in der devices-Tabelle bekommen ihre Reservierung automatisch, siehe _dhcp_reservation_candidates in app.py).
- **Zeile 192** (srv/tesm/create_db.py): Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt das alte.
- **Zeile 206** (srv/tesm/create_db.py): AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer App-Gruppe zugeordnet werden (additiv, siehe _ldap_resolve_app_groups in app.py) -- passt keine dieser Gruppen, greift die Standardgruppe.
- **Zeile 219** (srv/tesm/create_db.py): Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE PoE-Neustarts (die stehen im Live-Log von poe.sh).
- **Zeile 232** (srv/tesm/create_db.py): Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden)
- **Zeile 236** (srv/tesm/create_db.py): Standardgruppe 'Benutzer' — Systemgruppe (is_system), Name und Rechte sind über die UI fest und nicht änderbar. Jeder neu angelegte (nicht-admin) Benutzer landet automatisch darin (siehe app.py: users()). Der genaue Rechtesatz muss mit DEFAULT_GROUP_PERMISSIONS in app.py übereinstimmen.

---

## poe.sh

- **Kopfbereich**: PoE Device Check Script -- prueft Erreichbarkeit von Geraeten, startet PoE-Port bei Ausfall neu, loggt Ereignisse.
- **LOG_DIR/LOGFILE**: Eine einzige, durchgehend fortgeschriebene Datei statt vorher bei jedem (auch taeglich per tesm-check-restart.timer automatisch ausgeloesten) Dienst-Neustart ein neues rpi-<timestamp>.log -- Rotation/Aufbewahrung uebernimmt jetzt zentral logrotate (siehe app.py: _write_logrotate_config, einstellbar unter Systemeinstellungen -> Logs), nicht mehr dieses Skript selbst. Pfad muss mit TESM_LOG_DIR/live.log in app.py uebereinstimmen.
- **SLEEP**: Intervall aus DB abrufen.
- **disable_poe()/enable_poe()**: bewusst OHNE expliziten "timeout { exit 1 }"/"eof { exit 1 }"-Abbruch in den einzelnen expect-Schritten -- ein nicht matchender Schritt laesst expect nach Ablauf von "set timeout 5" einfach zur naechsten Zeile weiterlaufen, statt abzubrechen. Das ist absichtlich so belassen: ein Versuch, hier "echte" Fehlererkennung nachzuruesten (Exit-Code 0 nur bei bestaetigtem Erfolg je Schritt), hat sich live als Regression herausgestellt. Grund: das Login-Prompt-Pattern "-re \".*> $\"" matcht nur ein Operator-Prompt ("switchname>"). Auf ArubaOS-Switches, bei denen der SSH-Account (z.B. "admin") mit Manager-Rechten direkt im privilegierten Modus landet ("switchname#" statt "switchname>", bestaetigt an einem 2930M/"ST-2000-Sued"), matcht das Pattern nie -- mit einem harten Timeout-Abbruch waere das faelschlich als Fehlschlag gewertet worden, obwohl Passwort und Verbindung in Ordnung waren. Diese Funktionen sind daher exakt auf den Stand der urspruenglichen, in Produktion bewaehrten Fassung zurueckgesetzt.
- **check_device()**: Ein Geraete-Check (Ping + ggf. PoE-Neustart) als eigene Funktion, damit sie sowohl vom manuellen Neustart (einzeln, synchron) als auch von der Hauptschleife (parallel im Hintergrund) verwendet werden kann. Nur PoE neu starten, wenn dem Geraet auch tatsaechlich ein Switch zugeordnet ist (switch_ip) -- ein reiner Port-Wert ohne Switch (switch_ip leer) darf keinen SSH-Versuch mit leeren Zugangsdaten ausloesen.
- **Hauptschleife**: Jedes Geraet wird als eigener Hintergrund-Job geprueft, statt strikt nacheinander in einer einzigen Schleife -- bei einer groesseren Geraeteliste (z.B. nach einem Import) haette ein einzelnes bereits ausgefallenes Geraet mit langsamem/nicht erreichbarem Switch (jeder expect-Versuch bis zu ~5s Timeout je Verbindungsschritt) sonst die Pruefung ALLER nachfolgenden Geraete verzoegert. "< <(...)" (Process Substitution) statt einer Pipe, damit die Hintergrund-Jobs Kinder dieser Shell bleiben und "wait" sie unten tatsaechlich erfassen kann (bei "cmd | while ...; done" liefe die Schleife in einer eigenen Subshell, deren Hintergrund-Jobs von aussen nicht mehr sichtbar waeren). Auf alle in diesem Durchlauf gestarteten Geraete-Checks warten, bevor das Intervall abgewartet wird -- sonst koennten sich Durchlaeufe ueberlappen (z.B. mehrere parallele expect-Sitzungen zum selben Switch aus zwei verschiedenen, noch laufenden Zyklen gleichzeitig).

---

## _dashboard_tiles.html

### Jinja-/HTML-Kommentare
- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im scripts-Block von index.html).

---

## credentials.html

### Jinja-/HTML-Kommentare
- Modal: Neue Zugangsdaten
- Modal: Zugangsdaten bearbeiten

---

## devices.html

### Jinja-/HTML-Kommentare
- Modal: Neues Gerät
- Modal: Bearbeiten
- Modal: Switch ändern
- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys und Prüfen der Zugangsdaten, bevor das Gerät gespeichert wird)

### JavaScript-Kommentare
- Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach vorherigem Umschalten auf "neu" ohne zu speichern).
- SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum Identisches Muster wie in switches.html.

---

## groups.html

### Jinja-/HTML-Kommentare
- Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients). "Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B. "D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht anklickbare Checkbox dargestellt statt zu fehlen — dadurch bleiben alle Spalten bündig untereinander. readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen (Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox nebeneinander (siehe .permission-groups-row), damit die ganze Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat.
- compact=true (Gruppen-Dropdown auf der Hauptseite) verkleinert Zellen- Padding/Abstände gegenüber dem "Neue Gruppe"-Modal (compact=false, bleibt unverändert) — gleicher Aufbau, nur enger, da im Dropdown mehr Breite zur Verfügung steht als im schmalen Modal und es sonst auseinandergezogen wirkt.
- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer oben (eigenes <tbody data-sort-pinned>).
- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit die Sortierung beide Zeilen gemeinsam verschiebt.
- Modal: Admin-Mitglieder verwalten
- Modals: Mitglieder pro Gruppe verwalten
- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder werden danach über die Gruppentabelle zugeordnet (die Gruppe muss dafür erst existieren).

### JavaScript-Kommentare
- Filtert ganze <tbody>-Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam, analog zur Sortierung) statt einzelner <tr> -- die gepinnte Admin-Gruppe (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff. Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung. Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist — spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP). Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin alle disabled. Zusätzlich zum disabled-Attribut (das der Browser nur dezent abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt "Bereich gesperrt" auf den ersten Blick kaum auf. R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit, Anwahl von W/E/D/X wählt automatisch R mit an.

---

## index.html

### Jinja-/HTML-Kommentare
- Device detail modal
- Schließen bewusst als letztes Element im DOM: modal-footer nutzt justify-content:flex-end, damit landet der zuletzt gerenderte Button ganz rechts — Schließen soll unabhängig davon, welche Aktions-Buttons gerade sichtbar sind, immer rechts stehen.
- Restart confirm modal

### JavaScript-Kommentare
- Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/ Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig greifen können (z.B. "offline" + Namenssuche). Welche Abschnitte (per data-status) manuell eingeklappt wurden — per Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt über Live-Updates hinweg erhalten (siehe applyCollapseState()). Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem Live-Update erneut angewendet, damit aktive Filter erhalten bleiben (die alten DOM-Knoten werden beim Austausch verworfen). Aktive Stat-Kachel synchron halten — läuft hier statt nur im Klick-Handler, damit der Zustand auch nach einem Live-Update (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt. Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das Kachel-Raster darunter, der Titel bleibt klickbar sichtbar. Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das Dashboard auf genau diesen Status; erneuter Klick auf die bereits aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf. Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt ein/aus, unabhängig vom Status-Filter oben. Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird nach jedem Live-Update erneut auf die frisch eingefügten Kacheln angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden. Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen (siehe restart_device()/poe.sh) — der Button wird für diese Geräte komplett ausgeblendet statt nur ausgegraut, damit kein Neustart vorgegaukelt wird, der tatsächlich nichts bewirkt. Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht. + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat. Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt dieses Durchlaufs synchronisieren (initCheckTimer() in app.js liest das Attribut bei jedem Tick neu ein).

---

## maintenance.html

### Jinja-/HTML-Kommentare
- Bewusst KEIN <form> hier: das säße dann innerhalb des äußeren #updateForm (verschachtelte <form>-Elemente sind ungültiges HTML) -- der Browser hätte das äußere Formular an dieser Stelle vorzeitig geschlossen bzw. dessen Inhalt zerrissen. "Update starten" hat dadurch live IMMER zusätzlich das erste/oberste Gerät mitgeschickt, auch ganz ohne angehakte Checkbox. Das eigentliche <form> für den Neustart steht daher unten AUSSERHALB von #updateForm; der Button hier referenziert es nur über "form=".
- Ein eigenständiges, NICHT verschachteltes <form> je Gerät für den einzelnen "Neustart"-Button (siehe Kommentar oben in der Tabelle) -- jedes referenziert nur genau eine MAC, unabhängig vom Checkbox-Zustand im großen #updateForm.

### JavaScript-Kommentare
- Checkboxen beim Laden IMMER zurücksetzen: Browser stellen den Checked-Zustand von Formularen nach einem Reload/Redirect (z.B. genau der Redirect nach "Update starten") gerne aus dem eigenen Verlauf wieder her, unabhängig davon, dass das frisch gerenderte HTML gar kein "checked" enthält -- ein einmal angehaktes Gerät (z.B. beim ersten Ausprobieren) blieb dadurch bei jedem weiteren Aufruf der Seite unsichtbar mitausgewählt und lief bei "Update starten" immer zusätzlich mit, ganz gleich was man gerade neu ankreuzt (live genau so reproduziert). Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert, sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom dazugehörigen Geräte-Namen ein-/ausblenden. "Klebt" am unteren Rand, solange man nicht selbst nach oben gescrollt hat -- reine Update-Ausgabe kann bei vielen Paketen sehr lang werden, ohne das würde man sie sonst manuell nachziehen müssen, um live mitzulesen. Ein kleiner Schwellwert (20px) toleriert, dass "ganz unten" durch Rundungsfehler beim Scrollen selten exakt 0 ist.

---

## settings_dhcp.html

### Jinja-/HTML-Kommentare
- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist ungültiges HTML und wird vom Browser aus der Tabelle heraus "foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen Formular und Feldern zerrissen werden.
- Neu ausgewählte Felder landen hier direkt unter dem Dropdown (siehe poeShowOptionField in app.js), statt irgendwo in der u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte Overrides stehen von Anfang an hier drin.
- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben bei den Reservierungen — dieselbe HTML-Validität-Begründung).

---

## switches.html

### Jinja-/HTML-Kommentare
- Bearbeiten-Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist ungültiges HTML — Browser "foster-parenten" es dann aus der Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung zwischen Formular und Buttons (this.closest('form') lieferte dadurch null, "Verbindung testen" öffnete kein Terminal mehr).
- Modal: Neuer Switch
- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird)

### JavaScript-Kommentare
- Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein
- Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach vorherigem Umschalten auf "neu" ohne zu speichern).
- SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
- Bewusst kein Toast bei "error": manche Browser melden beim Schließen einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt. Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen

---

## users.html

### Jinja-/HTML-Kommentare
- Modal: Neuer Benutzer
- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle)
- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten Login des AD-Benutzers)
- Modal: Gruppe zuweisen (inkl. Admin als Auswahl)

---

## logs.html

### JavaScript-Kommentare
- Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart bei Ausfall) einheitlich orange markieren. Dateiname mit anzeigen — ändert sich nach einem Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload.

---

## logs_kea.html

### JavaScript-Kommentare
- Kea-Zeilenformat: "<ts> <SEVERITY> [logger/pid.tid] MESSAGE_ID text". Grobe Einfärbung nach Schweregrad, ohne die Logik des Live-Logs (online/offline) hier künstlich nachzubilden.

---

## settings.html

### JavaScript-Kommentare
- Lädt beim Umschalten des Interfaces (Netzwerkeinstellungen) dessen tatsächlichen Ist-Zustand nach -- ohne das würden die Felder weiter die Werte des zuvor angezeigten Interfaces zeigen, obwohl gerade ein anderes ausgewählt wurde (bei mehreren Interfaces leicht zu Verwechslungen geführt, siehe /settings/network_state).
