diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md new file mode 100644 index 0000000..0487713 --- /dev/null +++ b/KNOWLEDGE_BASE.md @@ -0,0 +1,540 @@ +# 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-.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_" 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 "), 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-.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 ). +- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen , 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 -Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam, analog zur Sortierung) statt einzelner -- 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
hier: das säße dann innerhalb des äußeren #updateForm (verschachtelte -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 für den Neustart steht daher unten AUSSERHALB von #updateForm; der Button hier referenziert es nur über "form=". +- Ein eigenständiges, NICHT verschachteltes 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
direkt in 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
direkt in 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: " [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). diff --git a/srv/tesm/app.py b/srv/tesm/app.py index fbe93af..d01448f 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -26,9 +26,6 @@ from ldap3 import Server, Connection, Tls, ALL, SIMPLE, BASE from ldap3.core.exceptions import LDAPException from ldap3.utils.conv import escape_filter_chars -# 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. LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" @@ -77,10 +74,6 @@ _patch_simple_websocket_partial_writes() BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -# --------------------------------------------------------------------------- -# Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults -# passend zum produktiven Deployment unter /srv/tesm) -# --------------------------------------------------------------------------- DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh") FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) @@ -88,26 +81,10 @@ SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, SECRET_KEY_PATH = os.environ.get("TESM_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key")) AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars") -# 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. TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm") TESM_LIVE_LOG_PATH = os.path.join(TESM_LOG_DIR, "live.log") TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log") TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log") -# 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. TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log") LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm") try: @@ -115,17 +92,9 @@ try: os.chmod(TESM_LOG_DIR, 0o777) except OSError: pass -# 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. SWITCH_DEFAULT_SSH_PORT = 22 ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} -# 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). CREDENTIAL_CATEGORIES = [ ("switch", "Switch"), ("linux", "Linux-Client"), @@ -133,17 +102,11 @@ CREDENTIAL_CATEGORIES = [ ("router", "Router/Sonstiges"), ] CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES} -# Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird. DEVICE_MAINTENANCE_CATEGORY = "linux" os.makedirs(AVATAR_DIR, exist_ok=True) app = Flask(__name__) -# 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. try: _app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8") _app_log_handler.setLevel(logging.WARNING) @@ -188,21 +151,6 @@ login_manager.login_view = "login" login_manager.init_app(app) -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- -# 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. PERMISSION_ROW_TYPES = [ ("view", "R", "Lesen (Read)"), ("create", "W", "Schreiben/Anlegen (Write)"), @@ -218,11 +166,6 @@ PERMISSIONS = { "children": { "devices": { "label": "Clients", - # "Ä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). "rows": { "view": "devices.view", "create": "devices.create", @@ -232,7 +175,6 @@ PERMISSIONS = { }, "switches": { "label": "Switche", - # Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab. "rows": { "view": "switches.view", "create": "switches.create", @@ -249,10 +191,6 @@ PERMISSIONS = { }, "maintenance": { "label": "Wartung", - # 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. "rows": { "view": "maintenance.view", "edit": "maintenance.edit", @@ -295,11 +233,6 @@ PERMISSIONS = { }, "settings_importexport": { "label": "Im-/Export", - # 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. "rows": { "view": "settings_importexport.view", "edit": "settings_importexport.edit", @@ -308,8 +241,6 @@ PERMISSIONS = { }, "settings_dhcp": { "label": "DHCP", - # L = Konfiguration/Status/Vorschau ansehen, Ä = Konfiguration - # speichern und die generierte Reservierungsdatei schreiben. "rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"}, }, "settings_ldap": { @@ -320,8 +251,6 @@ PERMISSIONS = { }, } -# Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der -# Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt. PERMISSION_LABELS = { "devices_group.view": "Geräte-Bereich anzeigen", "logs_group.view": "Logs-Bereich anzeigen", @@ -351,13 +280,6 @@ PERMISSION_LABELS = { "settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)", } -# 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. ALL_PERMISSION_KEYS = [] PERMISSION_PARENT_GROUP = {} for _group in PERMISSIONS.values(): @@ -367,11 +289,6 @@ for _group in PERMISSIONS.values(): ALL_PERMISSION_KEYS.append(_key) PERMISSION_PARENT_GROUP[_key] = _group["view_key"] -# 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. GROUP_ROW_TYPES = {} for _group_key, _group in PERMISSIONS.items(): _used_row_keys = {"view"} @@ -385,14 +302,6 @@ DEFAULT_GROUP_PERMISSIONS = [ "logs_group.view", "logs_live.view", ] -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- NAV_ITEMS = [ {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, {"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [ @@ -645,10 +554,6 @@ class User(UserMixin): @property def can_view_settings_importexport(self): - # 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. return self.has_any_permission([ "settings_importexport.view", "settings_importexport.edit", "settings_importexport.export", ]) @@ -683,24 +588,6 @@ def get_db_connection(): return conn -# 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). DHCP_STANDARD_OPTIONS = [ (4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"), (7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"), @@ -767,8 +654,6 @@ def _ensure_schema(): Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und jeder Benutzer ohne Gruppe ihr zugeordnet ist.""" conn = get_db_connection() - # Für den Migrations-Einmal-Guard weiter unten (settings existiert normalerweise - # schon durch create_db.py, hier nur zur Absicherung defensiv angelegt). conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)") conn.execute(""" @@ -789,19 +674,12 @@ def _ensure_schema(): ) """) - # 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. existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()} if "is_default" not in existing_cols: conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") if "is_system" not in existing_cols: conn.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0") - # Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten. user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()} if "first_name" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT") @@ -809,32 +687,13 @@ def _ensure_schema(): conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT") if "avatar_filename" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT") - # '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). if "auth_source" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'") - # 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. if "email" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN email TEXT") - # 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). if "is_locked" not in user_cols: conn.execute("ALTER TABLE users ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0") - # 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). conn.execute(""" CREATE TABLE IF NOT EXISTS service_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -844,12 +703,6 @@ def _ensure_schema(): ) """) - # 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). conn.execute(""" CREATE TABLE IF NOT EXISTS ldap_group_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -860,8 +713,6 @@ def _ensure_schema(): ) """) - # Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne - # PoE-Neustarts (die stehen bereits im Live-Log von poe.sh). conn.execute(""" CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -873,22 +724,16 @@ def _ensure_schema(): ) """) - # 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. device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} if "rpi_ip" in device_cols and "ip" not in device_cols: conn.execute("ALTER TABLE devices RENAME COLUMN rpi_ip TO ip") device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} - # "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log. if "last_modified_by" not in device_cols: conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT") if "last_modified_at" not in device_cols: conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT") - # Zugangsdaten (Credentials): eigenständige, wiederverwendbare - # SSH-Logins für Switche, statt Username/Passwort direkt am Switch. conn.execute(""" CREATE TABLE IF NOT EXISTS credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -898,13 +743,6 @@ def _ensure_schema(): category TEXT NOT NULL DEFAULT 'switch' ) """) - # 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. credential_cols = {row["name"] for row in conn.execute("PRAGMA table_info(credentials)").fetchall()} if "category" not in credential_cols: conn.execute("ALTER TABLE credentials ADD COLUMN category TEXT NOT NULL DEFAULT 'switch'") @@ -915,35 +753,20 @@ def _ensure_schema(): conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT") if "last_modified_at" not in switch_cols: conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT") - # SSH-Port je Switch (falls nicht Standard 22) — NULL/leer bedeutet - # überall "22 verwenden" (siehe SWITCH_DEFAULT_SSH_PORT-Fallback). if "ssh_port" not in switch_cols: conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER") - # 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. device_ssh_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()} if "credential_id" not in device_ssh_cols: conn.execute("ALTER TABLE devices ADD COLUMN credential_id INTEGER") if "ssh_port" not in device_ssh_cols: conn.execute("ALTER TABLE devices ADD COLUMN ssh_port INTEGER") - # 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. for _table in ("devices", "switches", "credentials", "users", "groups"): _cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({_table})").fetchall()} if "deleted_at" not in _cols: conn.execute(f"ALTER TABLE {_table} ADD COLUMN deleted_at TEXT") - # DHCP: eigene Options-Definitionen + Werte (global/per-Client, - # siehe create_db.py für die ausführliche Begründung von device_mac=''). conn.execute(""" CREATE TABLE IF NOT EXISTS dhcp_option_defs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -954,8 +777,6 @@ def _ensure_schema(): is_standard INTEGER NOT NULL DEFAULT 0 ) """) - # Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus - # einer Version ohne vorbefüllte Standard-Optionen stammt. option_def_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_option_defs)").fetchall()} if "is_standard" not in option_def_cols: conn.execute("ALTER TABLE dhcp_option_defs ADD COLUMN is_standard INTEGER NOT NULL DEFAULT 0") @@ -969,17 +790,6 @@ def _ensure_schema(): UNIQUE (option_def_id, device_mac) ) """) - # 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. _migration_key_std_opts = "_seeded_dhcp_standard_options_v1" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts,)).fetchone(): for code, name, opt_type, description in DHCP_STANDARD_OPTIONS: @@ -989,10 +799,6 @@ def _ensure_schema(): (code, name, opt_type, description), ) conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,)) - # 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. _migration_key_std_opts_v2 = "_seeded_dhcp_standard_options_v2" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v2,)).fetchone(): for code, name, opt_type, description in DHCP_STANDARD_OPTIONS: @@ -1002,14 +808,6 @@ def _ensure_schema(): (code, name, opt_type, description), ) conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v2,)) - # 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. _migration_key_std_opts_v3 = "_removed_dhcp_standard_option_hostname_v3" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v3,)).fetchone(): stale = conn.execute("SELECT id FROM dhcp_option_defs WHERE name='host-name' AND is_standard=1").fetchone() @@ -1018,12 +816,6 @@ def _ensure_schema(): conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (stale["id"],)) conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v3,)) - # 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. conn.execute(""" CREATE TABLE IF NOT EXISTS dhcp_manual_reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1033,12 +825,6 @@ def _ensure_schema(): ) """) - # 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. conn.execute(""" CREATE TABLE IF NOT EXISTS dhcp_subnets ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1051,18 +837,9 @@ def _ensure_schema(): enabled INTEGER NOT NULL DEFAULT 1 ) """) - # Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus - # einer Version ohne Deaktivieren-Funktion stammt. dhcp_subnet_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_subnets)").fetchall()} if "enabled" not in dhcp_subnet_cols: conn.execute("ALTER TABLE dhcp_subnets ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1") - # 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. _migration_key_dhcp = "_migrated_dhcp_single_range_to_subnets_v1" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_dhcp,)).fetchone(): legacy_start = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_start'").fetchone() @@ -1083,8 +860,6 @@ def _ensure_schema(): ) conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_dhcp,)) - # Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten - # (ältere DB-Version) in eigene Credentials-Datensätze überführen. if "username" in switch_cols and "password" in switch_cols: legacy_switches = conn.execute( "SELECT hostname, username, password FROM switches WHERE credential_id IS NULL" @@ -1104,12 +879,6 @@ def _ensure_schema(): (cur.lastrowid, sw["hostname"]), ) - # 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. _migration_key = "_migrated_permission_hierarchy_v1" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone(): toggle_groups = conn.execute( @@ -1168,14 +937,6 @@ def _ensure_schema(): "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,) ) - # 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). _migration_key_v2 = "_migrated_permission_delete_fold_v2" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_v2,)).fetchone(): _delete_to_edit = { @@ -1200,14 +961,6 @@ def _ensure_schema(): "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,) ) - # Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu - # angelegten rpi-.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. _migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1" if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone(): for stale_log in glob.glob("/var/log/rpi-*.log"): @@ -1217,11 +970,6 @@ def _ensure_schema(): pass conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_old_logs,)) - # 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. cur = conn.execute( "INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", (DEFAULT_GROUP_NAME,), @@ -1236,9 +984,6 @@ def _ensure_schema(): [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS], ) - # Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der - # Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen - # durch die Einführung des Rechtesystems keinen Zugriff verlieren). orphan_users = conn.execute(""" SELECT users.id FROM users LEFT JOIN user_groups ON user_groups.user_id = users.id @@ -1260,10 +1005,6 @@ def _build_user(row): """Baut ein vollständiges User-Objekt inkl. effektiver Rechte aus allen Gruppen, denen der Benutzer angehört (Vereinigung, nicht Schnittmenge).""" conn = get_db_connection() - # 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. group_rows = conn.execute(""" SELECT g.id, g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id @@ -1439,9 +1180,6 @@ _write_logrotate_config() @login_manager.user_loader def load_user(user_id): conn = get_db_connection() - # deleted_at IS NULL: eine in den Papierkorb verschobene Benutzerin - # verliert damit sofort jede bereits laufende Sitzung, nicht erst beim - # nächsten Login-Versuch. user = conn.execute("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)).fetchone() conn.close() if user: @@ -1449,26 +1187,6 @@ def load_user(user_id): return None -# --------------------------------------------------------------------------- -# LDAP / Active Directory — optionale Anmeldung mit dem Domänen-Passwort -# zusätzlich zu lokalen Konten (siehe Systemeinstellungen → LDAP). -# -# 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. -# -# 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. -# --------------------------------------------------------------------------- LDAP_DEFAULT_PORT = 389 LDAP_DEFAULT_FILTER_ATTR = "sAMAccountName" @@ -1510,9 +1228,6 @@ def _ldap_settings(): "bind_password_enc": bind_password_enc, "base_dn": get_setting("ldap_base_dn", "") or "", "filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR, - # Fallback für Benutzer, die zu KEINER konfigurierten AD-Gruppen- - # Zuordnung passen (siehe ldap_group_mappings/_ldap_resolve_app_groups) - # -- leer = Systemstandardgruppe. "default_group": get_setting("ldap_default_group", "") or "", } @@ -1530,8 +1245,6 @@ def _ldap_connect_service(cfg): except Exception: return None, "Bind-Passwort konnte nicht entschlüsselt werden." try: - # tls nur relevant, wenn use_ssl gesetzt ist -- ldap3 ignoriert es - # sonst ohnehin, aber so bleibt die Absicht im Code eindeutig. tls = None if cfg["use_ssl"] and cfg.get("tls_skip_verify"): tls = Tls(validate=ssl.CERT_NONE) @@ -1630,7 +1343,7 @@ def _ldap_entry_info(entry, cfg): uac = _attr("userAccountControl") if uac is not None: try: - disabled = bool(int(uac) & 2) # ACCOUNTDISABLE-Bit, nur bei AD vorhanden + disabled = bool(int(uac) & 2) except ValueError: pass @@ -1638,12 +1351,6 @@ def _ldap_entry_info(entry, cfg): "username": canonical_username, "first_name": first_name, "last_name": last_name, - # "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. "email": _attr("userPrincipalName"), "disabled": disabled, } @@ -1788,10 +1495,6 @@ def _ldap_list_groups(limit=300): def _ldap_group_mappings(): conn = get_db_connection() - # 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. rows = conn.execute(""" SELECT m.id, m.ad_group_dn, m.ad_group_name, m.app_group_id, CASE @@ -1850,10 +1553,6 @@ def _ldap_authenticate(username, password): info = _ldap_entry_info(entry, cfg) if info["disabled"]: return False, None - # 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). info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn) except LDAPException: return False, None @@ -1875,9 +1574,6 @@ def _ldap_authenticate(username, password): return True, info -# --------------------------------------------------------------------------- -# Auth -# --------------------------------------------------------------------------- def _assign_ldap_groups(conn, user_id, app_groups): """Weist die per AD-Gruppenzuordnung ermittelten App-Gruppen zu — rein @@ -1889,9 +1585,6 @@ def _assign_ldap_groups(conn, user_id, app_groups): for group_id in app_groups: if group_id == "admin": continue - # 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. if not conn.execute("SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)).fetchone(): continue conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, group_id)) @@ -1900,10 +1593,6 @@ def _assign_ldap_groups(conn, user_id, app_groups): def _ldap_default_group_id(conn): default_group = get_setting("ldap_default_group", "") if default_group: - # 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. if conn.execute( "SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (default_group,) ).fetchone(): @@ -1921,23 +1610,10 @@ def login(): password = request.form["password"] conn = get_db_connection() - # 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). user = conn.execute( "SELECT * FROM users WHERE username = ? AND deleted_at IS NULL", (username,) ).fetchone() - # 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. if not user: user = conn.execute( "SELECT * FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '' " @@ -1945,11 +1621,6 @@ def login(): (username,), ).fetchone() - # 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. if not user: user = conn.execute( "SELECT * FROM users WHERE auth_source='ldap' AND username = ? COLLATE NOCASE AND deleted_at IS NULL", @@ -1957,24 +1628,13 @@ def login(): ).fetchone() if user and user["is_locked"]: - # 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. conn.close() flash("Dieses Konto ist gesperrt.", "danger") return render_template("login.html") if user and user["auth_source"] == "ldap": - # 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. ok, info = _ldap_authenticate(user["username"], password) if ok: - # 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). conn.execute( "UPDATE users SET first_name=?, last_name=?, email=? WHERE id=?", (info.get("first_name"), info.get("last_name"), info.get("email"), user["id"]), @@ -1996,10 +1656,6 @@ def login(): flash("Ungültiger Benutzername oder Passwort", "danger") else: - # 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. ok, info = _ldap_authenticate(username, password) if ok: canonical_username = info.get("username") or username @@ -2041,20 +1697,9 @@ def login(): @login_required def logout(): logout_user() - # 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. return redirect(url_for("index")) -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- @app.route("/account") @login_required @@ -2080,10 +1725,6 @@ def profile(): elif "change_password" in request.form: if current_user.is_ldap_user: - # 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. flash("Dieses Konto meldet sich über Active Directory an — das Passwort wird dort verwaltet.", "danger") conn.close() return redirect(url_for("account")) @@ -2128,9 +1769,6 @@ def profile(): return redirect(request.referrer or url_for("index")) -# --------------------------------------------------------------------------- -# Log-Auswertung (Erreichbarkeits-Status je Gerät) -# --------------------------------------------------------------------------- def _latest_log_file(): """Aktuelles Live-Log — seit der Umstellung auf eine einzige, per @@ -2286,9 +1924,6 @@ def get_device_status(devices): return status_dict, last_seen_dict, last_checked_dict, last_run_at -# --------------------------------------------------------------------------- -# Dashboard -# --------------------------------------------------------------------------- def _dashboard_context(): """Gemeinsam von index() und dem AJAX-Partial /dashboard/tiles genutzt, @@ -2316,7 +1951,7 @@ def _dashboard_context(): disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else [] visible_devices = offline_devices + online_devices + disabled_devices - devices = offline_devices + online_devices # für Kompatibilität/Zähler + devices = offline_devices + online_devices stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)} if is_authenticated: @@ -2364,7 +1999,7 @@ def _restart_check_service(): try: subprocess.run(["systemctl", "restart", "tesm-check.service"], check=False) except FileNotFoundError: - pass # z.B. lokale Entwicklungsumgebung ohne systemd + pass @app.route("/check/run-now", methods=["POST"]) @@ -2382,11 +2017,6 @@ def run_check_now(): return {"success": False, "msg": "Keine Berechtigung."}, 403 _restart_check_service() log_action("check.run_now", "Prüfintervall") - # 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). time.sleep(1.5) last_run_at = get_last_run_at() return { @@ -2396,24 +2026,10 @@ def run_check_now(): } -# --------------------------------------------------------------------------- -# Settings -# --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- NETWORK_REVERT_SECONDS = 45 -_pending_network_revert = {} # {"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. +_pending_network_revert = {} NETPLAN_CONFIG_PATH = "/etc/netplan/90-tesm.yaml" @@ -2429,11 +2045,6 @@ def _detect_network_backend(): return name except Exception: pass - # 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). try: result = subprocess.run(["systemctl", "is-active", "systemd-networkd"], capture_output=True, text=True, timeout=5) if result.stdout.strip() == "active" and shutil.which("netplan"): @@ -2467,12 +2078,6 @@ def _read_configured_dns(interface): if shutil.which("resolvectl"): ok, out = _dhcp_run_privileged(["resolvectl", "dns", interface], timeout=5) if ok: - # 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:..."). for line in out.splitlines(): stripped = line.strip() if not stripped: @@ -2482,11 +2087,6 @@ def _read_configured_dns(interface): if not servers: servers = _read_resolv_conf_dns() servers = [s for s in servers if s not in _RESOLVED_STUB_ADDRESSES] - # 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. ipv4_servers = [] for s in servers: try: @@ -2530,9 +2130,6 @@ def _read_network_state(interface, backend): except OSError: mode = "dhcp" elif backend == "netplan": - # 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). if net_info.get("ok"): mode = "dhcp" if net_info.get("dynamic") else "static" return {**net_info, "dns": _read_configured_dns(interface), "mode": mode, "backend": backend} @@ -2563,9 +2160,6 @@ def _backup_network_config(interface, backend): except Exception: backup["nm_connection"] = None elif backend == "netplan": - # 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. try: with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f: backup["netplan_conf"] = f.read() @@ -2635,22 +2229,9 @@ def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_lis else: eth_cfg["dhcp4"] = True if dns_list: - # 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. eth_cfg["dhcp4-overrides"] = {"use-dns": False} if dns_list: eth_cfg["nameservers"] = {"addresses": dns_list} - # 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). try: with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f: existing_doc = yaml.safe_load(f) or {} @@ -2659,11 +2240,6 @@ def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_lis ethernets = ((existing_doc.get("network") or {}).get("ethernets") or {}).copy() ethernets[interface] = eth_cfg doc = {"network": {"version": 2, "ethernets": ethernets}} - # 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. with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f: yaml.safe_dump(doc, f, default_flow_style=False) os.chmod(NETPLAN_CONFIG_PATH, 0o600) @@ -2698,19 +2274,11 @@ def _revert_network_config(token): with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f: f.write(backup["netplan_conf"]) else: - # Datei existierte vor der Änderung nicht (erste Netplan- - # Änderung über diese Seite) — dann muss sie beim Rollback - # auch wieder verschwinden statt leer zurückzubleiben. try: os.remove(NETPLAN_CONFIG_PATH) except OSError: pass subprocess.run(["netplan", "apply"], timeout=20) - # 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. if _stop_dhcp_service_if_active(): log_action_system("settings.network_revert", backup["interface"], "DHCP-Dienst wegen Rollback gestoppt") log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt") @@ -2764,24 +2332,15 @@ def settings(): token = secrets.token_hex(8) timer = threading.Timer(NETWORK_REVERT_SECONDS, _revert_network_config, args=(token,)) timer.daemon = True - _pending_network_revert.clear() # nur eine ausstehende Änderung gleichzeitig + _pending_network_revert.clear() _pending_network_revert[token] = {"timer": timer, "backup": backup} timer.start() - # 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. set_setting("net_interface", interface) log_action("settings.network_apply", interface, f"Modus {mode}") msg = ( f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte " f"unten bestätigen — sonst wird nach {NETWORK_REVERT_SECONDS}s automatisch zurückgerollt." ) - # 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. if _stop_dhcp_service_if_active(): log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt") msg += ( @@ -2812,12 +2371,6 @@ def settings(): else: ok, out = _dhcp_run_privileged(["timedatectl", "set-timezone", new_tz], timeout=10) if ok: - # 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. time.tzset() log_action("settings.update", "Zeitzone", new_tz) flash(f"Zeitzone auf „{new_tz}“ gesetzt.", "success") @@ -2864,9 +2417,6 @@ def settings(): log_action("settings.update", "Papierkorb sofort bereinigt") flash("Abgelaufene Papierkorb-Einträge wurden endgültig gelöscht.", "success") - # LDAP/Active Directory hat seit dem Umzug auf eine eigene - # Unterseite (Übersichtlichkeit) eine eigene Route -- siehe - # settings_ldap(). return redirect(url_for("settings")) @@ -2874,12 +2424,6 @@ def settings(): all_interfaces = _list_network_interfaces() net_interface = get_setting("net_interface") or (all_interfaces or [None])[0] net_state = _read_network_state(net_interface, net_backend) if net_interface else None - # Ü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). active_net_states = [ {"interface": iface, **_read_network_state(iface, net_backend)} for iface in all_interfaces @@ -2932,9 +2476,6 @@ def settings_network_state(): return jsonify({"error": "Unbekanntes Interface."}), 404 backend = _detect_network_backend() state = _read_network_state(interface, backend) - # "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). state.pop("network", None) return jsonify(state) @@ -2952,30 +2493,11 @@ def settings_import_export(): ) -# --------------------------------------------------------------------------- -# DHCP-Reservierungen (Kea DHCP) -# -# 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. -# --------------------------------------------------------------------------- DHCP_PACKAGE = "kea-dhcp4-server" DHCP_SERVICE = "kea-dhcp4-server" -# 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. KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv" -# 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. DHCP_DEFAULTS = { "dhcp_domain": "local", "dhcp_lease_default": "600", @@ -2997,9 +2519,6 @@ def _dhcp_subnet_matches(subnet_row): die CIDR/Netzmaske, die Kea für den subnet4-Block braucht.""" return _dhcp_matching_network(subnet_row["interface"], subnet_row["range_start"], subnet_row["range_end"]) -# Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible -# Teilmenge statt der vollen Liste, orientiert an den in der Praxis -# gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex). DHCP_OPTION_TYPES = [ ("string", "Text (string)"), ("ipv4-address", "IP-Adresse (ipv4-address)"), @@ -3103,9 +2622,6 @@ def _detect_interface_networks(interface): ) gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout) if not gw_match: - # Fallback: irgendeine Default-Route, falls keine speziell an - # dieses Interface gebunden ist (typisch bei Hosts mit nur - # einer aktiven Schnittstelle). route_out = subprocess.run( ["ip", "-4", "route", "show", "default"], capture_output=True, text=True, timeout=5, ) @@ -3119,9 +2635,6 @@ def _detect_interface_networks(interface): continue ip_str, prefix = match.group(1), int(match.group(2)) network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False) - # 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). dynamic = "dynamic" in line results.append({ "ok": True, "ip": ip_str, "prefix": prefix, "network": network, @@ -3236,9 +2749,7 @@ def _dhcp_reservation_candidates(conn, valid_subnets): ).fetchall() manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall() - # Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können - # unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer. - candidates = {} # MAC (upper) -> {mac, ip, name, source, manual_id} + candidates = {} for row in manual_rows: candidates[row["mac"].upper()] = { "mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "manual", "manual_id": row["id"], @@ -3262,9 +2773,6 @@ def _dhcp_reservation_candidates(conn, valid_subnets): out_of_subnet += 1 continue hostname = _dhcp_safe_hostname(c["name"], c["mac"]) - # 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). seen = seen_hostnames_by_subnet[matched["id"]] base_hostname, suffix = hostname, 2 while hostname in seen: @@ -3309,32 +2817,20 @@ def _dhcp_read_leases(): except OSError: return None now = int(time.time()) - # 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 - # eine tatsächlich vergebene, gültige Adresse. if (row.get("state") or "0") != "0": continue try: expire = int(row.get("expire") or 0) except ValueError: expire = 0 - # Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc) - # 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 + continue by_ip[ip] = {"expire": expire, "row": row} leases = [] for ip, entry in by_ip.items(): @@ -3343,12 +2839,6 @@ def _dhcp_read_leases(): "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 - # 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. "expires_at": datetime.utcfromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None, "expire_epoch_ms": expire * 1000 if expire else None, }) @@ -3380,21 +2870,10 @@ def _dhcp_delete_lease(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: @@ -3426,10 +2905,6 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, Reservierung — Kea überschreibt damit automatisch gleichnamige globale/Subnet-Options für genau diesen Client, ohne dass wir das selbst zusammenrechnen müssen.""" - # 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. option_def_list = [ {"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"} for d in option_defs if not d["is_standard"] @@ -3445,10 +2920,6 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, subnet4 = [] for idx, s in enumerate(valid_subnets, start=1): - # 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. router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or "" dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip()) sub_option_data = [] @@ -3484,28 +2955,12 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, return { "Dhcp4": { "interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})}, - # 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). "lease-database": {"type": "memfile", "lfc-interval": 3600, "name": KEA_LEASE_FILE}, "valid-lifetime": int(cfg["dhcp_lease_default"] or 600), "max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200), "option-def": option_def_list, "option-data": global_option_data, "subnet4": subnet4, - # 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. "loggers": [ { "name": "kea-dhcp4", @@ -3602,9 +3057,6 @@ def settings_dhcp(): return redirect(url_for("settings_dhcp")) if "save_dhcp_config" in request.form: - # 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). for key in DHCP_DEFAULTS: value = request.form.get(key, "").strip() if value: @@ -3870,11 +3322,6 @@ def settings_dhcp(): elif "dhcp_service_action" in request.form: action = request.form.get("dhcp_service_action") - # 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. valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn)) if action == "enable_restart" and not valid_subnets_now: flash( @@ -3892,12 +3339,6 @@ def settings_dhcp(): flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger") elif action == "stop": ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15) - # 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. _dhcp_run_privileged(["systemctl", "disable", DHCP_SERVICE], timeout=15) log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen")) flash(f"{DHCP_SERVICE} gestoppt und deaktiviert (startet nach einem Neustart nicht automatisch wieder)." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger") @@ -3921,17 +3362,11 @@ def settings_dhcp(): _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values), indent=2, ensure_ascii=False, ) - # 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. reserved_macs = {r["mac"].upper() for r in reservations} leases = _dhcp_read_leases() if leases is not None: for lease in leases: lease["reserved"] = lease["mac"] in reserved_macs - # 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. subnet_status = {} for vs in valid_subnets: subnet_status[vs["id"]] = {"ok": True, "network": str(vs["net_info"]["network"])} @@ -4024,13 +3459,6 @@ def settings_ldap(): set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip()) set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR) set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip()) - # 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). new_bind_password = request.form.get("ldap_bind_password", "") if new_bind_dn: conn = get_db_connection() @@ -4056,10 +3484,6 @@ def settings_ldap(): flash("LDAP-Einstellungen gespeichert.", "success") elif "test_ldap" in request.form: - # 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. new_bind_password = request.form.get("ldap_bind_password", "") _, saved_bind_password_enc = _ldap_bind_account() test_cfg = { @@ -4079,9 +3503,6 @@ def settings_ldap(): conn.execute("DELETE FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)) conn.commit() conn.close() - # Ohne Bind-Konto kann LDAP nicht funktionieren -- vorsorglich - # mit deaktivieren, statt eine "aktivierte", aber unbenutzbare - # Konfiguration stehen zu lassen. set_setting("ldap_enabled", "0") log_action("settings.update", "LDAP-Bind-Konto gelöscht") flash("LDAP-Bind-Konto gelöscht. LDAP-Anmeldung wurde deaktiviert.", "success") @@ -4146,8 +3567,6 @@ def save_nav_order(): order += [k for k in DEFAULT_NAV_ORDER if k not in order] set_setting("nav_order", json.dumps(order)) - # Unterpunkt-Reihenfolge je Gruppe — ein Feld "nav_child_order_" - # pro Gruppe mit Kindern, Werte in der gewünschten Reihenfolge. child_order = {} for group_key, item in NAV_ITEMS_BY_KEY.items(): if not item.get("children"): @@ -4163,19 +3582,6 @@ def save_nav_order(): return redirect(url_for("account")) -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- EXPORT_SECTIONS = [ ("devices", "Clients"), @@ -4189,11 +3595,6 @@ EXPORT_SECTIONS = [ ] EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS) -# 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). IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"} LOG_EXPORT_LIMIT = 500 @@ -4204,9 +3605,6 @@ LDAP_SETTING_KEYS = [ def _export_devices(conn): - # 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. return [dict(r) for r in conn.execute( "SELECT mac, ip, port, name, switch_hostname, is_active FROM devices WHERE deleted_at IS NULL" ).fetchall()] @@ -4226,10 +3624,6 @@ def _export_credentials(conn): def _export_users(conn): - # 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()). rows = conn.execute( "SELECT id, username, password, first_name, last_name, email, is_admin, is_locked " "FROM users WHERE auth_source='local' AND deleted_at IS NULL" @@ -4299,7 +3693,7 @@ def _export_dhcp(conn): "SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?", (d["id"],) ).fetchall() if not values and d["is_standard"]: - continue # unveränderte Standard-Option -- wird auf dem Ziel automatisch angelegt + continue options.append({ "code": d["code"], "name": d["name"], "type": d["type"], "description": d["description"], "is_standard": d["is_standard"], @@ -4325,10 +3719,6 @@ EXPORT_BUILDERS = { def _import_devices(conn, items): - # 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). n = 0 for d in items: conn.execute( @@ -4375,9 +3765,6 @@ def _import_credentials(conn, items): def _import_users(conn, items): - # AD/LDAP-Konten mit gleichem Benutzernamen werden nie überschrieben -- - # deren Konto/Passwort wird über Active Directory/Windows verwaltet, - # nicht über einen Import hier. n = 0 for u in items: existing = conn.execute( @@ -4386,9 +3773,6 @@ def _import_users(conn, items): if existing and existing["auth_source"] != "local": continue if existing and existing["deleted_at"] is not None: - # Im Papierkorb liegender Benutzername bleibt reserviert -- ein - # Import darf ihn nicht stillschweigend wiederbeleben (siehe - # Wartung-Seite: erst wiederherstellen oder endgültig löschen). continue if existing: conn.execute( @@ -4417,9 +3801,6 @@ def _import_users(conn, items): def _import_groups(conn, items): - # 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. n = 0 for g in items: existing = conn.execute( @@ -4448,15 +3829,6 @@ def _import_groups(conn, items): def _import_ldap(conn, data): - # 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. for key, value in data.get("settings", {}).items(): if key in LDAP_SETTING_KEYS: conn.execute( @@ -4482,9 +3854,6 @@ def _import_ldap(conn, data): def _import_dhcp(conn, data): - # 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). for key, value in data.get("settings", {}).items(): if key in DHCP_DEFAULTS: conn.execute( @@ -4493,8 +3862,6 @@ def _import_dhcp(conn, data): (key, value), ) n = 0 - # Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig - # vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen. for s in data.get("subnets", []): exists = conn.execute( "SELECT 1 FROM dhcp_subnets WHERE interface=? AND range_start=? AND range_end=?", @@ -4547,11 +3914,6 @@ IMPORT_APPLIERS = { "logs": _import_logs, } -# 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. _pending_imports = {} PENDING_IMPORT_TTL_SECONDS = 900 @@ -4566,11 +3928,6 @@ def _purge_expired_imports(): @app.route("/settings/export", methods=["POST"]) @login_required def export_data(): - # 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. if not current_user.has_permission("settings_importexport.export"): flash("Keine Berechtigung, Daten zu exportieren.", "danger") return redirect(url_for("index")) @@ -4643,9 +4000,6 @@ def import_data(): flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger") return redirect(url_for("settings_import_export")) - # 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. present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)] if not present_sections: flash("Die Datei enthält keine bekannten/unterstützten Daten.", "danger") @@ -4720,9 +4074,6 @@ def import_apply(): return redirect(url_for("settings_import_export")) -# --------------------------------------------------------------------------- -# Devices -# --------------------------------------------------------------------------- @app.route("/devices", methods=["GET", "POST"]) @login_required @@ -4761,9 +4112,6 @@ def devices(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) - # 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). ssh_port, port_error = _parse_ssh_port(request.form) credential_id, cred_error = (None, None) if request.form.get("credential_choice"): @@ -4800,7 +4148,6 @@ def devices(): name = request.form.get("name") switch_hostname_field = "switch_hostname" in request.form - # --- Nur Switch-Zuordnung ändern (Modal "Switch ändern") --- if switch_hostname_field and not any([mac, ip, name]): device = conn.execute( "SELECT name, switch_hostname FROM devices WHERE mac=? AND deleted_at IS NULL", (old_mac,) @@ -4823,7 +4170,6 @@ def devices(): ) return redirect(url_for("devices")) - # --- Normales Gerät bearbeiten --- if not all([old_mac, mac, ip, name]): flash("Felder 'MAC', 'IP' und 'Name' müssen ausgefüllt sein!", "danger") return redirect(url_for("devices")) @@ -4865,7 +4211,6 @@ def devices(): flash("Fehler beim Aktualisieren des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung).", "danger") elif "delete_device" in request.form: - # "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht). if not current_user.has_permission("devices.edit"): flash("Keine Berechtigung, Geräte zu löschen.", "danger") return redirect(url_for("devices")) @@ -4876,8 +4221,6 @@ def devices(): "SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (del_mac,) ).fetchone() name = device["name"] if device else del_mac - # Papierkorb statt echtem Löschen -- siehe Wartung-Seite für - # Wiederherstellen/endgültiges Löschen. conn.execute( "UPDATE devices SET deleted_at=? WHERE mac=? AND deleted_at IS NULL", (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), del_mac), @@ -4912,8 +4255,6 @@ def devices(): @app.route("/devices/toggle/", methods=["POST"]) @login_required def toggle_device(mac): - # Aktivieren/Deaktivieren zählt jetzt zu "Ändern" (devices.edit) statt - # eines eigenen devices.toggle-Rechts. if not current_user.has_permission("devices.edit"): return {"success": False, "msg": "Keine Berechtigung."}, 403 @@ -4936,9 +4277,6 @@ def toggle_device(mac): return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status} -# --------------------------------------------------------------------------- -# Switches -# --------------------------------------------------------------------------- def _resolve_credential_choice(conn, default_category="switch"): """ @@ -5074,7 +4412,6 @@ def switches(): @app.route("/switches/delete/", methods=["POST"]) @login_required def delete_switch(hostname): - # "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht). if not current_user.has_permission("switches.edit"): flash("Keine Berechtigung, Switche zu löschen.", "danger") return redirect(url_for("switches")) @@ -5089,8 +4426,6 @@ def delete_switch(hostname): conn.close() return redirect(url_for("switches")) - # Papierkorb statt echtem Löschen -- siehe Wartung-Seite für - # Wiederherstellen/endgültiges Löschen. conn.execute( "UPDATE switches SET deleted_at=? WHERE hostname=? AND deleted_at IS NULL", (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), hostname), @@ -5102,11 +4437,6 @@ def delete_switch(hostname): return redirect(url_for("switches")) -# --------------------------------------------------------------------------- -# 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). -# --------------------------------------------------------------------------- @app.route("/credentials", methods=["GET", "POST"]) @login_required @@ -5174,7 +4504,6 @@ def credentials(): flash("Name und Username müssen ausgefüllt sein!", "danger") elif request.method == "POST" and "delete_credential" in request.form: - # "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht). if not current_user.has_permission("credentials.edit"): flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") return redirect(url_for("credentials")) @@ -5192,8 +4521,6 @@ def credentials(): cred_row = conn.execute( "SELECT name FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,) ).fetchone() - # Papierkorb statt echtem Löschen -- siehe Wartung-Seite für - # Wiederherstellen/endgültiges Löschen. conn.execute( "UPDATE credentials SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), cred_id), @@ -5217,23 +4544,8 @@ def credentials(): return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES) -# --------------------------------------------------------------------------- -# Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients. -# -# 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). -# --------------------------------------------------------------------------- _maintenance_jobs_lock = threading.Lock() -# mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error", -# "message": str, "output": str, "started": str, "finished": str|None} _maintenance_jobs = {} @@ -5267,16 +4579,6 @@ def _maintenance_devices(conn): ).fetchall() -# --------------------------------------------------------------------------- -# 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. -# --------------------------------------------------------------------------- TRASH_RETENTION_DEFAULT_DAYS = 30 @@ -5321,9 +4623,6 @@ def _trashed_groups(conn): def _purge_device(conn, mac): - # 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. conn.execute("DELETE FROM dhcp_option_values WHERE device_mac=?", (mac,)) conn.execute("DELETE FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)) @@ -5344,9 +4643,6 @@ def _purge_user(conn, user_id): def _purge_group(conn, group_id): conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) - # 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). conn.execute("DELETE FROM ldap_group_mappings WHERE app_group_id=?", (group_id,)) conn.execute("DELETE FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)) @@ -5561,14 +4857,6 @@ def maintenance(): devices_rows = _maintenance_devices(conn) conn.close() with _maintenance_jobs_lock: - # 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. for mac in [m for m, job in _maintenance_jobs.items() if job.get("status") == "success"]: del _maintenance_jobs[mac] jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()} @@ -5586,9 +4874,6 @@ def papierkorb(): if not current_user.can_view_papierkorb: flash("Keine Berechtigung für den Papierkorb.", "danger") return redirect(url_for("index")) - # 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. _purge_expired_trash() conn = get_db_connection() trash = { @@ -5646,13 +4931,6 @@ def _maintenance_run_update(mac, name, host, port, username, password): output="", started=now, finished=None, ) - # 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. lines = [] def on_line(line, is_err): @@ -5661,18 +4939,11 @@ def _maintenance_run_update(mac, name, host, port, username, password): del lines[: len(lines) - _MAINTENANCE_OUTPUT_MAX_LINES] _maintenance_job_set(mac, output="\n".join(lines)) - # 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. result = _run_ssh_command_streaming( host, port, username, password, _apt_upgrade_command(), on_line, sudo_password=password, overall_timeout=600, ) finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # 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. if result["error"]: lines.append(f"{finished} ✖ Update abgebrochen: {result['error']}") _maintenance_job_set(mac, status="error", message=result["error"], output="\n".join(lines), finished=finished) @@ -5692,11 +4963,6 @@ def _maintenance_run_update(mac, name, host, port, username, password): log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}") -# 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 "), für konsistentes Verhalten mit dem Dashboard. _MAINTENANCE_REBOOT_GRACE_SECONDS = 15 _MAINTENANCE_REBOOT_TIMEOUT_SECONDS = 300 _MAINTENANCE_REBOOT_POLL_INTERVAL = 5 @@ -5814,17 +5080,6 @@ def maintenance_reboot(): return _maintenance_dispatch("reboot", _maintenance_run_reboot) -# --------------------------------------------------------------------------- -# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen. -# -# Ö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. -# --------------------------------------------------------------------------- _SSH_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9\.\-:]{0,253}$") _SSH_USER_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$") @@ -5842,7 +5097,7 @@ def _send_and_close(ws, message): ws.send(message) time.sleep(0.15) except ConnectionClosed: - pass # Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall. + pass try: ws.close() except Exception: @@ -5878,7 +5133,7 @@ def _terminal_read_line(ws, echo=True): line = line[:-1] if echo: ws.send("\b \b") - elif ch == "\x03": # Strg+C + elif ch == "\x03": ws.send("^C\r\n") return None else: @@ -5952,17 +5207,6 @@ def _authenticate_interactive(ws, transport, username): if transport.is_authenticated(): return True - # "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. if "password" in allowed: ws.send("\r\nPasswort: ") pw = _terminal_read_line(ws, echo=False) @@ -5972,12 +5216,6 @@ def _authenticate_interactive(ws, transport, username): pass elif "keyboard-interactive" in allowed: def handler(title, instructions, prompt_list): - # 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. for text in (title, instructions): if text: ws.send(f"\r\n{text}") @@ -5998,8 +5236,6 @@ def _authenticate_interactive(ws, transport, username): @sock.route("/ws/ssh_terminal") def ssh_terminal(ws): - # Fängt unerwartete Fehler ab und loggt sie, statt sie in flask-sock's - # Verbindungs-Handling verschwinden zu lassen. try: return _ssh_terminal_inner(ws) except Exception: @@ -6029,14 +5265,6 @@ def _ssh_terminal_inner(ws): ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port} …\x1b[0m\r\n") - # 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). try: sock_conn = socket.create_connection((host, port), timeout=10) except OSError as e: @@ -6136,28 +5364,7 @@ def _ssh_terminal_inner(ws): pass -# --------------------------------------------------------------------------- -# Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart) -# -# 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. -# --------------------------------------------------------------------------- -# 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). _LINUX_UPDATE_STEPS = [ ("apt update", "apt-get update"), ("initramfs MODULES=most setzen", "sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf"), @@ -6308,8 +5515,6 @@ def _run_ssh_command_streaming(host, port, username, password, command, on_line, return result if not got: time.sleep(0.2) - # Ein letzter, nicht mit "\n" abgeschlossener Rest zählt noch als Zeile - # (apt beendet die letzte Ausgabezeile nicht immer mit Zeilenumbruch). if out_buf: line = bytes(out_buf).decode("utf-8", errors="replace") out_parts.append(line) @@ -6439,7 +5644,7 @@ def _run_ssh_reboot(host, port, username, password, timeout=15): stdin.flush() stdout.channel.recv_exit_status() except (paramiko.SSHException, OSError, EOFError): - pass # Verbindungsabbruch durch den Neustart selbst ist erwartet + pass finally: try: client.close() @@ -6450,9 +5655,6 @@ def _run_ssh_reboot(host, port, username, password, timeout=15): return result -# --------------------------------------------------------------------------- -# Logs -# --------------------------------------------------------------------------- @app.route("/get_log") @login_required @@ -6467,9 +5669,6 @@ def get_log(): content = f.read() except Exception as e: content = f"Fehler beim Lesen des Logs: {e}" - # 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. response = app.response_class(content, mimetype="text/plain") response.headers["X-Log-Name"] = os.path.basename(latest_log) return response @@ -6558,9 +5757,6 @@ def activity_log(): return render_template("activity_log.html", entries=entries, avatars=avatars) -# --------------------------------------------------------------------------- -# Manueller PoE-Neustart -# --------------------------------------------------------------------------- @app.route("/restart/", methods=["POST"]) @login_required @@ -6580,9 +5776,6 @@ def restart_device(mac): if device["is_active"] == 0: return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400 - # 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. if not device["switch_hostname"] or not device["port"]: return jsonify({"success": False, "message": f"{device['name']} hat keinen Switch/Port zugewiesen — PoE-Neustart nicht möglich."}), 400 @@ -6598,9 +5791,6 @@ def restart_device(mac): return jsonify({"success": False, "message": str(e)}), 500 -# --------------------------------------------------------------------------- -# Users -# --------------------------------------------------------------------------- @app.route("/users", methods=["GET", "POST"]) @login_required @@ -6622,11 +5812,6 @@ def users(): first_name = request.form.get("first_name", "").strip() or None last_name = request.form.get("last_name", "").strip() or None email = request.form.get("email", "").strip() or None - # 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. group_choice = request.form.get("group_id") or "" if group_choice == "admin" and not current_user.is_admin: flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger") @@ -6659,13 +5844,7 @@ def users(): flash("Keine Berechtigung, Benutzer zu bearbeiten.", "danger") return redirect(url_for("users")) - # Nur Stammdaten + optional Passwort — Gruppe/Rolle wird - # ausschließlich über "Gruppe zuweisen" geändert (s.u.). user_id = request.form["user_id"] - # 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. target_user = conn.execute( "SELECT is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) ).fetchone() @@ -6673,11 +5852,6 @@ def users(): flash("Nur Admins dürfen Admin-Konten bearbeiten.", "danger") return redirect(url_for("users")) - # 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). if target_user and target_user["auth_source"] == "ldap": flash("AD/LDAP-Konten werden über Active Directory verwaltet und können hier nicht bearbeitet werden — nur sperren oder eine Gruppe zuweisen.", "danger") return redirect(url_for("users")) @@ -6715,11 +5889,6 @@ def users(): user_id = request.form["user_id"] choice = request.form.get("group_id") or "" - # 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. target = conn.execute( "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) ).fetchone() @@ -6727,7 +5896,6 @@ def users(): flash("Nur Admins dürfen Admin-Zuweisungen ändern.", "danger") return redirect(url_for("users")) - # Mindestens ein Admin muss bestehen bleiben. if target and target["is_admin"] and choice != "admin": admin_count = conn.execute( "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND deleted_at IS NULL" @@ -6752,7 +5920,6 @@ def users(): flash("Gruppe zugewiesen!", "success") elif "delete_user" in request.form: - # "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht). if not current_user.has_permission("users.edit"): flash("Keine Berechtigung, Benutzer zu löschen.", "danger") return redirect(url_for("users")) @@ -6761,25 +5928,17 @@ def users(): target = conn.execute( "SELECT username, is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) ).fetchone() - # Admin-Konten dürfen nur von echten Admins gelöscht werden. if target and target["is_admin"] and not current_user.is_admin: flash("Nur Admins dürfen Admin-Konten löschen.", "danger") return redirect(url_for("users")) if target and target["auth_source"] == "ldap": - # 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. conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM users WHERE id=?", (user_id,)) conn.commit() log_action("user.delete", target["username"], "AD/LDAP-Konto, kein Papierkorb") flash("AD/LDAP-Konto gelöscht (legt sich bei erneutem gültigen Login automatisch neu an).", "success") else: - # Papierkorb statt echtem Löschen -- Mitgliedschaften bleiben - # dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen - # soll denselben Stand wiederherstellen wie vor dem Löschen). conn.execute( "UPDATE users SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_id), @@ -6789,9 +5948,6 @@ def users(): flash("Benutzer in den Papierkorb verschoben.", "success") elif "toggle_lock" in request.form: - # 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. if not current_user.has_permission("users.edit"): flash("Keine Berechtigung, Benutzer zu sperren.", "danger") return redirect(url_for("users")) @@ -6824,11 +5980,6 @@ def users(): flash(f"Benutzer „{target['username']}“ {'gesperrt' if new_state else 'entsperrt'}.", "success") elif "ldap_add_user" in request.form: - # 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. if not current_user.has_permission("users.create"): flash("Keine Berechtigung, Benutzer anzulegen.", "danger") return redirect(url_for("users")) @@ -6899,9 +6050,6 @@ def users_ldap_search(): return jsonify([]) results = _ldap_search_users(query) conn = get_db_connection() - # 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). existing = { row["username"].lower() for row in conn.execute("SELECT username FROM users WHERE deleted_at IS NULL").fetchall() @@ -6910,10 +6058,6 @@ def users_ldap_search(): return jsonify([r for r in results if r["username"] and r["username"].lower() not in existing]) -# --------------------------------------------------------------------------- -# Gruppen (Rechtesystem) — nur Admins dürfen Gruppen und deren -# Berechtigungen/Mitgliedschaften verwalten. -# --------------------------------------------------------------------------- @app.route("/groups", methods=["GET", "POST"]) @login_required @@ -6933,9 +6077,6 @@ def groups(): if name: try: cur = conn.execute("INSERT INTO groups (name) VALUES (?)", (name,)) - # Rechte direkt beim Anlegen setzbar (dieselbe Tabelle wie - # beim Bearbeiten, hier ohne eigenes "_submitted"-Flag, da - # eine neue Gruppe ohnehin noch keine Rechte hat). selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) if selected_permissions: conn.executemany( @@ -6963,14 +6104,6 @@ def groups(): conn.close() return redirect(url_for("groups")) - # 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. target_group = conn.execute( "SELECT name, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,) ).fetchone() @@ -6985,12 +6118,6 @@ def groups(): conn.close() return redirect(url_for("groups")) - # 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). try: conn.execute("UPDATE groups SET name=? WHERE id=? AND deleted_at IS NULL", (name, group_id)) @@ -7022,9 +6149,6 @@ def groups(): flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger") elif "assign_admins" in request.form: - # Bleibt bewusst admin-exklusiv, unabhängig von groups.edit — - # wer Admin ist/wird, darf nicht über ein delegierbares Recht - # gesteuert werden können. if not current_user.is_admin: flash("Nur Admins dürfen Admin-Mitgliedschaften ändern.", "danger") return redirect(url_for("groups")) @@ -7037,9 +6161,6 @@ def groups(): "UPDATE users SET is_admin=1 WHERE id=?", [(uid,) for uid in selected_admins], ) - # Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin - # alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt - # in Admin- und z.B. Standardgruppe auftauchen. conn.executemany( "DELETE FROM user_groups WHERE user_id=?", [(uid,) for uid in selected_admins], @@ -7049,7 +6170,6 @@ def groups(): flash("Admin-Zuweisung aktualisiert.", "success") elif "delete_group" in request.form: - # "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht). if not current_user.has_permission("groups.edit"): flash("Keine Berechtigung, Gruppen zu löschen.", "danger") return redirect(url_for("groups")) @@ -7060,9 +6180,6 @@ def groups(): if target and (target["is_default"] or target["is_system"]): flash(f"Die Systemgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") else: - # Papierkorb statt echtem Löschen -- Rechte/Mitgliedschaften - # bleiben dabei bewusst erhalten (siehe Wartung-Seite: - # Wiederherstellen soll denselben Stand wie vorher liefern). conn.execute( "UPDATE groups SET deleted_at=? WHERE id=? AND deleted_at IS NULL", (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), group_id), @@ -7103,10 +6220,6 @@ def groups(): "member_names": [m["username"] for m in member_rows], }) - # 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. admin_rows = conn.execute( "SELECT username FROM users WHERE is_admin=1 AND deleted_at IS NULL ORDER BY username ASC" ).fetchall() @@ -7135,7 +6248,4 @@ def groups(): if __name__ == "__main__": debug = os.environ.get("FLASK_DEBUG", "0") == "1" - # 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. app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug, threaded=True) diff --git a/srv/tesm/create_db.py b/srv/tesm/create_db.py index 809a433..a9bfa91 100644 --- a/srv/tesm/create_db.py +++ b/srv/tesm/create_db.py @@ -20,8 +20,6 @@ DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) conn = sqlite3.connect(DB_PATH) c = conn.cursor() -# Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich -# dieselben teilen; Passwort wird verschlüsselt gespeichert) c.execute(""" CREATE TABLE IF NOT EXISTS credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -33,8 +31,6 @@ CREATE TABLE IF NOT EXISTS credentials ( ); """) -# Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie -# direkt zu speichern c.execute(""" CREATE TABLE IF NOT EXISTS switches ( hostname TEXT PRIMARY KEY, @@ -48,7 +44,6 @@ CREATE TABLE IF NOT EXISTS switches ( ); """) -# Devices c.execute(""" CREATE TABLE IF NOT EXISTS devices ( mac TEXT PRIMARY KEY, @@ -66,7 +61,6 @@ CREATE TABLE IF NOT EXISTS devices ( ); """) -# Benutzer c.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -83,9 +77,6 @@ CREATE TABLE IF NOT EXISTS users ( ); """) -# 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. c.execute(""" CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -96,7 +87,6 @@ CREATE TABLE IF NOT EXISTS groups ( ); """) -# Welche Berechtigungen eine Gruppe gewährt (siehe app.py: PERMISSIONS) c.execute(""" CREATE TABLE IF NOT EXISTS group_permissions ( group_id INTEGER NOT NULL, @@ -106,8 +96,6 @@ CREATE TABLE IF NOT EXISTS group_permissions ( ); """) -# Gruppenmitgliedschaft (ein Benutzer kann in mehreren Gruppen sein; -# effektive Rechte = Vereinigung aller Gruppen) c.execute(""" CREATE TABLE IF NOT EXISTS user_groups ( user_id INTEGER NOT NULL, @@ -118,7 +106,6 @@ CREATE TABLE IF NOT EXISTS user_groups ( ); """) -# Settings (z.B. Prüfintervall) c.execute(""" CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -126,11 +113,6 @@ CREATE TABLE IF NOT EXISTS settings ( ); """) -# 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()). c.execute(""" CREATE TABLE IF NOT EXISTS dhcp_option_defs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -142,12 +124,6 @@ CREATE TABLE IF NOT EXISTS dhcp_option_defs ( ); """) -# 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. c.execute(""" CREATE TABLE IF NOT EXISTS dhcp_option_values ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -159,11 +135,6 @@ CREATE TABLE IF NOT EXISTS dhcp_option_values ( ); """) -# 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. c.execute(""" CREATE TABLE IF NOT EXISTS dhcp_subnets ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -177,9 +148,6 @@ CREATE TABLE IF NOT EXISTS dhcp_subnets ( ); """) -# 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). c.execute(""" CREATE TABLE IF NOT EXISTS dhcp_manual_reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -189,11 +157,6 @@ CREATE TABLE IF NOT EXISTS dhcp_manual_reservations ( ); """) -# 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. c.execute(""" CREATE TABLE IF NOT EXISTS service_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -203,9 +166,6 @@ CREATE TABLE IF NOT EXISTS service_accounts ( ); """) -# 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. c.execute(""" CREATE TABLE IF NOT EXISTS ldap_group_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -216,8 +176,6 @@ CREATE TABLE IF NOT EXISTS ldap_group_mappings ( ); """) -# Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE -# PoE-Neustarts (die stehen im Live-Log von poe.sh). c.execute(""" CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -229,14 +187,9 @@ CREATE TABLE IF NOT EXISTS audit_log ( ); """) -# Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden) c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300")) -# 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. cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",)) if cur.rowcount > 0: default_group_id = cur.lastrowid diff --git a/srv/tesm/templates/_dashboard_tiles.html b/srv/tesm/templates/_dashboard_tiles.html index a4bb5db..b9a198c 100644 --- a/srv/tesm/templates/_dashboard_tiles.html +++ b/srv/tesm/templates/_dashboard_tiles.html @@ -1,7 +1,3 @@ -
diff --git a/srv/tesm/templates/credentials.html b/srv/tesm/templates/credentials.html index 7892c0e..20e86a3 100644 --- a/srv/tesm/templates/credentials.html +++ b/srv/tesm/templates/credentials.html @@ -80,7 +80,6 @@
{% if can_create %} -