From d1b10bd970c68dd28c728149c8c10890641d607f Mon Sep 17 00:00:00 2001 From: alientim Date: Wed, 12 Aug 2026 22:21:52 +0200 Subject: [PATCH] Rebrand to TESM: infra rename, LDAP/AD login, selective import/export, Papierkorb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vollständiges Rebranding "PoE Manager" -> TESM (TimEShepManager): neue Logos/Favicon (theme-aware), Sidebar/Login-Branding, Copyright-Zeile. - Infrastruktur-Umbenennung: srv/poe_manager -> srv/tesm, alle POE_*-Env-Vars -> TESM_* (POE_SCRIPT bewusst unverändert, echtes PoE-Skript), systemd Units poe_web/rpi-check* -> tesm/tesm-check*, nginx-Site, netplan/logrotate Configs, Gitea-Repo alientim/Aruba-PoE-Modern -> alientim/tesm. - LDAP/Active-Directory Same-Sign-On: Search+Bind, AD-Gruppen->App-Gruppen- Zuordnung (additiv), Konto-Sperren, AD-Vorab-Suche/-Anlage, eigene Einstellungsseite. - Selektives Import/Export (8 Kategorien, Zwei-Schritt-Vorschau) mit eigenem R/E/X-Rechtemodell (X = Export, getrennt von E = Import). - Papierkorb: Soft-Delete statt Hard-Delete für Geräte/Switche/Zugangsdaten/ Benutzer/Gruppen (AD-Nutzer ausgenommen), eigene Seite unterhalb Wartung, konfigurierbare Aufbewahrungsfrist. - rpi_ip -> ip Spalten-/Code-Umbenennung (Geräte sind längst nicht mehr auf Raspberry Pis beschränkt). - README auf aktuellen Stand gebracht. --- .gitignore | 12 +- README.md | 388 ++- .../sites-available/{poe_manager => tesm} | 2 +- etc/systemd/system/rpi-check-restart.service | 6 - etc/systemd/system/rpi-check-restart.timer | 10 - etc/systemd/system/rpi-check.service | 14 - etc/systemd/system/tesm-check-restart.service | 6 + etc/systemd/system/tesm-check-restart.timer | 10 + etc/systemd/system/tesm-check.service | 14 + .../system/{poe_web.service => tesm.service} | 7 +- install.sh | 46 +- .../templates/settings_import_export.html | 58 - srv/poe_manager/templates/users.html | 177 -- srv/{poe_manager => tesm}/app.py | 2272 +++++++++++++++-- srv/{poe_manager => tesm}/create_admin.py | 0 srv/{poe_manager => tesm}/create_db.py | 9 +- srv/{poe_manager => tesm}/generate_ips.py | 6 +- srv/{poe_manager => tesm}/poe_wrapper.py | 0 srv/{poe_manager => tesm}/requirements.txt | 6 + .../static/css/style.css | 11 + .../static/css/vendor/xterm.css | 0 srv/tesm/static/images/icon-dark.svg | 12 + srv/tesm/static/images/icon-light.svg | 12 + srv/tesm/static/images/logo-dark-subline.svg | 13 + srv/tesm/static/images/logo-dark.svg | 9 + srv/tesm/static/images/logo-light-subline.svg | 13 + srv/tesm/static/images/logo-light.svg | 9 + .../static/images/logo.png | Bin srv/{poe_manager => tesm}/static/js/app.js | 11 + .../static/js/vendor/xterm-addon-fit.js | 0 .../static/js/vendor/xterm.js | 0 .../templates/_dashboard_tiles.html | 4 +- .../templates/account.html | 74 +- .../templates/activity_log.html | 0 srv/{poe_manager => tesm}/templates/base.html | 13 +- .../templates/credentials.html | 16 +- .../templates/devices.html | 18 +- .../templates/groups.html | 92 +- .../templates/index.html | 0 .../templates/login.html | 11 +- srv/{poe_manager => tesm}/templates/logs.html | 0 .../templates/logs_kea.html | 0 .../templates/maintenance.html | 25 +- srv/tesm/templates/papierkorb.html | 214 ++ .../templates/settings.html | 105 +- .../templates/settings_dhcp.html | 0 .../templates/settings_import_export.html | 114 + srv/tesm/templates/settings_ldap.html | 229 ++ .../templates/switches.html | 0 srv/tesm/templates/users.html | 321 +++ 50 files changed, 3641 insertions(+), 728 deletions(-) rename etc/nginx/sites-available/{poe_manager => tesm} (97%) delete mode 100644 etc/systemd/system/rpi-check-restart.service delete mode 100644 etc/systemd/system/rpi-check-restart.timer delete mode 100644 etc/systemd/system/rpi-check.service create mode 100644 etc/systemd/system/tesm-check-restart.service create mode 100644 etc/systemd/system/tesm-check-restart.timer create mode 100644 etc/systemd/system/tesm-check.service rename etc/systemd/system/{poe_web.service => tesm.service} (53%) delete mode 100644 srv/poe_manager/templates/settings_import_export.html delete mode 100644 srv/poe_manager/templates/users.html rename srv/{poe_manager => tesm}/app.py (68%) rename srv/{poe_manager => tesm}/create_admin.py (100%) rename srv/{poe_manager => tesm}/create_db.py (96%) rename srv/{poe_manager => tesm}/generate_ips.py (93%) rename srv/{poe_manager => tesm}/poe_wrapper.py (100%) rename srv/{poe_manager => tesm}/requirements.txt (75%) rename srv/{poe_manager => tesm}/static/css/style.css (98%) rename srv/{poe_manager => tesm}/static/css/vendor/xterm.css (100%) create mode 100644 srv/tesm/static/images/icon-dark.svg create mode 100644 srv/tesm/static/images/icon-light.svg create mode 100644 srv/tesm/static/images/logo-dark-subline.svg create mode 100644 srv/tesm/static/images/logo-dark.svg create mode 100644 srv/tesm/static/images/logo-light-subline.svg create mode 100644 srv/tesm/static/images/logo-light.svg rename srv/{poe_manager => tesm}/static/images/logo.png (100%) rename srv/{poe_manager => tesm}/static/js/app.js (96%) rename srv/{poe_manager => tesm}/static/js/vendor/xterm-addon-fit.js (100%) rename srv/{poe_manager => tesm}/static/js/vendor/xterm.js (100%) rename srv/{poe_manager => tesm}/templates/_dashboard_tiles.html (96%) rename srv/{poe_manager => tesm}/templates/account.html (53%) rename srv/{poe_manager => tesm}/templates/activity_log.html (100%) rename srv/{poe_manager => tesm}/templates/base.html (93%) rename srv/{poe_manager => tesm}/templates/credentials.html (91%) rename srv/{poe_manager => tesm}/templates/devices.html (96%) rename srv/{poe_manager => tesm}/templates/groups.html (75%) rename srv/{poe_manager => tesm}/templates/index.html (100%) rename srv/{poe_manager => tesm}/templates/login.html (73%) rename srv/{poe_manager => tesm}/templates/logs.html (100%) rename srv/{poe_manager => tesm}/templates/logs_kea.html (100%) rename srv/{poe_manager => tesm}/templates/maintenance.html (82%) create mode 100644 srv/tesm/templates/papierkorb.html rename srv/{poe_manager => tesm}/templates/settings.html (70%) rename srv/{poe_manager => tesm}/templates/settings_dhcp.html (100%) create mode 100644 srv/tesm/templates/settings_import_export.html create mode 100644 srv/tesm/templates/settings_ldap.html rename srv/{poe_manager => tesm}/templates/switches.html (100%) create mode 100644 srv/tesm/templates/users.html diff --git a/.gitignore b/.gitignore index c33a814..3d2ba76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt) -/srv/poe_manager/venv/ +/srv/tesm/venv/ __pycache__/ *.pyc @@ -7,10 +7,10 @@ __pycache__/ *.log # Secrets — niemals committen -/srv/poe_manager/sqlite.db -/srv/poe_manager/fernet.key -/srv/poe_manager/secret.key -/srv/poe_manager/known_hosts +/srv/tesm/sqlite.db +/srv/tesm/fernet.key +/srv/tesm/secret.key +/srv/tesm/known_hosts # Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt -/srv/poe_manager/static/uploads/ +/srv/tesm/static/uploads/ diff --git a/README.md b/README.md index 7fc0896..633d2c2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# PoE Manager — Modern UI Rebuild +# TESM (TimEShepManager) Webbasierte Verwaltung und Monitoring von PoE-Devices und Aruba-Switchen — funktionales Rebuild von [Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE) @@ -47,6 +47,9 @@ Die App ermöglicht: getrennt vom PoE-Neustart oben): Bulk-Update (`apt update && apt upgrade`) und Neustart per SSH direkt auf dem Gerät, unabhängig von jeder Switch-Zuordnung (siehe [Wartung](#wartung-ssh-bulk-update--neustart)) +- **Optionale LDAP/Active-Directory-Anmeldung** mit dem Domänen-Passwort, + zusätzlich zu lokalen Konten — automatische Konto-Anlage beim ersten + Login (siehe [LDAP / Active Directory](#ldap--active-directory-same-sign-on)) - Einstellbares Prüfintervall (steuert den Hintergrund-Check-Dienst) — der Countdown „Nächste Prüfung“ wird aus dem tatsächlichen letzten Log-Eintrag berechnet, nicht bei jedem Seitenaufruf neu gestartet, und @@ -113,8 +116,8 @@ Die App ermöglicht: überarbeitet — parallele statt sequentielle Geräte-Prüfung und echte Erfolgskontrolle statt blind angenommenem Erfolg, siehe [Geräte-Prüfung](#geräte-prüfung-poesh). -- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`POE_DB_PATH`, - `POE_SCRIPT`, `POE_LOG_GLOB`, …) statt hart codierter/inkonsistenter Pfade; +- **Aufgeräumtes Backend**: konsistente, konfigurierbare Pfade (`TESM_DB_PATH`, + `POE_SCRIPT`, `TESM_LOG_DIR`, …) statt hart codierter/inkonsistenter Pfade; Secret-Key und Fernet-Key werden beim ersten Start automatisch generiert statt im Repo zu liegen. @@ -137,29 +140,43 @@ Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich ### Zweistufiger Rechtebaum -Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei Sidebar-Gruppen -(Geräte/Logs/Einstellungen). Auf der Gruppen-Seite stehen sie als drei -kompakte Spalten nebeneinander, mit einer kurzen Legende direkt darüber. -Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält in EINER Zeile -sowohl den Bereichsnamen mit seinem Kästchen (dem Kill-Switch, dicht -nebeneinander statt an den Rand gestellt) als auch die Buchstaben-Spalten -**R**(ead/Lesen), **W**(rite/Schreiben, Anlegen), **E**(dit/Ändern) — nur -die Rechte, die dieser Bereich tatsächlich nutzt. Darunter je eine -eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen überall mit -ab** — es gibt bewusst kein eigenes Löschen-Recht. Eine vierte Spalte **D** -gibt es nur bei Geräte → Clients, und sie bedeutet dort **nicht** Löschen, -sondern *PoE-Neustart über das Dashboard-Popup auslösen* (automatische -Neustarts bei Ausfall über `poe.sh` sind davon komplett unabhängig und -laufen immer). Für Zeilen ohne passendes Recht (z.B. „D“ bei Switche/ -Zugangsdaten/Benutzer/Gruppen) wird eine ausgegraute, nicht anklickbare -Checkbox angezeigt statt die Zelle wegzulassen: +Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei +Rechte-Bereichen der Gruppen-Seite (Geräte/Logs/Einstellungen) — das ist +**unabhängig** von der Sidebar-Gliederung: „Benutzer“ und „Gruppen“ stehen +in der Sidebar zwar als eigene, flache Top-Level-Punkte (siehe +[Navigation anpassen](#navigation-anpassen)), gehören rechtlich aber +weiterhin zum Bereich „Einstellungen“. Auf der Gruppen-Seite stehen die drei +Bereiche als drei kompakte Spalten nebeneinander, mit einer kurzen Legende +direkt darüber. Jede Spalte ist eine kleine Tabelle: die Kopfzeile enthält +in EINER Zeile sowohl den Bereichsnamen mit seinem Kästchen (dem +Kill-Switch, dicht nebeneinander statt an den Rand gestellt) als auch die +Buchstaben-Spalten **R**(ead/Lesen), **W**(rite/Schreiben, Anlegen), +**E**(dit/Ändern) — nur die Rechte, die dieser Bereich tatsächlich nutzt. +Darunter je eine eingerückte Zeile pro Unterpunkt. **„Ändern“ deckt Löschen +überall mit ab** — es gibt bewusst kein eigenes Löschen-Recht (stattdessen +landet ein gelöschter Eintrag im [Papierkorb](#papierkorb)). Zwei Bereiche +haben eine zusätzliche, bereichsspezifische vierte Spalte: **D** nur bei +Geräte → Clients bedeutet **nicht** Löschen, sondern *PoE-Neustart über das +Dashboard-Popup auslösen* (automatische Neustarts bei Ausfall über `poe.sh` +sind davon komplett unabhängig und laufen immer); **X** nur bei +Einstellungen → Im-/Export bedeutet *Export ausführen* — bewusst getrennt +von E (Import), da die Export-Datei Passwörter im Klartext enthält (siehe +[Import/Export](#importexport)). Checken einer W/E/D/X-Zelle einer Zeile +hakt automatisch auch deren R an, und Abwählen von R wählt automatisch auch +die übrigen Spalten derselben Zeile ab (R ist logisch Voraussetzung für +alles andere). Für Zeilen ohne passendes Recht (z.B. „D“ bei Switche/ +Zugangsdaten/Benutzer/Gruppen, oder „X“ bei allem außer Im-/Export) wird +eine ausgegraute, nicht anklickbare Checkbox angezeigt statt die Zelle +wegzulassen: ``` -☑ Geräte R W E D ☑ Logs R W E ☑ Einstellungen R W E - Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑ - Switche ☑ ☑ ☑ ▢ Änd. ☑ ☑ ☑ Gruppen ☑ ☑ ☑ - Zugangsd. ☑ ☑ ☑ ▢ Systemeinst. ☑ ▢ ☑ - Im-/Export ☑ ▢ ☑ +☑ Geräte R W E D ☑ Logs R W E ☑ Einstellungen R W E X + Clients ☑ ☑ ☑ ☑ Live ☑ ☑ ☑ Benutzer ☑ ☑ ☑ ▢ + Switche ☑ ☑ ☑ ▢ Änderungen☑ ☑ ☑ Gruppen ☑ ☑ ☑ ▢ + Zugangsd. ☑ ☑ ☑ ▢ Kea-DHCP ☑ ☑ ☑ Systemeinst. ☑ ▢ ☑ ▢ + Wartung ☑ ▢ ☑ ▢ LDAP ☑ ▢ ☑ ▢ + DHCP ☑ ▢ ☑ ▢ + Im-/Export ☑ ▢ ☑ ☑ ``` Das jeweilige „Bereich sichtbar“-Recht (`devices_group.view` / @@ -217,6 +234,136 @@ sämtliche `*.delete`-Rechte → `*.edit` (Löschen wurde nachträglich überall eigenen Guard in der `settings`-Tabelle, damit z.B. ein bewusst deaktiviertes Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird. +## LDAP / Active Directory (Same Sign-On) + +Optionale Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten +— auf der eigenen Seite **Einstellungen → LDAP** (Recht `settings_ldap.view` +zum Ansehen, Speichern erfordert Admin). + +**Wichtige Begriffsklärung, direkt aus einer Nutzerfrage entstanden:** das +ist **"Same Sign-On"**, kein automatisches, promptloses **Single Sign-On**. +Der Benutzer tippt sein AD-Passwort weiterhin auf der normalen Login-Seite +ein — es wird nur bei jedem Login **live** gegen Active Directory geprüft +statt gegen ein lokal gespeichertes Passwort. Eine Passwortänderung in AD +greift dadurch sofort, ohne dass irgendetwas synchronisiert werden müsste. +Echtes, promptloses Single Sign-On (der Browser meldet automatisch den +bereits an Windows angemeldeten Benutzer an) bräuchte zusätzlich: +- **Kerberos/SPNEGO** ("Integrated Windows Authentication"): ein SPN + + Keytab vom AD-Admin, eine per DNS auflösbare Hostname für die App, und + die Browser-Konfiguration der Clients (i.d.R. bereits Standard für die + Intranet-Zone auf domänenbeigetretenen PCs) — **oder** +- **SAML/OIDC über Entra ID (Azure AD)**, falls Microsoft 365/Entra + vorhanden ist — oft der pragmatischere Weg zu echtem SSO als klassisches + Kerberos. + +Beides sind eigenständige, deutlich aufwändigere Ausbaustufen, die sich +später auf die hier beschriebene LDAP-Anmeldung aufsetzen ließen, aber +AD-seitige Vorarbeit (Keytab bzw. App-Registrierung) durch einen +Domain-Admin voraussetzen. + +### Funktionsweise (Search+Bind) + +Beim Login wird zunächst lokal gesucht — per exaktem Benutzernamen, per +E-Mail-Adresse, oder (case-insensitiv, nur für `auth_source='ldap'`-Konten) +per bereits bekanntem AD-Benutzernamen. Ist das gefundene (oder noch nicht +existierende) Konto gesperrt (`is_locked`, siehe unten) oder im Papierkorb, +wird der Login abgelehnt, ohne AD überhaupt zu kontaktieren. Existiert kein +lokales Konto und ist LDAP aktiviert, prüft die App die eingegebene Kennung +(sAMAccountName **oder** UPN, z.B. sowohl `jdoe` als auch +`jdoe@ad.eertmoed.net` funktionieren) und das Passwort per **Search+Bind** +gegen den konfigurierten Server: + +1. Verbindung mit dem konfigurierten **Service-Konto** aufbauen (ein + normales, unprivilegiertes Domänenkonto reicht — es braucht nur + Lesezugriff auf das Verzeichnis, was jedes Domänenkonto in AD + standardmäßig hat). +2. Den Benutzer per konfigurierbarem Attribut suchen (Standard + `sAMAccountName` für Active Directory; für generisches LDAP z.B. `uid`). +3. Eine **zweite, separate** Verbindung mit der gefundenen DN des Benutzers + und dem eingegebenen Passwort aufbauen, um es zu verifizieren — robuster + als ein direktes UPN-Bind (`user@domain`), da es nicht voraussetzt, dass + `sAMAccountName` und der UPN-Präfix übereinstimmen. +4. Zusätzlich wird das `userAccountControl`-Bit `ACCOUNTDISABLE` geprüft + (auch wenn ein echter AD-DC den Bind eines deaktivierten Kontos i.d.R. + ohnehin schon selbst ablehnt) — zusätzliche Absicherung. + +Die **Base-DN** wird standardmäßig automatisch per RootDSE +(`defaultNamingContext`) ermittelt, kann aber manuell überschrieben werden +(z.B. bei mehreren Domänen in einer Gesamtstruktur). + +Bei erfolgreichem erstem Login wird automatisch ein lokales Konto angelegt +(`auth_source='ldap'`, kein Admin). Das gespeicherte Passwort dieses Kontos +ist nur ein **nie geprüfter Platzhalter-Hash** — bei jedem weiteren Login +wird live gegen AD verifiziert, nie gegen die lokale DB. Benutzernamen +werden dabei case-insensitiv abgeglichen (nur für `auth_source='ldap'`- +Konten, wirkt sich nie auf lokale Konten aus), damit z.B. "JDoe" und "jdoe" +nicht zwei verschiedene lokale Konten erzeugen. + +**Gruppen-Zuordnung (AD-Gruppe → App-Gruppe).** Auf der LDAP-Einstellungsseite +lässt sich jeder AD-Gruppe (per DN) genau eine App-Gruppe zuordnen. Ist der +Benutzer beim Login Mitglied mehrerer zugeordneter AD-Gruppen, erhält er +**additiv** die Rechte aller passenden App-Gruppen (dieselbe +Vereinigungslogik wie bei mehrfacher lokaler Gruppenmitgliedschaft). Passt +keine der konfigurierten Zuordnungen, greift ersatzweise eine konfigurierbare +**Standardgruppe** (sonst die System-Standardgruppe „Benutzer“) — es sei denn, +eine der AD-Gruppen ist explizit der internen Admin-Zuordnung zugewiesen, +dann wird das Konto automatisch zum Admin. Die Zuordnung wird bei **jedem** +Login neu ausgewertet, nicht nur beim ersten — Änderungen an +AD-Gruppenmitgliedschaften wirken sich also ohne manuellen Eingriff auf die +nächste Anmeldung aus. + +**Konten sperren.** Ein Admin kann jedes Konto (lokal wie AD) in „Benutzer“ +per Schalter sperren (`is_locked`) — ein gesperrtes Konto kann sich nicht +mehr anmelden, unabhängig davon, ob das AD-Passwort weiterhin gültig ist. +Nützlich, um jemandem ohne Eingriff in AD selbst kurzfristig den Zugriff auf +die App zu entziehen. + +**Vorab-Anlage aus Active Directory** („Benutzer" → „Aus Active Directory +hinzufügen"): eine Live-Suche (Substring, ab 2 Zeichen) im Verzeichnis, +gefiltert um bereits lokal bekannte Benutzernamen. So kann ein Admin einer +Person schon **vor** ihrem ersten Login eine Gruppe zuweisen, statt sich auf +die Standardgruppe zu verlassen — das Konto landet dabei sofort mit +`auth_source='ldap'` und demselben Platzhalter-Passwort-Mechanismus in der +DB, ganz so, als hätte die Person sich bereits einmal angemeldet. + +**Lokale Konten bleiben vollständig erhalten** — der lokale Login-Pfad ist +unverändert, LDAP kommt nur als zusätzlicher Weg für Benutzernamen ohne +lokales Konto dazu. Wichtig als Fallback (Break-Glass-Zugang, falls +AD/LDAP mal nicht erreichbar ist) und für den allerersten +Admin-Bootstrap (`create_admin.py`). + +Für AD-Konten wird die Passwort-Änderung sowohl unter „Mein Konto" als auch +im Admin-Bereich „Benutzer" ausgeblendet bzw. serverseitig ignoriert (ein +kleines "AD"-Symbol markiert sie in der Benutzerliste) — das Passwort wird +ausschließlich in Active Directory verwaltet. + +**Löschen eines AD-Kontos** in „Benutzer" entfernt nur das lokale +Schatten-Konto — **AD-Konten landen dabei bewusst NIE im Papierkorb** +(anders als lokale Konten, siehe [Papierkorb](#papierkorb)), da sie sich bei +jedem gültigen Login ohnehin automatisch wieder anlegen würden und ein +"gelöscht, aber weiter nutzbar"-Zwischenzustand hier keinen Sinn ergibt. +Meldet sich die Person danach erneut mit gültigen AD-Zugangsdaten an, wird +automatisch wieder ein (neues, rechteloses) Konto angelegt. Um jemandem den +Zugriff wirklich zu entziehen, sperrt man das Konto entweder direkt in der +App (siehe oben) oder deaktiviert es in AD selbst (blockiert dann auch den +LDAP-Bind). Aus demselben Grund werden AD-Konten beim +[Export](#importexport) grundsätzlich nie mit exportiert — nur lokale +Konten landen in der Export-Datei. + +**Sicherheitshinweis**: ohne LDAPS/StartTLS wird das Passwort unverschlüsselt +über das Netzwerk übertragen — nur für interne Tests in einem vertrauenswürdigen +Netz geeignet. Vor Produktivbetrieb sollte LDAPS auf dem Domain Controller +eingerichtet und in den Einstellungen aktiviert werden (das Server-Zertifikat +wird dafür noch nicht validiert — reine Transportverschlüsselung). + +Live gegen einen echten Active-Directory-Domain-Controller verifiziert: +automatische Base-DN-Ermittlung, "Verbindung testen", erfolgreicher +Erst-Login mit automatischer Konto-Anlage und korrekter +Standardgruppen-Zuweisung, Ablehnung bei falschem Passwort, erneuter Login +verwendet das bereits angelegte Konto (keine Duplikate), unveränderter +lokaler Admin-Login, sowie dass das Bind-Passwort nie im Klartext im +gerenderten HTML landet. + ## Zugangsdaten (wiederverwendbare SSH-Logins) Zugangsdaten (Username + verschlüsseltes Passwort) sind eine eigene Entität @@ -297,7 +444,7 @@ letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/stat alle 2s solange etwas läuft, sonst alle 8s) aktualisiert, ohne die Seite neu zu laden. Der Job-Status lebt bewusst nur im Prozessspeicher (wie beim SSH-Terminal auch keine Sitzung persistiert wird) — ein Neustart von -`poe_web.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät +`tesm.service` verwirft nur die Anzeige, nicht die auf dem Zielgerät bereits laufende Aktion selbst. Ein abgeschlossener **erfolgreicher** Job wird zusätzlich bei jedem (erneuten) Laden der Seite zurückgesetzt (Status wieder „Noch keine Aktion“) — ein Erfolg ist nur so lange relevant, wie man @@ -497,7 +644,7 @@ wurde korrekt mit der „Ohne Reservierung“-Markierung angezeigt. ## Geräte-Prüfung (`poe.sh`) -Der Hintergrund-Check-Dienst (`rpi-check.service`, ruft `poe.sh` ohne +Der Hintergrund-Check-Dienst (`tesm-check.service`, ruft `poe.sh` ohne Argument auf) prüft in einer Endlosschleife alle Geräte per Ping und startet bei Ausfall automatisch den PoE-Port neu, über den ein Gerät am zugeordneten Switch hängt (`expect`, siehe unten). Zwei Korrekturen nach @@ -543,7 +690,7 @@ fälschlich als 43× „PoE deaktiviert“/„PoE aktiviert“ geloggt. ## Netzwerkeinstellungen des Hosts (Systemeinstellungen) Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um -die Netzwerkkonfiguration des Hosts, auf dem der PoE Manager selbst läuft +die Netzwerkkonfiguration des Hosts, auf dem TESM selbst läuft (IP-Adresse, DNS-Server, DHCP-Client vs. statisch, Hostname) — nicht um Adressen, die er an andere Geräte vergibt. Der Hostname wird separat und risikolos über `hostnamectl set-hostname` gesetzt (kein Revert-Timer nötig, @@ -578,7 +725,7 @@ da eine Änderung nicht die Erreichbarkeit kappt). IP/Prefix/Gateway, sowie unabhängig davon eigene DNS-Server (funktioniert in beiden Modi — z.B. IP per DHCP, aber feste DNS-Server). Bei netplan landet die Änderung in einer eigenen, niedrig priorisierten Override-Datei - (`/etc/netplan/90-poe-manager.yaml`) statt in der vom Distributor bzw. + (`/etc/netplan/90-tesm.yaml`) statt in der vom Distributor bzw. cloud-init mitgelieferten — netplan wendet Dateien alphabetisch an, spätere Werte gewinnen pro Schlüssel, damit bleibt die Original-Datei unangetastet. - **Sicherheitsnetz** (wie `netplan try`): vor jeder Änderung wird die @@ -635,18 +782,18 @@ Monat Historie): | Log | Datei | Einsehbar unter | |-----|-------|------------------| -| Live (Ping-Check/PoE-Neustart) | `/var/log/poe-manager/live.log` | Logs → Live | -| Änderungen (Audit-Trail) | `/var/log/poe-manager/changes.log` | Logs → Änderungen (aus der DB) | -| App (Fehler/Warnungen der Flask-App) | `/var/log/poe-manager/app.log` | nur Datei, keine eigene Seite | +| Live (Ping-Check/PoE-Neustart) | `/var/log/tesm/live.log` | Logs → Live | +| Änderungen (Audit-Trail) | `/var/log/tesm/changes.log` | Logs → Änderungen (aus der DB) | +| App (Fehler/Warnungen der Flask-App) | `/var/log/tesm/app.log` | nur Datei, keine eigene Seite | | Kea-DHCP | `/var/log/kea/kea-dhcp4.log` | Logs → Kea-DHCP (Recht `logs_kea.view`) | - **Live-Log ist jetzt eine einzige, durchgehend fortgeschriebene Datei** - statt vorher bei jedem (auch täglich per `rpi-check-restart.timer` + statt vorher bei jedem (auch täglich per `tesm-check-restart.timer` automatisch ausgelösten) Dienst-Neustart ein neues `rpi-.log` — `poe.sh` räumt nicht mehr selbst nach 30 Tagen auf, das übernimmt jetzt zentral logrotate. Bestehende alte `rpi-*.log`-Dateien werden beim ersten Start dieser Version einmalig aufgeräumt (Migration). -- **Kea-DHCP-Log liegt bewusst NICHT unter `/var/log/poe-manager/`**: Keas +- **Kea-DHCP-Log liegt bewusst NICHT unter `/var/log/tesm/`**: Keas AppArmor-Profil (`/etc/apparmor.d/usr.sbin.kea-dhcp4`) erlaubt Schreibzugriff exklusiv auf den Dateinamen `/var/log/kea/kea-dhcp4.log` (kein Verzeichnis-Wildcard) — ein anderer Pfad wird mit `DENIED ... mknod @@ -660,14 +807,14 @@ Monat Historie): funktioniert ohne jede Kooperation des schreibenden Prozesses und läuft als root, ist also auch von Keas AppArmor-Profil nicht betroffen. - **App-Log** (`app.log`) fängt Fehler/Warnungen der Flask-App ab (vorher - nur im journalctl-Output von `poe_web.service` sichtbar, ohne von der App + nur im journalctl-Output von `tesm.service` sichtbar, ohne von der App selbst kontrollierbare Aufbewahrung) — bewusst nur `WARNING` und höher, kein Access-/Request-Rauschen. - **Zeitzone** (Systemeinstellungen, zusammen mit dem Hostnamen in der Kachel „Host“): setzt die System-Zeitzone per `timedatectl set-timezone`, damit alle vier Logs sowie das Änderungslog die tatsächliche lokale Zeit statt UTC zeigen. Wirkt über `time.tzset()` - sofort für die bereits laufende `poe_web.service`-Instanz (kein + sofort für die bereits laufende `tesm.service`-Instanz (kein Neustart nötig) — `poe.sh`/`date` liest die Zeitzone ohnehin bei jedem Prüf-Durchlauf frisch, da es ein neuer Prozess pro Durchlauf ist. - **Systemeinstellungen neu sortiert**: Host (Hostname + Zeitzone in einer @@ -682,20 +829,85 @@ bereits den neuen Zeitstempel. ## Import/Export -Unter **Einstellungen** lassen sich Geräte, Switche und Zugangsdaten -(inkl. entschlüsselter Passwörter) als ein einziges JSON-Bundle exportieren. +Unter **Einstellungen → Im-/Export** lassen sich acht Kategorien einzeln +auswählen und als ein JSON-Bundle exportieren: Geräte, Switche, Zugangsdaten +(inkl. entschlüsselter Passwörter), Benutzer, Gruppen, LDAP-Einstellungen +(inkl. Gruppenzuordnungen), DHCP-Konfiguration und Logs. **AD/LDAP- +Benutzerkonten werden dabei nie mitexportiert** — nur lokale Konten landen +in der Datei, siehe [LDAP-Kapitel](#ldap--active-directory-same-sign-on). Das Bundle wird mit einem aus einer selbstgewählten Passphrase abgeleiteten Schlüssel verschlüsselt (PBKDF2-HMAC-SHA256 + Fernet) — unabhängig vom `fernet.key` dieser Installation, damit die Datei auf einer komplett neuen Umgebung (mit eigenem, neu generiertem `fernet.key`) wieder eingelesen -werden kann. Gedacht für einen Umzug auf einen neuen Server. +werden kann. Gedacht für einen Umzug auf einen neuen Server oder ein +selektives Backup einzelner Kategorien. -Beim Import wird dieselbe Passphrase erneut abgefragt; Zugangsdaten werden -nach Name, Switche nach Hostname und Geräte nach MAC-Adresse abgeglichen — -bestehende Einträge mit gleichem Schlüssel werden aktualisiert, neue werden -angelegt. Switche referenzieren ihre Zugangsdaten dabei über den Namen (nicht -die interne ID), damit der Import auch bei abweichender ID-Vergabe auf dem -Zielsystem korrekt verknüpft. +**Zweistufiger Import.** Datei hochladen + Passphrase eingeben lädt das +Bundle serverseitig nur kurzzeitig in den Speicher (Token-basiert, läuft +nach einer Weile automatisch ab) und zeigt eine **Vorschau**: nur die +Kategorien, die tatsächlich in der Datei enthalten sind, jeweils mit Anzahl +der Einträge. Erst danach wählt man aus, welche der vorhandenen Kategorien +tatsächlich übernommen werden sollen — ein Import kann also z.B. nur die +Geräte aus einer Datei ziehen, ohne versehentlich auch Benutzer oder +LDAP-Einstellungen zu überschreiben. Abgeglichen wird pro Kategorie nach dem +jeweiligen natürlichen Schlüssel (Zugangsdaten nach Name, Switche nach +Hostname, Geräte nach MAC-Adresse, Benutzer nach Benutzername, Gruppen nach +Name) — bestehende Einträge werden aktualisiert, neue angelegt. Switche +referenzieren ihre Zugangsdaten dabei über den Namen (nicht die interne ID), +damit der Import auch bei abweichender ID-Vergabe auf dem Zielsystem korrekt +verknüpft. + +**Berechtigungen.** Die Im-/Export-Seite hat drei eigene Rechte: **R** +(`settings_importexport.view`) zum Ansehen der Seite (Standardbedeutung wie +überall), **E** (`settings_importexport.edit`) zum Ausführen eines Imports, +und **X** (`settings_importexport.export`) — eine eigene, dedizierte +Berechtigung nur zum Ausführen eines Exports, da die Export-Datei Passwörter +im Klartext enthält (entschlüsselt vor der eigenen Passphrasen- +Verschlüsselung) und deshalb bewusst nicht automatisch an jeden mit +Änderungsrecht gekoppelt ist. Admins haben ohnehin immer alle drei; für +andere Gruppen lassen sich Import und Export unabhängig voneinander vergeben. +Zusätzlich sind die drei Kategorien **Benutzer, Gruppen und LDAP** +unabhängig von R/E/X grundsätzlich Admins vorbehalten (sowohl beim Export +als auch beim Import) — ein Nicht-Admin mit E/X sieht und nutzt dort nur die +übrigen fünf Kategorien. + +## Papierkorb + +Löschen ist für Geräte, Switche, Zugangsdaten, (lokale) Benutzer und +(benutzerdefinierte) Gruppen ein **Soft-Delete** statt eines echten +`DELETE` — der Eintrag bekommt nur einen Zeitstempel (`deleted_at`) und +verschwindet dadurch aus allen normalen Listen, Dropdowns und +Berechtigungsprüfungen, bleibt aber vollständig erhalten und wiederherstellbar. +Erreichbar über einen eigenen Navigationspunkt **„Papierkorb"** direkt +unterhalb von „Wartung" (sichtbar, sobald man für mindestens eine der fünf +Kategorien Leserecht hat) — dort ist jede Kategorie als eigene, permissions- +gegated Kachel mit „Wiederherstellen"- und „Endgültig löschen"-Aktion +aufgeführt. + +**Ausnahmen**: System-/Standardgruppen (aktuell nur „Benutzer") lassen sich +grundsätzlich nicht löschen, landen also auch nie im Papierkorb — unverändert +gegenüber vorher. **AD/LDAP-Benutzerkonten landen ebenfalls nie im +Papierkorb** und werden bei „Löschen" weiterhin sofort endgültig entfernt, da +sie sich bei jedem gültigen Login automatisch neu anlegen (siehe +[LDAP-Kapitel](#ldap--active-directory-same-sign-on)) — ein +Papierkorb-Zwischenzustand wäre hier bedeutungslos. + +**Reservierter Schlüssel, solange etwas im Papierkorb liegt**: MAC-Adresse, +Hostname, Zugangsdaten-Name, Benutzername bzw. Gruppenname eines +gelöschten Eintrags bleiben bewusst reserviert und blockieren die +Neuanlage eines gleichnamigen Eintrags, bis der alte entweder +wiederhergestellt oder endgültig gelöscht (purged) wird — der Fehlertext +beim Anlegen weist entsprechend auf den Papierkorb hin. Kind-Datensätze +(Gruppenmitgliedschaften, Gruppenrechte, DHCP-Werte pro Gerät) werden beim +Soft-Delete bewusst **nicht** mit entfernt, damit eine Wiederherstellung +exakt den vorherigen Zustand zurückbringt — aufgeräumt werden sie erst beim +**endgültigen** Löschen (Purge). + +**Aufbewahrungsfrist**: konfigurierbar unter Systemeinstellungen +(`trash_retention_days`, Standard 30 Tage) — abgelaufene Einträge werden +beim Aufruf der Papierkorb-Seite automatisch endgültig gelöscht (kein +eigener Scheduler nötig), zusätzlich gibt es dort einen Button, um +abgelaufene Einträge sofort manuell zu bereinigen. ## Eigenes Konto & Profilbild @@ -705,8 +917,9 @@ hochladen (PNG/JPG/GIF/WEBP) und das eigene Passwort ändern (erfordert Eingabe des aktuellen Passworts). Das ist bewusst getrennt vom Admin-Bereich „Benutzer“, der andere Konten verwaltet. Ist ein Profilbild hinterlegt, erscheint es statt der Initialen sowohl in der Sidebar als auch im -Änderungslog neben dem Benutzernamen. Admins sehen auf derselben Seite -zusätzlich die Navbar-Reihenfolge (siehe unten). +Änderungslog neben dem Benutzernamen. Die Navbar-Reihenfolge (für Admins) +liegt dagegen unter Systemeinstellungen, siehe +[Navigation anpassen](#navigation-anpassen). ## Warnung bei ungespeicherten Änderungen @@ -754,23 +967,28 @@ verursachen, obwohl der Code auf dem Server längst aktuell war. ## Navigation anpassen -Die Sidebar ist in drei aufklappbare Gruppen strukturiert: +Die Sidebar besteht aus zwei aufklappbaren Gruppen und mehreren flachen +Top-Level-Punkten dazwischen: -- **Geräte** — Clients (die eigentlichen Devices), Switche, Zugangsdaten, - Wartung (SSH-Bulk-Update/-Neustart) -- **Einstellungen** — Benutzer, Gruppen, Systemeinstellungen (Prüfintervall), - Im-/Export -- **Logs** — Live, Änderungen (Änderungslog) +- **Geräte** (Gruppe) — Clients (die eigentlichen Devices), Switche, + Zugangsdaten, Wartung (SSH-Bulk-Update/-Neustart), **Papierkorb** + (direkt unterhalb von Wartung, siehe [Papierkorb](#papierkorb)) +- **Benutzer**, **Gruppen** — eigene, flache Top-Level-Punkte direkt nach + „Geräte“ (bewusst nicht in einer Untergruppe versteckt) +- **Einstellungen** (Gruppe) — Systemeinstellungen (Host/Prüfintervall/ + Navbar-Reihenfolge/Papierkorb-Aufbewahrung), LDAP, DHCP, Im-/Export +- **Logs** (Gruppe) — Live, Änderungen, Kea-DHCP -Die Reihenfolge dieser drei Gruppen (und von „Dashboard“) ist für Admins -unter **Mein Konto** (Zahnrad-Symbol in der Sidebar) per Auf-/Ab-Buttons -konfigurierbar (gespeichert als JSON-Liste unter `settings.nav_order`) und -gilt global für alle Benutzer. Unabhängig von der Reihenfolge sieht jeder -Benutzer ohnehin nur, wofür er berechtigt ist — sowohl auf Gruppenebene als -auch pro Unterpunkt: hat ein Benutzer z.B. nur `devices.view`, erscheint die -Gruppe „Geräte“ nur mit dem Unterpunkt „Clients“ (ohne Switche/ -Zugangsdaten); eine Gruppe verschwindet komplett, wenn kein Unterpunkt -sichtbar ist. +Die Reihenfolge der Top-Level-Punkte **und** der Unterpunkte innerhalb jeder +Gruppe ist für Admins unter **Systemeinstellungen** (eigene, nicht +einklappbare Karte „Navbar-Reihenfolge") per Auf-/Ab-Buttons konfigurierbar +(gespeichert als JSON unter `settings.nav_order` bzw. +`settings.nav_child_order`) und gilt global für alle Benutzer. Unabhängig +von der Reihenfolge sieht jeder Benutzer ohnehin nur, wofür er berechtigt +ist — sowohl auf Gruppenebene als auch pro Unterpunkt: hat ein Benutzer z.B. +nur `devices.view`, erscheint die Gruppe „Geräte“ nur mit dem Unterpunkt +„Clients“ (ohne Switche/Zugangsdaten/Wartung/Papierkorb); eine Gruppe +verschwindet komplett, wenn kein Unterpunkt sichtbar ist. ## Entwicklung unter WSL @@ -783,7 +1001,7 @@ läuft — das Projektverzeichnis liegt dabei ganz normal unter wsl --install -d Ubuntu # einmalig, falls noch nicht vorhanden wsl -d Ubuntu sudo apt update && sudo apt install -y python3 python3-venv python3-pip sqlite3 expect openssh-client -cd /mnt/c/Users//aruba-poe-modern/srv/poe_manager +cd /mnt/c/Users//aruba-poe-modern/srv/tesm python3 -m venv venv ./venv/bin/pip install -r requirements.txt ./venv/bin/python create_db.py @@ -792,15 +1010,15 @@ python3 -m venv venv # auch aus dem Windows-Browser erreichbar ``` -**Hinweis zu `rpi-check.service`:** Der „Speichern & Service neustarten“-Button +**Hinweis zu `tesm-check.service`:** Der „Speichern & Service neustarten“-Button unter Systemeinstellungen sowie das „Jetzt prüfen“-Icon funktionieren nur, -wenn `rpi-check.service` tatsächlich als echter systemd-Service läuft (viele +wenn `tesm-check.service` tatsächlich als echter systemd-Service läuft (viele aktuelle WSL2-Installationen unterstützen systemd, siehe `wsl.conf` `[boot] systemd=true`). Für lokale Tests reicht es, `etc/systemd/system/ -rpi-check.service` sowie `usr/local/bin/custom/poe.sh` per Symlink an ihre +tesm-check.service` sowie `usr/local/bin/custom/poe.sh` per Symlink an ihre Produktivpfade (`/etc/systemd/system/`, `/usr/local/bin/custom/`) zu legen -und `/srv/poe_manager` auf den Checkout zu verlinken, dann `systemctl -daemon-reload && systemctl enable --now rpi-check.service`. Ohne systemd +und `/srv/tesm` auf den Checkout zu verlinken, dann `systemctl +daemon-reload && systemctl enable --now tesm-check.service`. Ohne systemd (z.B. `systemctl` nicht vorhanden) scheitert der Neustart-Aufruf lautlos — das Intervall in der DB wird trotzdem aktualisiert, greift dann aber erst nach einem manuellen Neustart des Prozesses. @@ -808,7 +1026,7 @@ nach einem manuellen Neustart des Prozesses. ## Lokal starten (Entwicklung) ```bash -cd srv/poe_manager +cd srv/tesm python3 -m venv venv source venv/bin/activate pip install -r requirements.txt @@ -819,23 +1037,23 @@ python create_admin.py # legt ersten Admin-Benutzer an python app.py # http://localhost:5000 ``` -## Produktiv-Deployment (auf der Ziel-VM, App landet unter `/srv/poe_manager`) +## Produktiv-Deployment (auf der Ziel-VM, App landet unter `/srv/tesm`) -Das Repo wird bewusst **nicht** direkt nach `/srv/poe_manager` geklont — -`install.sh` kopiert (`rsync`) den Inhalt von `srv/poe_manager/` aus dem Repo +Das Repo wird bewusst **nicht** direkt nach `/srv/tesm` geklont — +`install.sh` kopiert (`rsync`) den Inhalt von `srv/tesm/` aus dem Repo gezielt dorthin und lässt Quell-Checkout und Zielverzeichnis damit sauber -getrennt (kein doppeltes `/srv/poe_manager/srv/poe_manager/…`): +getrennt (kein doppeltes `/srv/tesm/srv/tesm/…`): ```bash sudo git clone /opt/aruba-poe-modern-src cd /opt/aruba-poe-modern-src sudo bash install.sh -sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py +sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py ``` Nach der Installation liegt die laufende App vollständig unter -**`/srv/poe_manager`** (so wie es `poe_web.service`, `rpi-check.service` und -`POE_SCRIPT`/`POE_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src` +**`/srv/tesm`** (so wie es `tesm.service`, `tesm-check.service` und +`POE_SCRIPT`/`TESM_DB_PATH` als Default erwarten) — `/opt/aruba-poe-modern-src` wird nur als Checkout-Quelle für `install.sh` gebraucht und kann danach liegen bleiben (für spätere Updates: `git pull` dort, dann `install.sh` erneut ausführen) oder gelöscht werden. @@ -843,19 +1061,19 @@ erneut ausführen) oder gelöscht werden. `install.sh` installiert Systempakete, richtet die venv ein, initialisiert die Datenbank (falls nicht vorhanden), kopiert `poe.sh` nach `/usr/local/bin/custom/`, registriert die systemd-Units -(`poe_web.service`, `rpi-check.service`, `rpi-check-restart.timer`) und +(`tesm.service`, `tesm-check.service`, `tesm-check-restart.timer`) und konfiguriert nginx als Reverse-Proxy auf Port 80. ### Relevante Umgebungsvariablen (optional) | Variable | Zweck | Default | |-----------------------|------------------------------------------|---------------------------------------| -| `POE_DB_PATH` | Pfad zur SQLite-DB | `/sqlite.db` | +| `TESM_DB_PATH` | Pfad zur SQLite-DB | `/sqlite.db` | | `POE_SCRIPT` | Pfad zum PoE-Restart-Skript | `/usr/local/bin/custom/poe.sh` | -| `POE_LOG_GLOB` | Glob-Pattern der Check-Logs | `/var/log/rpi-*.log` | -| `POE_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `/fernet.key` (auto-erzeugt)| -| `POE_KNOWN_HOSTS` | Pfad zur SSH-known_hosts-Datei des Terminals| `/known_hosts` (auto-erzeugt)| -| `POE_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert| +| `TESM_LOG_DIR` | Verzeichnis für `live.log`/`changes.log`/`app.log` | `/var/log/tesm` | +| `TESM_FERNET_KEY` | Pfad zum Fernet-Schlüssel | `/fernet.key` (auto-erzeugt)| +| `TESM_KNOWN_HOSTS` | Pfad zur SSH-known_hosts-Datei des Terminals| `/known_hosts` (auto-erzeugt)| +| `TESM_SECRET_KEY` | Flask Secret Key (überschreibt Datei) | zufällig, in `secret.key` gespeichert| | `PORT`, `FLASK_DEBUG` | Flask-Serverport / Debug-Modus | `5000` / aus | ## Browser-SSH-Terminal (Switch-Verbindungstest) @@ -881,24 +1099,24 @@ Voraussetzungen: ohnehin ein WSGI-Server mit WebSocket-Unterstützung (z.B. Gunicorn mit `gevent`- oder `eventlet`-Worker) statt des eingebauten Dev-Servers. - **nginx muss den WebSocket-Upgrade für `/ws/` durchreichen** (`Upgrade`/ - `Connection`-Header, siehe `etc/nginx/sites-available/poe_manager`) — ohne + `Connection`-Header, siehe `etc/nginx/sites-available/tesm`) — ohne das lehnt Flask den Handshake mit `400 Bad Request` ab und das Terminal bleibt leer auf „Getrennt“ hängen, obwohl derselbe Aufruf direkt gegen Port 5000 (an nginx vorbei) funktioniert. War auf einer frisch per `install.sh` aufgesetzten Ubuntu-VM live reproduzierbar, da alle bisherigen Terminal-Tests ausschließlich direkt gegen Flask liefen, nie durch nginx. -- Da `poe_web.service` (wie im Original) als `root` läuft, kann über dieses +- Da `tesm.service` (wie im Original) als `root` läuft, kann über dieses Terminal im Prinzip jeder erreichbare Host per SSH kontaktiert werden — bewusst so gewählt, da Admins über die App ohnehin schon vollen Zugriff auf alle hinterlegten Switch-Zugangsdaten haben. Für eine strengere Trennung - empfiehlt es sich, `poe_web.service` künftig unter einem eigenen, + empfiehlt es sich, `tesm.service` künftig unter einem eigenen, unprivilegierten Systembenutzer laufen zu lassen (im Original wie auch hier aktuell `root`, um Änderungen am produktiven Setup nicht zu vergrößern). ## Projektstruktur ``` -srv/poe_manager/ Flask-App (Backend + Templates + Static-Assets) +srv/tesm/ Flask-App (Backend + Templates + Static-Assets) usr/local/bin/custom/ poe.sh — Ping-Check & PoE-Restart per SSH (expect) etc/systemd/system/ Service-/Timer-Units (Web-App + Check-Dienst) etc/nginx/ Reverse-Proxy-Konfiguration diff --git a/etc/nginx/sites-available/poe_manager b/etc/nginx/sites-available/tesm similarity index 97% rename from etc/nginx/sites-available/poe_manager rename to etc/nginx/sites-available/tesm index 53bdd0e..ce7e73c 100644 --- a/etc/nginx/sites-available/poe_manager +++ b/etc/nginx/sites-available/tesm @@ -37,7 +37,7 @@ server { } location /static/ { - alias /srv/poe_manager/static/; + alias /srv/tesm/static/; expires 7d; } } diff --git a/etc/systemd/system/rpi-check-restart.service b/etc/systemd/system/rpi-check-restart.service deleted file mode 100644 index 36e61c3..0000000 --- a/etc/systemd/system/rpi-check-restart.service +++ /dev/null @@ -1,6 +0,0 @@ -[Unit] -Description=Restart rpi-check.service - -[Service] -Type=oneshot -ExecStart=/bin/systemctl restart rpi-check.service \ No newline at end of file diff --git a/etc/systemd/system/rpi-check-restart.timer b/etc/systemd/system/rpi-check-restart.timer deleted file mode 100644 index 0770db3..0000000 --- a/etc/systemd/system/rpi-check-restart.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Restart RPI Check daily at 00:00 - -[Timer] -OnCalendar=*-*-* 00:00:00 -Persistent=true -Unit=rpi-check-restart.service - -[Install] -WantedBy=timers.target \ No newline at end of file diff --git a/etc/systemd/system/rpi-check.service b/etc/systemd/system/rpi-check.service deleted file mode 100644 index 7968706..0000000 --- a/etc/systemd/system/rpi-check.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=RPI Ping Check -After=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=/srv/poe_manager -ExecStart=/srv/poe_manager/venv/bin/python3 /srv/poe_manager/poe_wrapper.py -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/etc/systemd/system/tesm-check-restart.service b/etc/systemd/system/tesm-check-restart.service new file mode 100644 index 0000000..edd81bb --- /dev/null +++ b/etc/systemd/system/tesm-check-restart.service @@ -0,0 +1,6 @@ +[Unit] +Description=Restart TESM Check + +[Service] +Type=oneshot +ExecStart=/bin/systemctl restart tesm-check.service diff --git a/etc/systemd/system/tesm-check-restart.timer b/etc/systemd/system/tesm-check-restart.timer new file mode 100644 index 0000000..352997c --- /dev/null +++ b/etc/systemd/system/tesm-check-restart.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Restart TESM Check daily at 00:00 + +[Timer] +OnCalendar=*-*-* 00:00:00 +Persistent=true +Unit=tesm-check-restart.service + +[Install] +WantedBy=timers.target diff --git a/etc/systemd/system/tesm-check.service b/etc/systemd/system/tesm-check.service new file mode 100644 index 0000000..3f2776b --- /dev/null +++ b/etc/systemd/system/tesm-check.service @@ -0,0 +1,14 @@ +[Unit] +Description=TESM Ping Check +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/srv/tesm +ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/poe_wrapper.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/system/poe_web.service b/etc/systemd/system/tesm.service similarity index 53% rename from etc/systemd/system/poe_web.service rename to etc/systemd/system/tesm.service index b8a4352..1c59b3e 100644 --- a/etc/systemd/system/poe_web.service +++ b/etc/systemd/system/tesm.service @@ -1,16 +1,15 @@ [Unit] -Description=PoE Manager Web App +Description=TESM Web App After=network.target [Service] Type=simple User=root -WorkingDirectory=/srv/poe_manager +WorkingDirectory=/srv/tesm # Nutze die virtuelle Umgebung -ExecStart=/srv/poe_manager/venv/bin/python3 /srv/poe_manager/app.py +ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/app.py Restart=always RestartSec=5 [Install] WantedBy=multi-user.target - diff --git a/install.sh b/install.sh index be16cb0..b9d2da7 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,9 @@ #!/bin/bash # ============================================================================ -# PoE Manager — Installer +# TESM — Installer # Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits- # Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses -# Repo z.B. nach /root/aruba-poe-modern geklont wurde. +# Repo z.B. nach /root/tesm geklont wurde. # ============================================================================ set -e @@ -13,45 +13,45 @@ GREEN='\033[0;32m' NC='\033[0m' print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; } -step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/poe-manager-install.log; } +step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; } clear # ---- Pakete ---- step "Installing system packages" -sudo apt-get update >>/var/log/poe-manager-install.log 2>&1 && print_status "apt update" -sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/poe-manager-install.log 2>&1 && print_status "Packages installed" +sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update" +sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed" # ---- Log-Verzeichnis ---- -# Weltweit beschreibbar, da sowohl root (poe_web.service/poe.sh) als auch +# Weltweit beschreibbar, da sowohl root (tesm.service/poe.sh) als auch # der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier # ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche -# logrotate-Konfiguration (/etc/logrotate.d/poe-manager) schreibt die App +# logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt die App # selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs. -sudo mkdir -p /var/log/poe-manager -sudo chmod 777 /var/log/poe-manager +sudo mkdir -p /var/log/tesm +sudo chmod 777 /var/log/tesm # ---- App-Verzeichnis ---- -step "Deploying application to /srv/poe_manager" -sudo mkdir -p /srv/poe_manager +step "Deploying application to /srv/tesm" +sudo mkdir -p /srv/tesm sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \ - "$REPO_DIR/srv/poe_manager/" /srv/poe_manager/ >>/var/log/poe-manager-install.log 2>&1 + "$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1 print_status "Application files copied" # ---- Python venv ---- step "Setting up Python virtual environment" -cd /srv/poe_manager +cd /srv/tesm sudo python3 -m venv venv -sudo ./venv/bin/pip install --upgrade pip >>/var/log/poe-manager-install.log 2>&1 -sudo ./venv/bin/pip install -r requirements.txt >>/var/log/poe-manager-install.log 2>&1 +sudo ./venv/bin/pip install --upgrade pip >>/var/log/tesm-install.log 2>&1 +sudo ./venv/bin/pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1 print_status "Virtual environment ready" # ---- Datenbank ---- -if [ ! -f /srv/poe_manager/sqlite.db ]; then +if [ ! -f /srv/tesm/sqlite.db ]; then step "Initializing database" - sudo ./venv/bin/python3 create_db.py >>/var/log/poe-manager-install.log 2>&1 + sudo ./venv/bin/python3 create_db.py >>/var/log/tesm-install.log 2>&1 print_status "Database initialized" - echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/poe_manager/venv/bin/python3 /srv/poe_manager/create_admin.py" + echo -e "${RED}→${NC} Kein Admin-Benutzer vorhanden. Bitte danach ausführen: sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py" fi # ---- Custom-Skripte + systemd ---- @@ -65,17 +65,17 @@ print_status "Scripts and units copied" # ---- nginx ---- step "Configuring nginx reverse proxy" -sudo cp "$REPO_DIR/etc/nginx/sites-available/poe_manager" /etc/nginx/sites-available/poe_manager -sudo ln -sf /etc/nginx/sites-available/poe_manager /etc/nginx/sites-enabled/poe_manager +sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm +sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm sudo rm -f /etc/nginx/sites-enabled/default -sudo nginx -t >>/var/log/poe-manager-install.log 2>&1 && sudo systemctl reload nginx +sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx print_status "nginx configured" # ---- Dienste aktivieren ---- step "Enabling services" sudo systemctl daemon-reload -sudo systemctl enable --now poe_web.service -sudo systemctl enable --now rpi-check.service rpi-check-restart.timer +sudo systemctl enable --now tesm.service +sudo systemctl enable --now tesm-check.service tesm-check-restart.timer print_status "Services enabled and started" echo -e "${GREEN}✔${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)." diff --git a/srv/poe_manager/templates/settings_import_export.html b/srv/poe_manager/templates/settings_import_export.html deleted file mode 100644 index 6f45679..0000000 --- a/srv/poe_manager/templates/settings_import_export.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "settings_importexport" %} -{% block page_title %}Im-/Export{% endblock %} -{% block page_sub %}
Umzug auf eine neue Umgebung
{% endblock %} - -{% block content %} -
- -
-
-
-

Export

-
Geräte, Switche und Zugangsdaten verschlüsselt sichern.
-
-
-
-
- - -
Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.
-
- -
-
- -
-
-
-

Import

-
Exportiertes Bundle einlesen — vorhandene Einträge werden aktualisiert, neue angelegt.
-
-
- {% if current_user.has_permission('settings_importexport.edit') %} -
-
- - -
-
- - -
- -
- {% else %} -

Für den Import fehlt das Recht „Im-/Export ändern“.

- {% endif %} -
- -
-{% endblock %} diff --git a/srv/poe_manager/templates/users.html b/srv/poe_manager/templates/users.html deleted file mode 100644 index 196a4a4..0000000 --- a/srv/poe_manager/templates/users.html +++ /dev/null @@ -1,177 +0,0 @@ -{% extends "base.html" %} -{% set active_page = "users" %} -{% block page_title %}Benutzer{% endblock %} -{% block page_sub %}
{{ users|length }} Benutzer
{% endblock %} - -{% block content %} - -
-
-

Benutzer

-
Die Gruppe bestimmt die Rechte eines Benutzers.
-
- {% if current_user.has_permission('users.create') %} - - {% endif %} -
- -
-
- - - - - - - - - {% for u in users %} - {% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %} - {% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %} - - - - - {% set may_touch_target = current_user.is_admin or not u['is_admin'] %} - - - {% else %} - - {% endfor %} - -
UsernameNameGruppeAktionen
{{ u['username'] }}{{ full_name or '—' }} - {% if u['is_admin'] %} - Admin - {% else %} - {{ u['group_names'] or '—' }} - {% endif %} - -
- {% if current_user.has_permission('users.edit') and may_touch_target %} - - - {% endif %} - {% if current_user.has_permission('users.edit') and may_touch_target %} -
- - -
- {% endif %} -
-
Noch keine Benutzer vorhanden.
-
-
- - - - - - - - - - -{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/srv/poe_manager/app.py b/srv/tesm/app.py similarity index 68% rename from srv/poe_manager/app.py rename to srv/tesm/app.py index 669898e..e3b0666 100644 --- a/srv/poe_manager/app.py +++ b/srv/tesm/app.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -PoE Manager — Web-App zur Überwachung und Steuerung von PoE-Devices -über Aruba-Switche. +TESM (TimEShepManager) — Web-App zur Überwachung und Steuerung von +PoE-Devices über Aruba-Switche. Funktional identisch zur Ursprungsversion (Login, Dashboard, Device-/ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart), @@ -17,10 +17,19 @@ from werkzeug.utils import secure_filename from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes -from datetime import datetime +from datetime import datetime, timedelta import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback import paramiko import yaml +import ssl +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" class _SendAllSocketProxy: @@ -70,28 +79,28 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # --------------------------------------------------------------------------- # Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults -# passend zum produktiven Deployment unter /srv/poe_manager) +# passend zum produktiven Deployment unter /srv/tesm) # --------------------------------------------------------------------------- -DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db")) +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("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) -SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts")) -SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key")) +FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key")) +SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts")) +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): -# poe_web.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R. +# 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. -POE_LOG_DIR = os.environ.get("POE_LOG_DIR", "/var/log/poe-manager") -POE_LIVE_LOG_PATH = os.path.join(POE_LOG_DIR, "live.log") -POE_CHANGES_LOG_PATH = os.path.join(POE_LOG_DIR, "changes.log") -POE_APP_LOG_PATH = os.path.join(POE_LOG_DIR, "app.log") -# NICHT unter POE_LOG_DIR: kea-dhcp4s AppArmor-Profil +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 ... @@ -99,11 +108,11 @@ POE_APP_LOG_PATH = os.path.join(POE_LOG_DIR, "app.log") # 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. -POE_KEA_LOG_PATH = os.environ.get("POE_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log") -LOGROTATE_CONFIG_PATH = os.environ.get("POE_LOGROTATE_CONFIG", "/etc/logrotate.d/poe-manager") +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: - os.makedirs(POE_LOG_DIR, exist_ok=True) - os.chmod(POE_LOG_DIR, 0o777) + os.makedirs(TESM_LOG_DIR, exist_ok=True) + os.chmod(TESM_LOG_DIR, 0o777) except OSError: pass # Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat @@ -136,7 +145,7 @@ app = Flask(__name__) # WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose, # kein Access-Log. try: - _app_log_handler = logging.FileHandler(POE_APP_LOG_PATH, encoding="utf-8") + _app_log_handler = logging.FileHandler(TESM_APP_LOG_PATH, encoding="utf-8") _app_log_handler.setLevel(logging.WARNING) _app_log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S")) app.logger.addHandler(_app_log_handler) @@ -146,7 +155,7 @@ except OSError: def _load_or_create_secret() -> str: - env_secret = os.environ.get("POE_SECRET_KEY") + env_secret = os.environ.get("TESM_SECRET_KEY") if env_secret: return env_secret if os.path.exists(SECRET_KEY_PATH): @@ -199,6 +208,7 @@ PERMISSION_ROW_TYPES = [ ("create", "W", "Schreiben/Anlegen (Write)"), ("edit", "E", "Ändern inkl. Löschen (Edit)"), ("extra", "D", "Dashboard-Neustart (nur bei Clients — kein Löschen-Recht)"), + ("export", "X", "Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)"), ] PERMISSIONS = { @@ -285,8 +295,16 @@ PERMISSIONS = { }, "settings_importexport": { "label": "Im-/Export", - # L = Export (nur lesend), Ä = Import (verändert Bestand). - "rows": {"view": "settings_importexport.view", "edit": "settings_importexport.edit"}, + # 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", + "export": "settings_importexport.export", + }, }, "settings_dhcp": { "label": "DHCP", @@ -294,6 +312,10 @@ PERMISSIONS = { # speichern und die generierte Reservierungsdatei schreiben. "rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"}, }, + "settings_ldap": { + "label": "LDAP", + "rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"}, + }, }, }, } @@ -320,9 +342,13 @@ PERMISSION_LABELS = { "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.edit": "Gruppen ändern (inkl. Löschen)", "settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern", - "settings_importexport.view": "Daten exportieren", "settings_importexport.edit": "Daten importieren", + "settings_importexport.view": "Im-/Export-Seite ansehen", + "settings_importexport.edit": "Daten importieren", + "settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)", "settings_dhcp.view": "DHCP-Status und -Konfiguration lesen", "settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben", + "settings_ldap.view": "LDAP/AD-Konfiguration lesen", + "settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)", } # Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie @@ -374,13 +400,15 @@ NAV_ITEMS = [ {"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"}, {"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"}, {"key": "maintenance", "label": "Wartung", "icon": "wrench", "endpoint": "maintenance"}, + {"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"}, ]}, + {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, + {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, {"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [ - {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, - {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, {"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"}, - {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, + {"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"}, {"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"}, + {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, ]}, {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, @@ -403,6 +431,8 @@ def _nav_key_visible(key, user): return user.can_manage_credentials if key == "maintenance": return user.can_view_maintenance + if key == "papierkorb": + return user.can_view_papierkorb if key == "users": return user.can_manage_users if key == "groups": @@ -413,6 +443,8 @@ def _nav_key_visible(key, user): return user.can_view_settings_importexport if key == "settings_dhcp": return user.can_view_settings_dhcp + if key == "settings_ldap": + return user.can_view_settings_ldap if key == "logs_live": return user.can_view_live_log if key == "logs_activity": @@ -471,6 +503,12 @@ def inject_asset_version(): return {"asset_url": asset_url} +@app.context_processor +def inject_current_year(): + """Für die Copyright-Zeile im Sidebar-Footer (siehe base.html).""" + return {"current_year": datetime.now().year} + + @app.context_processor def inject_nav(): if not current_user.is_authenticated: @@ -488,7 +526,7 @@ def inject_nav(): class User(UserMixin): def __init__(self, id_, username, is_admin, permissions=None, group_names=None, - first_name=None, last_name=None, avatar_filename=None): + first_name=None, last_name=None, avatar_filename=None, auth_source="local"): self.id = id_ self.username = username self.is_admin = bool(is_admin) @@ -497,6 +535,11 @@ class User(UserMixin): self.first_name = first_name self.last_name = last_name self.avatar_filename = avatar_filename + self.auth_source = auth_source + + @property + def is_ldap_user(self): + return self.auth_source == "ldap" def has_permission(self, key): """Admins dürfen immer alles. Sonst muss das Recht selbst gesetzt @@ -538,6 +581,16 @@ class User(UserMixin): def can_run_maintenance(self): return self.has_permission("maintenance.edit") + @property + def can_view_papierkorb(self): + """Papierkorb-Seite: sichtbar sobald mind. eine der fünf dort + gelisteten Ressourcen (Geräte/Switche/Zugangsdaten/Benutzer/Gruppen) + View-Recht hat — jede Kachel darin ist ohnehin einzeln je Ressource + gated (siehe papierkorb.html).""" + return self.has_any_permission( + ["devices.view", "switches.view", "credentials.view", "users.view", "groups.view"] + ) + @property def can_view_live_log(self): return self.has_permission("logs_live.view") @@ -568,12 +621,22 @@ class User(UserMixin): @property def can_view_settings_importexport(self): - return self.has_permission("settings_importexport.view") + # 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", + ]) @property def can_view_settings_dhcp(self): return self.has_permission("settings_dhcp.view") + @property + def can_view_settings_ldap(self): + return self.has_any_permission(["settings_ldap.view", "settings_ldap.edit"]) + @property def full_name(self): parts = [p for p in (self.first_name, self.last_name) if p] @@ -722,6 +785,56 @@ 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, + purpose TEXT UNIQUE NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL + ) + """) + + # 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, + ad_group_dn TEXT UNIQUE NOT NULL, + ad_group_name TEXT NOT NULL, + app_group_id INTEGER NOT NULL, + FOREIGN KEY (app_group_id) REFERENCES groups(id) + ) + """) # Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne # PoE-Neustarts (die stehen bereits im Live-Log von poe.sh). @@ -736,8 +849,15 @@ def _ensure_schema(): ) """) - # "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log. + # 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: @@ -785,6 +905,19 @@ def _ensure_schema(): 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(""" @@ -1046,7 +1179,7 @@ def _ensure_schema(): # 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 - # (POE_LIVE_LOG_PATH unter /var/log/poe-manager/) verwaist — poe.sh + # (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 @@ -1085,7 +1218,7 @@ def _ensure_schema(): orphan_users = conn.execute(""" SELECT users.id FROM users LEFT JOIN user_groups ON user_groups.user_id = users.id - WHERE users.is_admin = 0 AND user_groups.user_id IS NULL + WHERE users.is_admin = 0 AND user_groups.user_id IS NULL AND users.deleted_at IS NULL """).fetchall() conn.executemany( "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", @@ -1103,10 +1236,14 @@ 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 - WHERE ug.user_id = ? + WHERE ug.user_id = ? AND g.deleted_at IS NULL ORDER BY g.name ASC """, (row["id"],)).fetchall() @@ -1126,12 +1263,13 @@ def _build_user(row): first_name=row["first_name"] if "first_name" in row.keys() else None, last_name=row["last_name"] if "last_name" in row.keys() else None, avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None, + auth_source=row["auth_source"] if "auth_source" in row.keys() else "local", ) def _append_changes_log(ts, who, action, target, details): """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per - logrotate rotierte Datei (POE_CHANGES_LOG_PATH) — die audit_log-Tabelle + logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle bleibt die Quelle für die durchsuchbare/sortierbare Seite unter Logs → Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs. @@ -1143,7 +1281,7 @@ def _append_changes_log(ts, who, action, target, details): line += f" — {target}" if details: line += f": {details}" - with open(POE_CHANGES_LOG_PATH, "a", encoding="utf-8") as f: + with open(TESM_CHANGES_LOG_PATH, "a", encoding="utf-8") as f: f.write(line + "\n") except OSError: pass @@ -1232,9 +1370,9 @@ LOG_ROTATION_DEFAULT_KEEP = 4 def _write_logrotate_config(): """Schreibt/aktualisiert die logrotate-Konfiguration für alle vier - App-Logs (Live, Änderungen, App unter POE_LOG_DIR; Kea-DHCP separat - unter POE_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen - einen Pfad, siehe Kommentar bei POE_KEA_LOG_PATH) anhand der aktuellen + App-Logs (Live, Änderungen, App unter TESM_LOG_DIR; Kea-DHCP separat + unter TESM_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen + einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start (Rotation ist dadurch auch ohne einen expliziten Besuch der Settings-Seite von Anfang an aktiv) als auch nach jedem Speichern dort. @@ -1255,7 +1393,7 @@ def _write_logrotate_config(): keep = LOG_ROTATION_DEFAULT_KEEP content = ( - f"{POE_LOG_DIR}/*.log {POE_KEA_LOG_PATH} {{\n" + f"{TESM_LOG_DIR}/*.log {TESM_KEA_LOG_PATH} {{\n" f" {interval}\n" f" rotate {keep}\n" f" missingok\n" @@ -1277,29 +1415,601 @@ _write_logrotate_config() @login_manager.user_loader def load_user(user_id): conn = get_db_connection() - user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + # 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: return _build_user(user) 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" + + +def _ldap_groups_for_dropdown(): + conn = get_db_connection() + rows = conn.execute( + "SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC" + ).fetchall() + conn.close() + return rows + + +LDAP_BIND_SERVICE_ACCOUNT_PURPOSE = "ldap_bind" + + +def _ldap_bind_account(): + """Liest das LDAP-Bind-Konto aus der service_accounts-Tabelle (nicht + aus settings — siehe Kommentar bei der Tabellen-Definition).""" + conn = get_db_connection() + row = conn.execute( + "SELECT username, password FROM service_accounts WHERE purpose=?", + (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,), + ).fetchone() + conn.close() + return (row["username"], row["password"]) if row else ("", "") + + +def _ldap_settings(): + bind_dn, bind_password_enc = _ldap_bind_account() + return { + "enabled": get_setting("ldap_enabled", "0") == "1", + "server": get_setting("ldap_server", "") or "", + "port": get_setting("ldap_port", str(LDAP_DEFAULT_PORT)) or str(LDAP_DEFAULT_PORT), + "use_ssl": get_setting("ldap_use_ssl", "0") == "1", + "tls_skip_verify": get_setting("ldap_tls_skip_verify", "0") == "1", + "bind_dn": bind_dn, + "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 "", + } + + +def _ldap_connect_service(cfg): + """Baut Server-Objekt + Service-Konto-Verbindung auf. Wirft nie — + liefert (server, connection) oder (None, None) samt Fehlermeldung im + zweiten Rückgabewert bei Bedarf über die aufrufende Funktion.""" + try: + port = int(cfg["port"]) + except (TypeError, ValueError): + port = LDAP_DEFAULT_PORT + try: + bind_password = decrypt_password(cfg["bind_password_enc"]) if cfg["bind_password_enc"] else "" + 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) + server = Server(cfg["server"], port=port, use_ssl=cfg["use_ssl"], tls=tls, get_info=ALL, connect_timeout=5) + conn = Connection( + server, user=cfg["bind_dn"], password=bind_password, + authentication=SIMPLE, auto_bind=True, receive_timeout=5, + ) + return (server, conn), None + except LDAPException as e: + return None, str(e) + + +def _ldap_base_dn(server, cfg): + if cfg["base_dn"].strip(): + return cfg["base_dn"].strip() + try: + return str(server.info.other["defaultNamingContext"][0]) + except (KeyError, IndexError, TypeError, AttributeError): + return None + + +def _ldap_test_connection(cfg=None): + """Für den 'Verbindung testen'-Button in den Systemeinstellungen — + bindet mit dem Service-Konto und ermittelt die (automatische oder + konfigurierte) Base-DN, ohne einen echten Benutzer zu prüfen. Testet + standardmäßig die zuletzt gespeicherten Einstellungen, akzeptiert aber + ein cfg-Override, damit "Verbindung testen" wie beim SSH-Terminal von + Switchen/Geräten die aktuell im Formular eingetragenen (noch nicht + gespeicherten) Werte prüfen kann. Gibt (True, meldung) oder + (False, fehler) zurück.""" + cfg = cfg or _ldap_settings() + if not cfg["server"] or not cfg["bind_dn"]: + return False, "Server und Bind-Konto müssen ausgefüllt sein." + result, error = _ldap_connect_service(cfg) + if error: + return False, f"Verbindung/Bind fehlgeschlagen: {error}" + server, conn = result + base_dn = _ldap_base_dn(server, cfg) + conn.unbind() + if not base_dn: + return False, "Verbunden, aber Base-DN konnte nicht automatisch ermittelt werden — bitte manuell angeben." + return True, f"Verbindung erfolgreich. Base-DN: {base_dn}" + + +_LDAP_ENTRY_ATTRS = ["distinguishedName", "givenName", "sn", "displayName", "userPrincipalName", "userAccountControl"] + + +def _ldap_find_entry(service_conn, base_dn, cfg, value): + """Sucht GENAU einen Verzeichniseintrag, dessen konfiguriertes + Login-Attribut (Standard sAMAccountName) ODER UPN (userPrincipalName, + dient hier zugleich als "E-Mail" -- siehe _ldap_entry_info) mit dem + übergebenen Wert übereinstimmt — deckt damit Login per bloßem + Benutzernamen UND per user@domain in einer einzigen Suche ab. Fällt bei + einem LDAP-Fehler (z.B. ein Server ohne userPrincipalName-Attribut im + Schema) auf eine einfache Suche nur nach dem Login-Attribut zurück. + Gibt das ldap3-Entry-Objekt oder None zurück (None auch bei mehr als + einem Treffer — mehrdeutig soll wie "nicht gefunden" behandelt werden).""" + safe_value = escape_filter_chars(value) + combined_filter = f"(|({cfg['filter_attr']}={safe_value})(userPrincipalName={safe_value}))" + try: + service_conn.search(search_base=base_dn, search_filter=combined_filter, attributes=_LDAP_ENTRY_ATTRS) + except LDAPException: + try: + service_conn.search( + search_base=base_dn, search_filter=f"({cfg['filter_attr']}={safe_value})", + attributes=_LDAP_ENTRY_ATTRS, + ) + except LDAPException: + return None + if len(service_conn.entries) != 1: + return None + return service_conn.entries[0] + + +def _ldap_entry_info(entry, cfg): + """Extrahiert Benutzername/Name/E-Mail/Sperrstatus aus einem + ldap3-Entry. Nutzt givenName/sn (echte AD-Attribute) statt eine + Vermutung anhand von displayName — fällt auf ein Aufsplitten von + displayName nur zurück, wenn givenName/sn nicht gesetzt sind (z.B. + minimalistisches generisches LDAP ohne diese Attribute).""" + def _attr(name): + val = getattr(entry, name, None) + return str(val) if val is not None and val.value else None + + canonical_username = _attr(cfg["filter_attr"]) + first_name = _attr("givenName") + last_name = _attr("sn") + if not first_name and not last_name: + display_name = _attr("displayName") or "" + first_name, _, last_name = display_name.partition(" ") + first_name = first_name or None + last_name = last_name or None + + disabled = False + uac = _attr("userAccountControl") + if uac is not None: + try: + disabled = bool(int(uac) & 2) # ACCOUNTDISABLE-Bit, nur bei AD vorhanden + except ValueError: + pass + + return { + "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, + } + + +def _ldap_lookup(username): + """Wie _ldap_authenticate(), aber OHNE Passwort-Prüfung — für die + Vorab-Zuweisung eines AD-Benutzers zu einer Gruppe, bevor er sich das + erste Mal angemeldet hat (siehe /users/ldap_add). Gibt (True, info) + oder (False, None) zurück.""" + cfg = _ldap_settings() + if not cfg["enabled"] or not username: + return False, None + if not cfg["server"] or not cfg["bind_dn"]: + return False, None + result, error = _ldap_connect_service(cfg) + if error: + return False, None + server, service_conn = result + try: + base_dn = _ldap_base_dn(server, cfg) + if not base_dn: + return False, None + entry = _ldap_find_entry(service_conn, base_dn, cfg, username) + if not entry: + return False, None + return True, _ldap_entry_info(entry, cfg) + except LDAPException: + return False, None + finally: + try: + service_conn.unbind() + except Exception: + pass + + +def _ldap_search_users(query, limit=25): + """Durchsucht das Verzeichnis per Substring-Suche über Login-Attribut, + Vor-/Nachname und E-Mail — für "Aus Active Directory hinzufügen" in + der Benutzerverwaltung (Vorab-Zuweisung, siehe _ldap_lookup). Gibt eine + Liste von Dicts zurück (siehe _ldap_entry_info), leer bei Fehler/ + deaktiviertem LDAP.""" + cfg = _ldap_settings() + if not cfg["enabled"] or not query.strip(): + return [] + if not cfg["server"] or not cfg["bind_dn"]: + return [] + result, error = _ldap_connect_service(cfg) + if error: + return [] + server, service_conn = result + try: + base_dn = _ldap_base_dn(server, cfg) + if not base_dn: + return [] + safe_q = escape_filter_chars(query.strip()) + filt = ( + f"(&(objectClass=person)" + f"(|({cfg['filter_attr']}=*{safe_q}*)(displayName=*{safe_q}*)(givenName=*{safe_q}*)(sn=*{safe_q}*)(userPrincipalName=*{safe_q}*)))" + ) + try: + service_conn.search(search_base=base_dn, search_filter=filt, attributes=_LDAP_ENTRY_ATTRS, size_limit=limit) + except LDAPException: + return [] + results = [] + for entry in service_conn.entries: + info = _ldap_entry_info(entry, cfg) + if info["username"]: + results.append(info) + return results + except LDAPException: + return [] + finally: + try: + service_conn.unbind() + except Exception: + pass + + +def _ldap_is_member_of(service_conn, user_dn, group_dn): + """Prüft AD-Gruppenmitgliedschaft REKURSIV (auch über verschachtelte + Gruppen) über LDAP_MATCHING_RULE_IN_CHAIN, statt nur das flache, + nicht-rekursive memberOf-Attribut des Benutzers direkt zu lesen — + jemand in einer Gruppe, die wiederum Mitglied der erforderlichen + Gruppe ist, soll sich genauso anmelden dürfen wie ein direktes + Mitglied.""" + try: + service_conn.search( + search_base=user_dn, search_scope=BASE, + search_filter=f"(memberOf:{LDAP_MATCHING_RULE_IN_CHAIN}:={escape_filter_chars(group_dn)})", + attributes=[], + ) + return len(service_conn.entries) == 1 + except LDAPException: + return False + + +def _ldap_list_groups(limit=300): + """Alle AD-Gruppen fürs Auswahl-Dropdown der erforderlichen Gruppe in + den Systemeinstellungen — per Service-Konto, keine Benutzer-Zugangsdaten + nötig. Gibt eine Liste von {"dn":..., "name":...} zurück, sortiert nach + Name, leer bei Fehler.""" + cfg = _ldap_settings() + if not cfg["server"] or not cfg["bind_dn"]: + return [] + result, error = _ldap_connect_service(cfg) + if error: + return [] + server, service_conn = result + try: + base_dn = _ldap_base_dn(server, cfg) + if not base_dn: + return [] + try: + service_conn.search( + search_base=base_dn, search_filter="(objectClass=group)", + attributes=["distinguishedName", "cn", "sAMAccountName"], size_limit=limit, + ) + except LDAPException: + return [] + groups = [] + for entry in service_conn.entries: + dn = str(entry.distinguishedName) if "distinguishedName" in entry and entry.distinguishedName.value else None + if not dn: + continue + name = None + if "cn" in entry and entry.cn.value: + name = str(entry.cn) + elif "sAMAccountName" in entry and entry.sAMAccountName.value: + name = str(entry.sAMAccountName) + groups.append({"dn": dn, "name": name or dn}) + groups.sort(key=lambda g: g["name"].lower()) + return groups + except LDAPException: + return [] + finally: + try: + service_conn.unbind() + except Exception: + pass + + +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 + WHEN m.app_group_id = 'admin' THEN 'Admin' + WHEN g.name IS NULL THEN '(gelöscht)' + ELSE g.name + END AS app_group_name + FROM ldap_group_mappings m + LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL + ORDER BY m.ad_group_name ASC + """).fetchall() + conn.close() + return rows + + +def _ldap_resolve_app_groups(service_conn, user_dn): + """Prüft für jede konfigurierte AD-Gruppen-Zuordnung, ob der Benutzer + Mitglied ist (auch über verschachtelte Gruppen, siehe + _ldap_is_member_of), und gibt die Menge der zugeordneten App-Gruppen + zurück -- normale Gruppen-IDs (int) sowie ggf. das Sentinel "admin". + Additiv gedacht: mehrere passende AD-Gruppen ergeben die Vereinigung + ihrer App-Gruppen, passend zum bestehenden Rechtesystem (mehrere + Gruppen je Benutzer, Rechte addieren sich).""" + matched = set() + for mapping in _ldap_group_mappings(): + if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]): + matched.add(mapping["app_group_id"]) + return matched + + +def _ldap_authenticate(username, password): + """Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry + für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info + wie bei _ldap_entry_info), sonst (False, None) — nie eine Exception + nach außen, ein LDAP-Fehler soll wie eine falsche Anmeldung behandelt + werden, nicht die Login-Seite mit einem 500er abbrechen.""" + cfg = _ldap_settings() + if not cfg["enabled"] or not username or not password: + return False, None + if not cfg["server"] or not cfg["bind_dn"]: + return False, None + + result, error = _ldap_connect_service(cfg) + if error: + return False, None + server, service_conn = result + + try: + base_dn = _ldap_base_dn(server, cfg) + if not base_dn: + return False, None + entry = _ldap_find_entry(service_conn, base_dn, cfg, username) + if not entry: + return False, None + user_dn = str(entry.distinguishedName) + 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 + finally: + try: + service_conn.unbind() + except Exception: + pass + + try: + user_conn = Connection( + server, user=user_dn, password=password, + authentication=SIMPLE, auto_bind=True, receive_timeout=5, + ) + user_conn.unbind() + except LDAPException: + return False, None + + return True, info + + # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- +def _assign_ldap_groups(conn, user_id, app_groups): + """Weist die per AD-Gruppenzuordnung ermittelten App-Gruppen zu — rein + additiv (nimmt nie eine bereits vorhandene, z.B. manuell zugewiesene + Gruppe wieder weg), damit ein Admin-Eingriff nie durch den nächsten + Login überschrieben wird. Das Sentinel "admin" setzt is_admin direkt.""" + if "admin" in app_groups: + conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,)) + 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)) + + +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(): + return default_group + row = conn.execute( + "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (DEFAULT_GROUP_NAME,) + ).fetchone() + return row["id"] if row else None + + @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": - username = request.form["username"] + username = request.form["username"].strip() password = request.form["password"] conn = get_db_connection() - user = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() - conn.close() - if user and bcrypt.check_password_hash(user["password"], password): - login_user(_build_user(user)) - return redirect(url_for("index")) - flash("Ungültiger Benutzername oder Passwort", "danger") + + # 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 != '' " + "AND deleted_at IS NULL", + (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", + (username,), + ).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"]), + ) + _assign_ldap_groups(conn, user["id"], info.get("app_groups") or set()) + conn.commit() + refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone() + conn.close() + login_user(_build_user(refreshed)) + return redirect(url_for("index")) + conn.close() + flash("Ungültiger Benutzername oder Passwort", "danger") + + elif user: + conn.close() + if bcrypt.check_password_hash(user["password"], password): + login_user(_build_user(user)) + return redirect(url_for("index")) + 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 + placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8") + app_groups = info.get("app_groups") or set() + is_admin_new = 1 if "admin" in app_groups else 0 + try: + cur = conn.execute( + "INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) " + "VALUES (?, ?, ?, ?, ?, ?, 'ldap')", + (canonical_username, placeholder_hash, is_admin_new, + info.get("first_name"), info.get("last_name"), info.get("email")), + ) + if app_groups: + _assign_ldap_groups(conn, cur.lastrowid, app_groups) + else: + default_group = _ldap_default_group_id(conn) + if default_group: + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", + (cur.lastrowid, default_group), + ) + conn.commit() + new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone() + conn.close() + log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login") + login_user(_build_user(new_user)) + return redirect(url_for("index")) + except sqlite3.IntegrityError: + conn.close() + flash("Ungültiger Benutzername oder Passwort", "danger") + else: + conn.close() + flash("Ungültiger Benutzername oder Passwort", "danger") return render_template("login.html") @@ -1318,20 +2028,14 @@ def logout(): # 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. Admins konfigurieren hier -# zusätzlich die Navbar-Reihenfolge (siehe save_nav_order()). +# Bereich, der andere Benutzer verwaltet. Die Navbar-Reihenfolge (siehe +# save_nav_order()) konfigurieren Admins unter Systemeinstellungen, nicht hier. # --------------------------------------------------------------------------- @app.route("/account") @login_required def account(): - # Volle, ungefilterte Navbar-Struktur für den Reihenfolge-Editor (nur - # für Admins sichtbar/relevant) — anders als das automatisch injizierte - # nav_items_ordered (das nach Berechtigung des jeweils eingeloggten - # Benutzers filtert), damit auch Unterpunkte editierbar sind, die der - # bearbeitende Admin selbst nicht als Nicht-Admin sehen würde. - full_nav_items = _ordered_nav_items() if current_user.is_admin else [] - return render_template("account.html", full_nav_items=full_nav_items) + return render_template("account.html") @app.route("/profile", methods=["POST"]) @@ -1351,6 +2055,14 @@ def profile(): flash("Profil aktualisiert.", "success") 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")) current_password = request.form.get("current_password", "") new_password = request.form.get("new_password", "") confirm_password = request.form.get("confirm_password", "") @@ -1401,7 +2113,7 @@ def _latest_log_file(): logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein neues rpi-.log) gibt es nur noch GENAU eine "aktuelle" Datei, kein Ermitteln der "neuesten von vielen" mehr nötig.""" - return POE_LIVE_LOG_PATH if os.path.exists(POE_LIVE_LOG_PATH) else None + return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None def get_last_seen(dev_name: str): @@ -1411,7 +2123,7 @@ def get_last_seen(dev_name: str): "zuletzt online"-Zeitpunkt eines länger schon offline befindlichen Geräts verloren gehen, obwohl er in einer älteren Rotation noch vorhanden wäre.""" - log_files = glob.glob(POE_LIVE_LOG_PATH + "*") + log_files = glob.glob(TESM_LIVE_LOG_PATH + "*") if not log_files: return None @@ -1560,7 +2272,7 @@ def _dashboard_context(): dieselben Daten liefert wie der initiale Seitenaufruf.""" conn = get_db_connection() all_devices = conn.execute( - "SELECT mac, name, rpi_ip, switch_hostname, port, is_active FROM devices ORDER BY name ASC" + "SELECT mac, name, ip, switch_hostname, port, is_active FROM devices WHERE deleted_at IS NULL ORDER BY name ASC" ).fetchall() conn.close() @@ -1620,13 +2332,13 @@ def dashboard_tiles(): def _restart_check_service(): - """Startet rpi-check.service (poe_wrapper.py -> poe.sh) neu, damit ein + """Startet tesm-check.service (poe_wrapper.py -> poe.sh) neu, damit ein geändertes Prüfintervall sofort greift (poe.sh liest SLEEP nur einmal beim Start aus der DB) — läuft dabei automatisch auch ein sofortiger Prüf-Durchlauf mit frischem Logfile an. Schlägt in Umgebungen ohne systemd (z.B. Container) lautlos fehl, statt die Anfrage abzubrechen.""" try: - subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False) + subprocess.run(["systemctl", "restart", "tesm-check.service"], check=False) except FileNotFoundError: pass # z.B. lokale Entwicklungsumgebung ohne systemd @@ -1678,7 +2390,7 @@ _pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., # 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. -NETPLAN_CONFIG_PATH = "/etc/netplan/90-poe-manager.yaml" +NETPLAN_CONFIG_PATH = "/etc/netplan/90-tesm.yaml" def _detect_network_backend(): @@ -2093,6 +2805,24 @@ def settings(): else: flash(f"Einstellung gespeichert, aber {LOGROTATE_CONFIG_PATH} konnte nicht geschrieben werden.", "danger") + elif "save_trash_retention" in request.form: + new_days_raw = request.form.get("trash_retention_days", "").strip() + if not new_days_raw.isdigit() or int(new_days_raw) < 1: + flash("Aufbewahrungsdauer muss eine Zahl ≥ 1 sein.", "danger") + else: + set_setting("trash_retention_days", new_days_raw) + log_action("settings.update", "Papierkorb-Aufbewahrung", f"{new_days_raw} Tage") + flash(f"Papierkorb-Aufbewahrung auf {new_days_raw} Tage gesetzt.", "success") + + elif "purge_trash_now" in request.form: + _purge_expired_trash() + 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")) net_backend = _detect_network_backend() @@ -2116,13 +2846,15 @@ def settings(): log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL), log_rotation_keep=get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP), log_paths={ - "live": POE_LIVE_LOG_PATH, - "changes": POE_CHANGES_LOG_PATH, - "app": POE_APP_LOG_PATH, - "kea": POE_KEA_LOG_PATH, + "live": TESM_LIVE_LOG_PATH, + "changes": TESM_CHANGES_LOG_PATH, + "app": TESM_APP_LOG_PATH, + "kea": TESM_KEA_LOG_PATH, }, current_timezone=_current_timezone(), timezones=_list_timezones(), + full_nav_items=_ordered_nav_items() if current_user.is_admin else [], + trash_retention_days=_trash_retention_days(), ) @@ -2132,7 +2864,11 @@ def settings_import_export(): if not current_user.can_view_settings_importexport: flash("Keine Berechtigung für Im-/Export.", "danger") return redirect(url_for("index")) - return render_template("settings_import_export.html") + return render_template( + "settings_import_export.html", + export_sections=EXPORT_SECTIONS, + admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, + ) # --------------------------------------------------------------------------- @@ -2404,7 +3140,7 @@ def _dhcp_reservation_candidates(conn, valid_subnets): — für Geräte, die nicht als Client gepflegt werden, z.B. ein externes Gerät, dem bewusst eine feste IP gegeben werden soll). Bei einer MAC in beiden Quellen gewinnt die automatische (echte Client-Daten vor einer - ggf. veralteten manuellen Zeile). Geräte ohne rpi_ip/mac werden + ggf. veralteten manuellen Zeile). Geräte ohne ip/mac werden übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso übersprungen: Einträge, deren IP zu KEINEM der übergebenen (bereits validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb @@ -2414,7 +3150,8 @@ def _dhcp_reservation_candidates(conn, valid_subnets): Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere Subnetze gleichzeitig anbieten).""" auto_rows = conn.execute( - "SELECT mac, rpi_ip AS ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC" + "SELECT mac, ip, name FROM devices " + "WHERE is_active=1 AND deleted_at IS NULL AND mac IS NOT NULL AND ip IS NOT NULL ORDER BY name ASC" ).fetchall() manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall() @@ -2624,7 +3361,7 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, "loggers": [ { "name": "kea-dhcp4", - "output_options": [{"output": POE_KEA_LOG_PATH}], + "output_options": [{"output": TESM_KEA_LOG_PATH}], "severity": "INFO", } ], @@ -2837,7 +3574,7 @@ def settings_dhcp(): flash("Ungültige IPv4-Adresse.", "danger") else: existing_device = conn.execute( - "SELECT 1 FROM devices WHERE is_active=1 AND UPPER(mac)=?", (mac,) + "SELECT 1 FROM devices WHERE is_active=1 AND deleted_at IS NULL AND UPPER(mac)=?", (mac,) ).fetchone() if existing_device: flash(f"MAC {mac} gehört bereits zu einem Client — dafür entsteht die Reservierung automatisch.", "danger") @@ -2964,7 +3701,7 @@ def settings_dhcp(): option_defs = _dhcp_option_defs(conn) option_values = _dhcp_option_values(conn) skipped_count = conn.execute( - "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND (mac IS NULL OR rpi_ip IS NULL)" + "SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND deleted_at IS NULL AND (mac IS NULL OR ip IS NULL)" ).fetchone()["n"] conn.close() status = _detect_dhcp_status() @@ -3008,6 +3745,143 @@ def settings_dhcp(): ) +@app.route("/settings/ldap", methods=["GET", "POST"]) +@login_required +def settings_ldap(): + if request.method == "GET" and not current_user.can_view_settings_ldap: + flash("Keine Berechtigung, die LDAP/AD-Einstellungen anzusehen.", "danger") + return redirect(url_for("index")) + + if request.method == "POST": + if not current_user.has_permission("settings_ldap.edit"): + flash("Keine Berechtigung, die LDAP/AD-Konfiguration zu ändern.", "danger") + return redirect(url_for("settings_ldap")) + + if "save_ldap" in request.form: + new_port_raw = request.form.get("ldap_port", "").strip() + new_bind_dn = request.form.get("ldap_bind_dn", "").strip() + if not new_port_raw.isdigit() or not (1 <= int(new_port_raw) <= 65535): + flash("LDAP-Port muss eine Zahl zwischen 1 und 65535 sein.", "danger") + else: + set_setting("ldap_enabled", "1" if request.form.get("ldap_enabled") else "0") + set_setting("ldap_server", request.form.get("ldap_server", "").strip()) + set_setting("ldap_port", new_port_raw) + set_setting("ldap_use_ssl", "1" if request.form.get("ldap_use_ssl") else "0") + set_setting("ldap_tls_skip_verify", "1" if request.form.get("ldap_tls_skip_verify") else "0") + 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() + if new_bind_password: + conn.execute( + "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " + "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password", + (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, encrypt_password(new_bind_password)), + ) + else: + existing = conn.execute( + "SELECT password FROM service_accounts WHERE purpose=?", + (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,), + ).fetchone() + conn.execute( + "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " + "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username", + (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, existing["password"] if existing else ""), + ) + conn.commit() + conn.close() + log_action("settings.update", "LDAP/Active Directory") + 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 = { + "server": request.form.get("ldap_server", "").strip(), + "port": request.form.get("ldap_port", "").strip() or str(LDAP_DEFAULT_PORT), + "use_ssl": bool(request.form.get("ldap_use_ssl")), + "tls_skip_verify": bool(request.form.get("ldap_tls_skip_verify")), + "bind_dn": request.form.get("ldap_bind_dn", "").strip(), + "base_dn": request.form.get("ldap_base_dn", "").strip(), + "bind_password_enc": encrypt_password(new_bind_password) if new_bind_password else saved_bind_password_enc, + } + ok, message = _ldap_test_connection(test_cfg) + flash(message, "success" if ok else "danger") + + elif "clear_ldap_bind" in request.form: + conn = get_db_connection() + 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") + + elif "add_ldap_group_mapping" in request.form: + ad_group_dn = request.form.get("ad_group_dn", "").strip() + ad_group_name = request.form.get("ad_group_name", "").strip() + app_group_id = request.form.get("app_group_id", "").strip() + if not ad_group_dn or not app_group_id: + flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger") + else: + conn = get_db_connection() + conn.execute( + "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) " + "ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", + (ad_group_dn, ad_group_name or ad_group_dn, app_group_id), + ) + conn.commit() + conn.close() + log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {app_group_id}") + flash("Gruppenzuordnung gespeichert.", "success") + + elif "delete_ldap_group_mapping" in request.form: + mapping_id = request.form.get("delete_ldap_group_mapping") + conn = get_db_connection() + row = conn.execute("SELECT ad_group_name FROM ldap_group_mappings WHERE id=?", (mapping_id,)).fetchone() + conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,)) + conn.commit() + conn.close() + log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id) + flash("Gruppenzuordnung gelöscht.", "success") + + return redirect(url_for("settings_ldap")) + + return render_template( + "settings_ldap.html", + ldap=_ldap_settings(), + ldap_groups=_ldap_groups_for_dropdown(), + mappings=_ldap_group_mappings(), + ) + + +@app.route("/settings/ldap/ad-groups") +@login_required +def settings_ldap_ad_groups(): + """JSON-Endpunkt für den 'AD-Gruppen laden'-Button in settings_ldap.html + -- fragt live per Bind-Konto alle Gruppen aus AD ab, damit die + Zuordnungstabelle nicht von Hand mit DNs gefüttert werden muss.""" + if not current_user.has_permission("settings_ldap.edit"): + return jsonify({"error": "Keine Berechtigung."}), 403 + return jsonify(_ldap_list_groups()) + + @app.route("/settings/nav-order", methods=["POST"]) @login_required def save_nav_order(): @@ -3037,64 +3911,436 @@ def save_nav_order(): # --------------------------------------------------------------------------- -# Import/Export — für Umzug auf eine neue Umgebung. Der komplette Datensatz -# (Devices, Switche, Zugangsdaten inkl. Passwörter) wird als ein einziges, -# 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. +# 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"), + ("switches", "Switche"), + ("credentials", "Zugangsdaten"), + ("users", "Benutzer (lokal)"), + ("groups", "Custom-Gruppen"), + ("ldap", "LDAP/AD-Einstellungen"), + ("dhcp", "DHCP-Einstellungen"), + ("logs", f"Logs (letzte {500} Einträge)"), +] +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 +LDAP_SETTING_KEYS = [ + "ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify", + "ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group", +] + + +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()] + + +def _export_switches(conn): + return [dict(r) for r in conn.execute(""" + SELECT switches.hostname, switches.ip, credentials.name AS credential_name + FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id + WHERE switches.deleted_at IS NULL + """).fetchall()] + + +def _export_credentials(conn): + rows = conn.execute("SELECT name, username, password FROM credentials WHERE deleted_at IS NULL").fetchall() + return [{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])} for r in rows] + + +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" + ).fetchall() + result = [] + for r in rows: + groups = [g["name"] for g in conn.execute( + "SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id " + "WHERE ug.user_id=? AND g.deleted_at IS NULL", + (r["id"],), + ).fetchall()] + result.append({ + "username": r["username"], "password_hash": r["password"], "first_name": r["first_name"], + "last_name": r["last_name"], "email": r["email"], "is_admin": r["is_admin"], + "is_locked": r["is_locked"], "groups": groups, + }) + return result + + +def _export_groups(conn): + rows = conn.execute("SELECT id, name FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall() + result = [] + for g in rows: + perms = [p["permission"] for p in conn.execute( + "SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],) + ).fetchall()] + members = [m["username"] for m in conn.execute( + "SELECT u.username FROM user_groups ug JOIN users u ON u.id=ug.user_id " + "WHERE ug.group_id=? AND u.deleted_at IS NULL", + (g["id"],), + ).fetchall()] + result.append({"name": g["name"], "permissions": perms, "members": members}) + return result + + +def _export_ldap(conn): + settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")} + data = {"settings": settings_data, "group_mappings": []} + bind = conn.execute( + "SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,) + ).fetchone() + if bind: + data["bind_username"] = bind["username"] + data["bind_password"] = decrypt_password(bind["password"]) + data["group_mappings"] = [dict(m) for m in conn.execute( + "SELECT ad_group_dn, ad_group_name, app_group_id FROM ldap_group_mappings" + ).fetchall()] + if not (settings_data or "bind_username" in data or data["group_mappings"]): + return None + return data + + +def _export_dhcp(conn): + settings_data = {k: get_setting(k) for k in DHCP_DEFAULTS if get_setting(k) not in (None, "")} + subnets = [dict(r) for r in conn.execute( + "SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled FROM dhcp_subnets" + ).fetchall()] + reservations = [dict(r) for r in conn.execute( + "SELECT mac, ip, name FROM dhcp_manual_reservations" + ).fetchall()] + option_defs = conn.execute( + "SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs" + ).fetchall() + options = [] + for d in option_defs: + values = conn.execute( + "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 + options.append({ + "code": d["code"], "name": d["name"], "type": d["type"], "description": d["description"], + "is_standard": d["is_standard"], + "values": [{"device_mac": v["device_mac"], "value": v["value"]} for v in values], + }) + if not (settings_data or subnets or reservations or options): + return None + return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options} + + +def _export_logs(conn): + rows = conn.execute( + "SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?", (LOG_EXPORT_LIMIT,) + ).fetchall() + return [dict(r) for r in rows] if rows else None + + +EXPORT_BUILDERS = { + "devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials, + "users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp, + "logs": _export_logs, +} + + +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( + """INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name, + switch_hostname=excluded.switch_hostname, is_active=excluded.is_active + WHERE devices.deleted_at IS NULL""", + (d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)), + ) + n += 1 + return n + + +def _import_switches(conn, items): + n = 0 + for s in items: + cred_id = None + if s.get("credential_name"): + row = conn.execute( + "SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (s["credential_name"],) + ).fetchone() + cred_id = row["id"] if row else None + conn.execute( + """INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?) + ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id + WHERE switches.deleted_at IS NULL""", + (s["hostname"], s["ip"], cred_id), + ) + n += 1 + return n + + +def _import_credentials(conn, items): + n = 0 + for c in items: + conn.execute( + """INSERT INTO credentials (name, username, password) VALUES (?, ?, ?) + ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password + WHERE credentials.deleted_at IS NULL""", + (c["name"], c["username"], encrypt_password(c["password"])), + ) + n += 1 + return n + + +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( + "SELECT id, auth_source, deleted_at FROM users WHERE username=?", (u["username"],) + ).fetchone() + 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( + "UPDATE users SET password=?, is_admin=?, first_name=?, last_name=?, email=?, is_locked=? WHERE id=?", + (u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"), + u.get("email"), u.get("is_locked", 0), existing["id"]), + ) + user_id = existing["id"] + else: + cur = conn.execute( + "INSERT INTO users (username, password, is_admin, first_name, last_name, email, is_locked, auth_source) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'local')", + (u["username"], u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"), + u.get("email"), u.get("is_locked", 0)), + ) + user_id = cur.lastrowid + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) + for gname in u.get("groups", []): + grow = conn.execute( + "SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,) + ).fetchone() + if grow: + conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"])) + n += 1 + return n + + +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( + "SELECT id, is_system, deleted_at FROM groups WHERE name=?", (g["name"],) + ).fetchone() + if existing and (existing["is_system"] or existing["deleted_at"] is not None): + continue + if existing: + group_id = existing["id"] + else: + cur = conn.execute("INSERT INTO groups (name, is_default, is_system) VALUES (?, 0, 0)", (g["name"],)) + group_id = cur.lastrowid + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) + valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS] + conn.executemany( + "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", [(group_id, p) for p in valid_perms] + ) + for username in g.get("members", []): + urow = conn.execute( + "SELECT id FROM users WHERE username=? AND deleted_at IS NULL", (username,) + ).fetchone() + if urow: + conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (urow["id"], group_id)) + n += 1 + return n + + +def _import_ldap(conn, data): + for key, value in data.get("settings", {}).items(): + if key in LDAP_SETTING_KEYS: + set_setting(key, value) + if data.get("bind_username"): + conn.execute( + "INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) " + "ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password", + (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, data["bind_username"], encrypt_password(data.get("bind_password", ""))), + ) + n = 0 + for m in data.get("group_mappings", []): + conn.execute( + "INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) " + "ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", + (m["ad_group_dn"], m["ad_group_name"], m["app_group_id"]), + ) + n += 1 + return n + + +def _import_dhcp(conn, data): + for key, value in data.get("settings", {}).items(): + if key in DHCP_DEFAULTS: + set_setting(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=?", + (s["interface"], s["range_start"], s["range_end"]), + ).fetchone() + if not exists: + max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"] + conn.execute( + "INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order, enabled) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (s["interface"], s["range_start"], s["range_end"], s.get("gateway"), s.get("dns"), + max_order + 1, s.get("enabled", 1)), + ) + n += 1 + for r in data.get("reservations", []): + conn.execute( + "INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?) " + "ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, name=excluded.name", + (r["mac"], r["ip"], r["name"]), + ) + n += 1 + for opt in data.get("options", []): + conn.execute( + "INSERT INTO dhcp_option_defs (code, name, type, description, is_standard) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(name) DO UPDATE SET code=excluded.code, type=excluded.type, description=excluded.description", + (opt["code"], opt["name"], opt["type"], opt.get("description"), opt.get("is_standard", 0)), + ) + def_id = conn.execute("SELECT id FROM dhcp_option_defs WHERE name=?", (opt["name"],)).fetchone()["id"] + for v in opt.get("values", []): + conn.execute( + "INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) " + "ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value", + (def_id, v.get("device_mac", ""), v["value"]), + ) + n += 1 + return n + + +def _import_logs(conn, items): + conn.executemany( + "INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)", + [(l["ts"], l["username"], l["action"], l.get("target"), l.get("details")) for l in items], + ) + return len(items) + + +IMPORT_APPLIERS = { + "devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials, + "users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp, + "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 + + +def _purge_expired_imports(): + now = datetime.now() + expired = [t for t, e in _pending_imports.items() if (now - e["created_at"]).total_seconds() > PENDING_IMPORT_TTL_SECONDS] + for t in expired: + _pending_imports.pop(t, None) + + @app.route("/settings/export", methods=["POST"]) @login_required def export_data(): - if not current_user.has_permission("settings_importexport.view"): + # 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")) passphrase = request.form.get("export_passphrase", "") + sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS] if not passphrase: flash("Bitte eine Passphrase für den Export angeben.", "danger") return redirect(url_for("settings_import_export")) + if not sections: + flash("Bitte mindestens eine Kategorie zum Exportieren auswählen.", "danger") + return redirect(url_for("settings_import_export")) + if not current_user.is_admin and (set(sections) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS): + flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins exportieren.", "danger") + return redirect(url_for("settings_import_export")) conn = get_db_connection() - credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall() - switches_rows = conn.execute(""" - SELECT switches.hostname, switches.ip, credentials.name AS credential_name - FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id - """).fetchall() - devices_rows = conn.execute( - "SELECT mac, rpi_ip, port, name, switch_hostname, is_active FROM devices" - ).fetchall() + payload, counts = {}, {} + for key in sections: + value = EXPORT_BUILDERS[key](conn) + if value: + payload[key] = value + counts[key] = len(value) if isinstance(value, list) else 1 conn.close() - payload = { - "credentials": [ - {"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])} - for r in credentials_rows - ], - "switches": [ - {"hostname": r["hostname"], "ip": r["ip"], "credential_name": r["credential_name"]} - for r in switches_rows - ], - "devices": [dict(r) for r in devices_rows], - } + if not payload: + flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger") + return redirect(url_for("settings_import_export")) salt = secrets.token_bytes(16) export_fernet = _derive_export_fernet(passphrase, salt) encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8")) envelope = { - "app": "poe-manager", - "version": 1, + "app": "tesm", + "version": 2, "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "salt": base64.urlsafe_b64encode(salt).decode("utf-8"), "payload": encrypted_payload.decode("utf-8"), } - log_action( - "data.export", "Devices/Switches/Zugangsdaten", - f"{len(payload['devices'])} Geräte, {len(payload['switches'])} Switche, {len(payload['credentials'])} Zugangsdaten", - ) + summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload) + log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload), summary) filename = f"poe_manager_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" response = jsonify(envelope) @@ -3124,43 +4370,80 @@ 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") + return redirect(url_for("settings_import_export")) + + _purge_expired_imports() + token = secrets.token_urlsafe(24) + _pending_imports[token] = {"payload": payload, "created_at": datetime.now(), "by": current_user.username} + return redirect(url_for("settings_import_preview", token=token)) + + +@app.route("/settings/import/preview/") +@login_required +def settings_import_preview(token): + if not current_user.has_permission("settings_importexport.edit"): + flash("Keine Berechtigung, Daten zu importieren.", "danger") + return redirect(url_for("index")) + + entry = _pending_imports.get(token) + if not entry: + flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger") + return redirect(url_for("settings_import_export")) + + payload = entry["payload"] + present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)] + preview_sections = [ + { + "key": key, "label": EXPORT_SECTION_LABELS.get(key, key), + "count": len(payload[key]) if isinstance(payload[key], list) else 1, + "admin_only": key in IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, + } + for key in present_sections + ] + return render_template( + "settings_import_export.html", + import_preview={"token": token, "sections": preview_sections}, + export_sections=EXPORT_SECTIONS, + admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, + ) + + +@app.route("/settings/import/apply", methods=["POST"]) +@login_required +def import_apply(): + if not current_user.has_permission("settings_importexport.edit"): + flash("Keine Berechtigung, Daten zu importieren.", "danger") + return redirect(url_for("index")) + + token = request.form.get("import_token", "") + entry = _pending_imports.pop(token, None) + if not entry: + flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger") + return redirect(url_for("settings_import_export")) + + payload = entry["payload"] + selected = [s for s in request.form.getlist("import_sections") if s in IMPORT_APPLIERS and payload.get(s)] + if not selected: + flash("Bitte mindestens eine Kategorie zum Importieren auswählen.", "danger") + return redirect(url_for("settings_import_export")) + if not current_user.is_admin and (set(selected) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS): + flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins importieren.", "danger") + return redirect(url_for("settings_import_export")) + conn = get_db_connection() - n_cred = n_switch = n_dev = 0 - - for c in payload.get("credentials", []): - conn.execute( - """INSERT INTO credentials (name, username, password) VALUES (?, ?, ?) - ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password""", - (c["name"], c["username"], encrypt_password(c["password"])), - ) - n_cred += 1 - - for s in payload.get("switches", []): - cred_id = None - if s.get("credential_name"): - row = conn.execute("SELECT id FROM credentials WHERE name=?", (s["credential_name"],)).fetchone() - cred_id = row["id"] if row else None - conn.execute( - """INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?) - ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id""", - (s["hostname"], s["ip"], cred_id), - ) - n_switch += 1 - - for d in payload.get("devices", []): - conn.execute( - """INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(mac) DO UPDATE SET rpi_ip=excluded.rpi_ip, port=excluded.port, name=excluded.name, - switch_hostname=excluded.switch_hostname, is_active=excluded.is_active""", - (d["mac"], d["rpi_ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)), - ) - n_dev += 1 - + results = {key: IMPORT_APPLIERS[key](conn, payload[key]) for key in selected} conn.commit() conn.close() - log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten") - flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success") + summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected) + log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary) + flash(f"Import abgeschlossen: {summary}.", "success") return redirect(url_for("settings_import_export")) @@ -3176,7 +4459,9 @@ def devices(): return redirect(url_for("index")) conn = get_db_connection() - switches = conn.execute("SELECT hostname FROM switches ORDER BY hostname ASC").fetchall() + switches = conn.execute( + "SELECT hostname FROM switches WHERE deleted_at IS NULL ORDER BY hostname ASC" + ).fetchall() if request.method == "POST": if "add_device" in request.form: @@ -3185,21 +4470,21 @@ def devices(): return redirect(url_for("devices")) mac = request.form.get("mac") - rpi_ip = request.form.get("rpi_ip") + ip = request.form.get("ip") port = request.form.get("port") name = request.form.get("name") switch_hostname = request.form.get("switch_hostname") or None is_active = 1 if "is_active" in request.form else 0 - if not all([mac, rpi_ip, name]): + if not all([mac, ip, name]): flash("Alle Felder müssen ausgefüllt sein!", "danger") return redirect(url_for("devices")) - if conn.execute("SELECT name FROM devices WHERE rpi_ip=?", (rpi_ip,)).fetchone(): + if conn.execute("SELECT name FROM devices WHERE ip=? AND deleted_at IS NULL", (ip,)).fetchone(): flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) - if conn.execute("SELECT name FROM devices WHERE mac=?", (mac,)).fetchone(): + if conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)).fetchone(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) @@ -3219,16 +4504,16 @@ def devices(): try: conn.execute( - "INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active, credential_id, ssh_port) " + "INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (mac, rpi_ip, port, name, switch_hostname, is_active, credential_id, ssh_port), + (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port), ) touch_record(conn, "devices", "mac", mac) conn.commit() - log_action("device.create", name, f"MAC {mac}, IP {rpi_ip}") + log_action("device.create", name, f"MAC {mac}, IP {ip}") flash(f"Gerät {name} hinzugefügt.", "success") except sqlite3.IntegrityError: - flash("Fehler beim Hinzufügen des Geräts!", "danger") + flash("Fehler beim Hinzufügen des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung, dort zuerst wiederherstellen oder endgültig löschen).", "danger") elif "edit_device" in request.form: if not current_user.has_permission("devices.edit"): @@ -3237,15 +4522,15 @@ def devices(): old_mac = request.form.get("old_mac") mac = request.form.get("mac") - rpi_ip = request.form.get("rpi_ip") + ip = request.form.get("ip") port = request.form.get("port") or None 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, rpi_ip, name]): + if switch_hostname_field and not any([mac, ip, name]): device = conn.execute( - "SELECT name, switch_hostname FROM devices WHERE mac=?", (old_mac,) + "SELECT name, switch_hostname FROM devices WHERE mac=? AND deleted_at IS NULL", (old_mac,) ).fetchone() if not device: flash("Gerät nicht gefunden!", "danger") @@ -3266,18 +4551,18 @@ def devices(): return redirect(url_for("devices")) # --- Normales Gerät bearbeiten --- - if not all([old_mac, mac, rpi_ip, name]): + 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")) if conn.execute( - "SELECT name FROM devices WHERE rpi_ip=? AND mac<>?", (rpi_ip, old_mac) + "SELECT name FROM devices WHERE ip=? AND mac<>? AND deleted_at IS NULL", (ip, old_mac) ).fetchone(): flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) if conn.execute( - "SELECT name FROM devices WHERE mac=? AND mac<>?", (mac, old_mac) + "SELECT name FROM devices WHERE mac=? AND mac<>? AND deleted_at IS NULL", (mac, old_mac) ).fetchone(): flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") return redirect(url_for("devices")) @@ -3295,15 +4580,16 @@ def devices(): try: conn.execute( - "UPDATE devices SET mac=?, rpi_ip=?, port=?, name=?, credential_id=?, ssh_port=? WHERE mac=?", - (mac, rpi_ip, port, name, credential_id, ssh_port, old_mac), + "UPDATE devices SET mac=?, ip=?, port=?, name=?, credential_id=?, ssh_port=? " + "WHERE mac=? AND deleted_at IS NULL", + (mac, ip, port, name, credential_id, ssh_port, old_mac), ) touch_record(conn, "devices", "mac", mac) conn.commit() - log_action("device.edit", name, f"MAC {old_mac} → {mac}, IP {rpi_ip}") + log_action("device.edit", name, f"MAC {old_mac} → {mac}, IP {ip}") flash(f"Gerät {name} aktualisiert.", "success") except sqlite3.IntegrityError: - flash("Fehler beim Aktualisieren des Geräts!", "danger") + 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). @@ -3313,12 +4599,19 @@ def devices(): del_mac = request.form.get("delete_device") if del_mac: - device = conn.execute("SELECT name FROM devices WHERE mac=?", (del_mac,)).fetchone() + device = conn.execute( + "SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (del_mac,) + ).fetchone() name = device["name"] if device else del_mac - conn.execute("DELETE FROM devices WHERE mac=?", (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), + ) conn.commit() - log_action("device.delete", name, f"MAC {del_mac}") - flash(f"Gerät {name} gelöscht.", "success") + log_action("device.delete", name, f"MAC {del_mac} — in den Papierkorb verschoben") + flash(f"Gerät {name} in den Papierkorb verschoben.", "success") else: flash("Keine MAC-Adresse übermittelt!", "danger") @@ -3326,16 +4619,19 @@ def devices(): return redirect(url_for("devices")) device_rows = conn.execute(""" - SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active, + SELECT devices.mac, devices.ip, devices.port, devices.name, devices.is_active, devices.credential_id, devices.ssh_port, switches.hostname AS switch_hostname, credentials.name AS credential_name, credentials.username AS credential_username FROM devices LEFT JOIN switches ON devices.switch_hostname = switches.hostname LEFT JOIN credentials ON credentials.id = devices.credential_id + WHERE devices.deleted_at IS NULL ORDER BY switches.hostname ASC, devices.name ASC """).fetchall() - all_credentials = conn.execute("SELECT id, name, username, category FROM credentials ORDER BY name ASC").fetchall() + all_credentials = conn.execute( + "SELECT id, name, username, category FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC" + ).fetchall() conn.close() return render_template("devices.html", devices=device_rows, switches=switches, all_credentials=all_credentials) @@ -3349,13 +4645,15 @@ def toggle_device(mac): return {"success": False, "msg": "Keine Berechtigung."}, 403 conn = get_db_connection() - device = conn.execute("SELECT is_active, name FROM devices WHERE mac=?", (mac,)).fetchone() + device = conn.execute( + "SELECT is_active, name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,) + ).fetchone() if not device: conn.close() return {"success": False, "msg": "Gerät nicht gefunden!"}, 404 new_status = 0 if device["is_active"] else 1 - conn.execute("UPDATE devices SET is_active=? WHERE mac=?", (new_status, mac)) + conn.execute("UPDATE devices SET is_active=? WHERE mac=? AND deleted_at IS NULL", (new_status, mac)) touch_record(conn, "devices", "mac", mac) conn.commit() conn.close() @@ -3396,9 +4694,14 @@ def _resolve_credential_choice(conn, default_category="switch"): ) return cur.lastrowid, None except sqlite3.IntegrityError: - return None, "Es existieren bereits Zugangsdaten mit diesem Namen!" + return None, "Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!" elif choice.isdigit(): - return int(choice), None + cred_id = int(choice) + if not conn.execute( + "SELECT 1 FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,) + ).fetchone(): + return None, "Ausgewählte Zugangsdaten wurden zwischenzeitlich gelöscht." + return cred_id, None return None, "Bitte Zugangsdaten auswählen oder neue anlegen." @@ -3446,7 +4749,7 @@ def switches(): log_action("switch.create", hostname, f"IP {ip}") flash(f"Switch {hostname} hinzugefügt.", "success") except sqlite3.IntegrityError: - flash("Hostname existiert bereits oder Eingabefehler!", "danger") + flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger") if request.method == "POST" and "edit_switch" in request.form: if not current_user.has_permission("switches.edit"): @@ -3464,7 +4767,8 @@ def switches(): else: try: conn.execute( - "UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=? WHERE hostname=?", + "UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=? " + "WHERE hostname=? AND deleted_at IS NULL", (hostname, ip, ssh_port, credential_id, old_hostname), ) if hostname != old_hostname: @@ -3477,16 +4781,19 @@ def switches(): log_action("switch.edit", hostname, f"IP {ip}") flash(f"Switch {hostname} aktualisiert.", "success") except sqlite3.IntegrityError: - flash("Hostname existiert bereits oder Eingabefehler!", "danger") + flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger") switch_rows = conn.execute(""" SELECT switches.hostname, switches.ip, switches.ssh_port, switches.credential_id, credentials.name AS credential_name, credentials.username AS credential_username FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id + WHERE switches.deleted_at IS NULL ORDER BY switches.hostname ASC """).fetchall() - all_credentials = conn.execute("SELECT id, name, username FROM credentials ORDER BY name ASC").fetchall() + all_credentials = conn.execute( + "SELECT id, name, username FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC" + ).fetchall() conn.close() return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials) @@ -3500,18 +4807,25 @@ def delete_switch(hostname): return redirect(url_for("switches")) conn = get_db_connection() - assigned = conn.execute("SELECT name FROM devices WHERE switch_hostname=?", (hostname,)).fetchall() + assigned = conn.execute( + "SELECT name FROM devices WHERE switch_hostname=? AND deleted_at IS NULL", (hostname,) + ).fetchall() if assigned: names = ", ".join(d["name"] for d in assigned) flash(f"Folgende Geräte sind noch dem Switch {hostname} zugewiesen: {names}", "danger") conn.close() return redirect(url_for("switches")) - conn.execute("DELETE FROM switches WHERE hostname=?", (hostname,)) + # 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), + ) conn.commit() conn.close() - log_action("switch.delete", hostname) - flash(f"Switch '{hostname}' gelöscht.", "success") + log_action("switch.delete", hostname, "in den Papierkorb verschoben") + flash(f"Switch '{hostname}' in den Papierkorb verschoben.", "success") return redirect(url_for("switches")) @@ -3550,7 +4864,7 @@ def credentials(): log_action("credential.create", name) flash(f"Zugangsdaten '{name}' angelegt.", "success") except sqlite3.IntegrityError: - flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger") + flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger") else: flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger") @@ -3569,19 +4883,20 @@ def credentials(): try: if new_password: conn.execute( - "UPDATE credentials SET name=?, username=?, password=?, category=? WHERE id=?", + "UPDATE credentials SET name=?, username=?, password=?, category=? " + "WHERE id=? AND deleted_at IS NULL", (name, username, encrypt_password(new_password), category, cred_id), ) else: conn.execute( - "UPDATE credentials SET name=?, username=?, category=? WHERE id=?", + "UPDATE credentials SET name=?, username=?, category=? WHERE id=? AND deleted_at IS NULL", (name, username, category, cred_id), ) conn.commit() log_action("credential.edit", name) flash(f"Zugangsdaten '{name}' aktualisiert.", "success") except sqlite3.IntegrityError: - flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger") + flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger") else: flash("Name und Username müssen ausgefüllt sein!", "danger") @@ -3591,25 +4906,37 @@ def credentials(): flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") return redirect(url_for("credentials")) cred_id = request.form.get("delete_credential") - used_by_switches = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall() - used_by_devices = conn.execute("SELECT name FROM devices WHERE credential_id=?", (cred_id,)).fetchall() + used_by_switches = conn.execute( + "SELECT hostname FROM switches WHERE credential_id=? AND deleted_at IS NULL", (cred_id,) + ).fetchall() + used_by_devices = conn.execute( + "SELECT name FROM devices WHERE credential_id=? AND deleted_at IS NULL", (cred_id,) + ).fetchall() if used_by_switches or used_by_devices: names = ", ".join([s["hostname"] for s in used_by_switches] + [d["name"] for d in used_by_devices]) flash(f"Diese Zugangsdaten werden noch verwendet von: {names}", "danger") else: - cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone() - conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,)) + 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), + ) conn.commit() - log_action("credential.delete", cred_row["name"] if cred_row else cred_id) - flash("Zugangsdaten gelöscht.", "success") + log_action("credential.delete", cred_row["name"] if cred_row else cred_id, "in den Papierkorb verschoben") + flash("Zugangsdaten in den Papierkorb verschoben.", "success") credential_rows = conn.execute(""" SELECT credentials.id, credentials.name, credentials.username, credentials.category, COUNT(DISTINCT switches.hostname) AS switch_count, COUNT(DISTINCT devices.mac) AS device_count FROM credentials - LEFT JOIN switches ON switches.credential_id = credentials.id - LEFT JOIN devices ON devices.credential_id = credentials.id + LEFT JOIN switches ON switches.credential_id = credentials.id AND switches.deleted_at IS NULL + LEFT JOIN devices ON devices.credential_id = credentials.id AND devices.deleted_at IS NULL + WHERE credentials.deleted_at IS NULL GROUP BY credentials.id ORDER BY credentials.name ASC """).fetchall() @@ -3655,18 +4982,302 @@ def _maintenance_devices(conn): als eigener, separater Schritt, siehe CREDENTIAL_CATEGORIES-Kommentar).""" return conn.execute( """ - SELECT devices.mac, devices.name, devices.rpi_ip, devices.ssh_port, + SELECT devices.mac, devices.name, devices.ip, devices.ssh_port, devices.credential_id, devices.is_active, credentials.username AS cred_username FROM devices JOIN credentials ON credentials.id = devices.credential_id - WHERE credentials.category = ? + WHERE credentials.category = ? AND devices.deleted_at IS NULL AND credentials.deleted_at IS NULL ORDER BY devices.name ASC """, (DEVICE_MAINTENANCE_CATEGORY,), ).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 + + +def _trash_retention_days(): + try: + return max(1, int(get_setting("trash_retention_days", str(TRASH_RETENTION_DEFAULT_DAYS)))) + except (TypeError, ValueError): + return TRASH_RETENTION_DEFAULT_DAYS + + +def _trashed_devices(conn): + return conn.execute( + "SELECT mac, name, ip, deleted_at FROM devices WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" + ).fetchall() + + +def _trashed_switches(conn): + return conn.execute( + "SELECT hostname, ip, deleted_at FROM switches WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" + ).fetchall() + + +def _trashed_credentials(conn): + return conn.execute( + "SELECT id, name, username, category, deleted_at FROM credentials " + "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" + ).fetchall() + + +def _trashed_users(conn): + return conn.execute( + "SELECT id, username, first_name, last_name, deleted_at FROM users " + "WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" + ).fetchall() + + +def _trashed_groups(conn): + return conn.execute( + "SELECT id, name, deleted_at FROM groups WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC" + ).fetchall() + + +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,)) + + +def _purge_switch(conn, hostname): + conn.execute("DELETE FROM switches WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,)) + + +def _purge_credential(conn, cred_id): + conn.execute("DELETE FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)) + + +def _purge_user(conn, user_id): + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) + conn.execute("DELETE FROM users WHERE id=? AND deleted_at IS NOT NULL", (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,)) + + +def _purge_expired_trash(): + """Wird bei jedem Aufruf der Wartung-Seite ausgeführt (kein eigener + Scheduler nötig, analog _purge_expired_imports()) -- löscht alle + Papierkorb-Einträge endgültig, deren Aufbewahrungsfrist abgelaufen ist.""" + cutoff = (datetime.now() - timedelta(days=_trash_retention_days())).strftime("%Y-%m-%d %H:%M:%S") + conn = get_db_connection() + for row in conn.execute( + "SELECT mac FROM devices WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) + ).fetchall(): + _purge_device(conn, row["mac"]) + for row in conn.execute( + "SELECT hostname FROM switches WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) + ).fetchall(): + _purge_switch(conn, row["hostname"]) + for row in conn.execute( + "SELECT id FROM credentials WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) + ).fetchall(): + _purge_credential(conn, row["id"]) + for row in conn.execute( + "SELECT id FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) + ).fetchall(): + _purge_user(conn, row["id"]) + for row in conn.execute( + "SELECT id FROM groups WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) + ).fetchall(): + _purge_group(conn, row["id"]) + conn.commit() + conn.close() + + +@app.route("/devices/restore/", methods=["POST"]) +@login_required +def restore_device(mac): + if not current_user.has_permission("devices.edit"): + flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone() + conn.execute("UPDATE devices SET deleted_at=NULL WHERE mac=? AND deleted_at IS NOT NULL", (mac,)) + conn.commit() + conn.close() + log_action("device.restore", device["name"] if device else mac) + flash(f"Gerät {device['name'] if device else mac} wiederhergestellt.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/devices/purge/", methods=["POST"]) +@login_required +def purge_device(mac): + if not current_user.has_permission("devices.edit"): + flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone() + _purge_device(conn, mac) + conn.commit() + conn.close() + log_action("device.purge", device["name"] if device else mac) + flash(f"Gerät {device['name'] if device else mac} endgültig gelöscht.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/switches/restore/", methods=["POST"]) +@login_required +def restore_switch(hostname): + if not current_user.has_permission("switches.edit"): + flash("Keine Berechtigung, Switche wiederherzustellen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + conn.execute("UPDATE switches SET deleted_at=NULL WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,)) + conn.commit() + conn.close() + log_action("switch.restore", hostname) + flash(f"Switch {hostname} wiederhergestellt.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/switches/purge/", methods=["POST"]) +@login_required +def purge_switch(hostname): + if not current_user.has_permission("switches.edit"): + flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + _purge_switch(conn, hostname) + conn.commit() + conn.close() + log_action("switch.purge", hostname) + flash(f"Switch {hostname} endgültig gelöscht.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/credentials/restore/", methods=["POST"]) +@login_required +def restore_credential(cred_id): + if not current_user.has_permission("credentials.edit"): + flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone() + conn.execute("UPDATE credentials SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)) + conn.commit() + conn.close() + log_action("credential.restore", cred["name"] if cred else cred_id) + flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' wiederhergestellt.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/credentials/purge/", methods=["POST"]) +@login_required +def purge_credential(cred_id): + if not current_user.has_permission("credentials.edit"): + flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone() + _purge_credential(conn, cred_id) + conn.commit() + conn.close() + log_action("credential.purge", cred["name"] if cred else cred_id) + flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' endgültig gelöscht.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/users/restore/", methods=["POST"]) +@login_required +def restore_user(user_id): + if not current_user.has_permission("users.edit"): + flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + target = conn.execute( + "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,) + ).fetchone() + if target and target["is_admin"] and not current_user.is_admin: + flash("Nur Admins dürfen Admin-Konten wiederherstellen.", "danger") + conn.close() + return redirect(url_for("papierkorb")) + conn.execute("UPDATE users SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (user_id,)) + conn.commit() + conn.close() + log_action("user.restore", target["username"] if target else user_id) + flash(f"Benutzer „{target['username'] if target else user_id}“ wiederhergestellt.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/users/purge/", methods=["POST"]) +@login_required +def purge_user(user_id): + if not current_user.has_permission("users.edit"): + flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + target = conn.execute( + "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,) + ).fetchone() + if target and target["is_admin"] and not current_user.is_admin: + flash("Nur Admins dürfen Admin-Konten endgültig löschen.", "danger") + conn.close() + return redirect(url_for("papierkorb")) + _purge_user(conn, user_id) + conn.commit() + conn.close() + log_action("user.purge", target["username"] if target else user_id) + flash(f"Benutzer „{target['username'] if target else user_id}“ endgültig gelöscht.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/groups/restore/", methods=["POST"]) +@login_required +def restore_group(group_id): + if not current_user.has_permission("groups.edit"): + flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone() + conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (group_id,)) + conn.commit() + conn.close() + log_action("group.restore", group["name"] if group else group_id) + flash(f"Gruppe '{group['name'] if group else group_id}' wiederhergestellt.", "success") + return redirect(url_for("papierkorb")) + + +@app.route("/groups/purge/", methods=["POST"]) +@login_required +def purge_group(group_id): + if not current_user.has_permission("groups.edit"): + flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger") + return redirect(url_for("papierkorb")) + conn = get_db_connection() + group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone() + _purge_group(conn, group_id) + conn.commit() + conn.close() + log_action("group.purge", group["name"] if group else group_id) + flash(f"Gruppe '{group['name'] if group else group_id}' endgültig gelöscht.", "success") + return redirect(url_for("papierkorb")) + + @app.route("/maintenance") @login_required def maintenance(): @@ -3696,6 +5307,32 @@ def maintenance(): ) +@app.route("/papierkorb") +@login_required +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 = { + "devices": _trashed_devices(conn) if current_user.has_permission("devices.view") else [], + "switches": _trashed_switches(conn) if current_user.has_permission("switches.view") else [], + "credentials": _trashed_credentials(conn) if current_user.has_permission("credentials.view") else [], + "users": _trashed_users(conn) if current_user.has_permission("users.view") else [], + "groups": _trashed_groups(conn) if current_user.has_permission("groups.view") else [], + } + conn.close() + return render_template( + "papierkorb.html", + trash=trash, + trash_retention_days=_trash_retention_days(), + ) + + @app.route("/maintenance/status") @login_required def maintenance_status(): @@ -4194,7 +5831,7 @@ def _resolve_device_ssh_target(conn, device): if not device["credential_id"]: return None, None, None, None, f"{device['name']}: keine SSH-Zugangsdaten hinterlegt." cred = conn.execute( - "SELECT username, password FROM credentials WHERE id=?", (device["credential_id"],) + "SELECT username, password FROM credentials WHERE id=? AND deleted_at IS NULL", (device["credential_id"],) ).fetchone() if not cred: return None, None, None, None, f"{device['name']}: hinterlegte Zugangsdaten existieren nicht mehr." @@ -4203,7 +5840,7 @@ def _resolve_device_ssh_target(conn, device): password = decrypt_password(cred["password"]) except Exception: return None, None, None, None, f"{device['name']}: Zugangsdaten konnten nicht entschlüsselt werden." - return device["rpi_ip"], port, cred["username"], password, None + return device["ip"], port, cred["username"], password, None def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25): @@ -4377,7 +6014,7 @@ def get_kea_log(): if not current_user.can_view_kea_log: return "Keine Berechtigung.", 403 try: - with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: + with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: content = f.read() except FileNotFoundError: content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \ @@ -4392,7 +6029,7 @@ def get_kea_log(): def kea_log(): """Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/ Änderungen — die Konfiguration (siehe _render_kea_config) leitet Keas - eigene Logging-Ausgabe explizit in POE_KEA_LOG_PATH statt in den + eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den distributionsabhängigen Default (meist Syslog), damit sie sich hier genauso wie die anderen Logs einsehen und per logrotate rotieren lässt.""" @@ -4400,7 +6037,7 @@ def kea_log(): flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger") return redirect(url_for("index")) try: - with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: + with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f: log_content = f.read() except FileNotFoundError: log_content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \ @@ -4443,7 +6080,7 @@ def restart_device(mac): conn = get_db_connection() device = conn.execute( - "SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=?", (mac,) + "SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,) ).fetchone() conn.close() @@ -4494,6 +6131,7 @@ def users(): password = request.form["password"].strip() 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 @@ -4509,9 +6147,9 @@ def users(): pw_hash = bcrypt.generate_password_hash(password).decode("utf-8") try: cur = conn.execute( - "INSERT INTO users (username, password, is_admin, first_name, last_name) " - "VALUES (?, ?, ?, ?, ?)", - (username, pw_hash, is_admin, first_name, last_name), + "INSERT INTO users (username, password, is_admin, first_name, last_name, email) " + "VALUES (?, ?, ?, ?, ?, ?)", + (username, pw_hash, is_admin, first_name, last_name, email), ) if not is_admin and group_choice: conn.execute( @@ -4522,7 +6160,7 @@ def users(): log_action("user.create", username) flash(f"Benutzer '{username}' erfolgreich angelegt!", "success") except sqlite3.IntegrityError: - flash("Benutzername existiert bereits!", "danger") + flash("Benutzername existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger") else: flash("Username und Passwort dürfen nicht leer sein!", "danger") @@ -4538,26 +6176,40 @@ def users(): # (u.a. Passwort-Reset) — verhindert, dass ein delegiertes # "Benutzer bearbeiten"-Recht zur Übernahme eines Admin-Kontos # missbraucht wird. - target_admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() - if target_admin and target_admin["is_admin"] and not current_user.is_admin: + target_user = conn.execute( + "SELECT is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) + ).fetchone() + if target_user and target_user["is_admin"] and not current_user.is_admin: 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")) + username = request.form.get("username", "").strip() 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 new_password = request.form.get("new_password", "").strip() if username: if new_password: pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8") conn.execute( - "UPDATE users SET username=?, first_name=?, last_name=?, password=? WHERE id=?", - (username, first_name, last_name, pw_hash, user_id), + "UPDATE users SET username=?, first_name=?, last_name=?, email=?, password=? " + "WHERE id=? AND deleted_at IS NULL", + (username, first_name, last_name, email, pw_hash, user_id), ) else: conn.execute( - "UPDATE users SET username=?, first_name=?, last_name=? WHERE id=?", - (username, first_name, last_name, user_id), + "UPDATE users SET username=?, first_name=?, last_name=?, email=? " + "WHERE id=? AND deleted_at IS NULL", + (username, first_name, last_name, email, user_id), ) conn.commit() log_action("user.edit", username) @@ -4578,14 +6230,18 @@ def users(): # 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=?", (user_id,)).fetchone() + target = conn.execute( + "SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) + ).fetchone() if (choice == "admin" or (target and target["is_admin"])) and not current_user.is_admin: 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").fetchone()["n"] + admin_count = conn.execute( + "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND deleted_at IS NULL" + ).fetchone()["n"] if admin_count <= 1: flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") conn.close() @@ -4593,9 +6249,9 @@ def users(): conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) if choice == "admin": - conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,)) + conn.execute("UPDATE users SET is_admin=1 WHERE id=? AND deleted_at IS NULL", (user_id,)) else: - conn.execute("UPDATE users SET is_admin=0 WHERE id=?", (user_id,)) + conn.execute("UPDATE users SET is_admin=0 WHERE id=? AND deleted_at IS NULL", (user_id,)) if choice: conn.execute( "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", @@ -4612,31 +6268,156 @@ def users(): return redirect(url_for("users")) user_id = request.form["delete_user"] - target = conn.execute("SELECT username, is_admin FROM users WHERE id=?", (user_id,)).fetchone() + 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")) - conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) - conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + 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), + ) + conn.commit() + log_action("user.delete", target["username"] if target else user_id, "in den Papierkorb verschoben") + 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")) + + user_id = request.form["toggle_lock"] + target = conn.execute( + "SELECT username, is_admin, is_locked FROM users WHERE id=? AND deleted_at IS NULL", (user_id,) + ).fetchone() + if not target: + flash("Benutzer nicht gefunden.", "danger") + return redirect(url_for("users")) + if target["is_admin"] and not current_user.is_admin: + flash("Nur Admins dürfen Admin-Konten sperren.", "danger") + return redirect(url_for("users")) + if str(user_id) == str(current_user.id): + flash("Das eigene Konto kann nicht gesperrt werden.", "danger") + return redirect(url_for("users")) + new_state = 0 if target["is_locked"] else 1 + if new_state and target["is_admin"]: + unlocked_admins = conn.execute( + "SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL" + ).fetchone()["n"] + if unlocked_admins <= 1: + flash("Es muss mindestens ein entsperrtes Admin-Konto bestehen bleiben.", "danger") + conn.close() + return redirect(url_for("users")) + conn.execute("UPDATE users SET is_locked=? WHERE id=? AND deleted_at IS NULL", (new_state, user_id)) conn.commit() - log_action("user.delete", target["username"] if target else user_id) - flash("Benutzer gelöscht!", "success") + log_action("user.lock" if new_state else "user.unlock", target["username"]) + 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")) + + ldap_username = request.form.get("ldap_username", "").strip() + 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") + return redirect(url_for("users")) + + found, info = _ldap_lookup(ldap_username) if ldap_username else (False, None) + if not found: + flash("Benutzer wurde im Verzeichnis nicht (mehr) gefunden.", "danger") + else: + canonical_username = info["username"] + placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8") + is_admin_new = 1 if group_choice == "admin" else 0 + try: + cur = conn.execute( + "INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) " + "VALUES (?, ?, ?, ?, ?, ?, 'ldap')", + (canonical_username, placeholder_hash, is_admin_new, + info.get("first_name"), info.get("last_name"), info.get("email")), + ) + if not is_admin_new and group_choice: + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", + (cur.lastrowid, group_choice), + ) + conn.commit() + log_action("user.ldap_add", canonical_username, "Aus Active Directory hinzugefügt") + flash(f"„{canonical_username}“ aus Active Directory hinzugefügt.", "success") + except sqlite3.IntegrityError: + flash("Dieser Benutzer existiert bereits (ggf. im Papierkorb unter Wartung).", "danger") users_list = conn.execute(""" SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name, + users.auth_source, users.email, users.is_locked, GROUP_CONCAT(groups.name, ', ') AS group_names, (SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id FROM users LEFT JOIN user_groups ON user_groups.user_id = users.id - LEFT JOIN groups ON groups.id = user_groups.group_id + LEFT JOIN groups ON groups.id = user_groups.group_id AND groups.deleted_at IS NULL + WHERE users.deleted_at IS NULL GROUP BY users.id ORDER BY users.username ASC """).fetchall() - all_groups = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall() + all_groups = conn.execute( + "SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC" + ).fetchall() conn.close() - return render_template("users.html", users=users_list, all_groups=all_groups) + return render_template( + "users.html", users=users_list, all_groups=all_groups, + ldap_enabled=get_setting("ldap_enabled", "0") == "1", + ) + + +@app.route("/users/ldap_search") +@login_required +def users_ldap_search(): + """JSON-Endpunkt für 'Aus Active Directory hinzufügen' -- Substring-Suche + im Verzeichnis, gefiltert um bereits lokal bekannte Benutzernamen + (case-insensitiv, unabhängig von auth_source).""" + if not current_user.has_permission("users.create"): + return jsonify({"error": "Keine Berechtigung."}), 403 + query = request.args.get("q", "").strip() + if len(query) < 2: + 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() + } + conn.close() + return jsonify([r for r in results if r["username"] and r["username"].lower() not in existing]) # --------------------------------------------------------------------------- @@ -4675,7 +6456,7 @@ def groups(): log_action("group.create", name, f"{len(selected_permissions)} Rechte" if selected_permissions else None) flash(f"Gruppe '{name}' angelegt.", "success") except sqlite3.IntegrityError: - flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") + flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger") else: flash("Gruppenname darf nicht leer sein!", "danger") @@ -4693,14 +6474,20 @@ def groups(): return redirect(url_for("groups")) # Systemgruppen (aktuell nur die Standardgruppe 'Benutzer') sind - # in Name und Rechten fix — nicht mal für Admins änderbar über - # die UI. Mitgliedschaft (members_submitted) bleibt frei - # verwaltbar, nur permissions_submitted bzw. eine Umbenennung - # werden blockiert. - target_group = conn.execute("SELECT name, is_system FROM groups WHERE id=?", (group_id,)).fetchone() + # 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() is_system = bool(target_group["is_system"]) if target_group else False - if is_system and "permissions_submitted" in request.form: - flash("Die Rechte der Systemgruppe können nicht geändert werden.", "danger") + system_unlocked = is_system and current_user.is_admin and request.form.get("unlock_system_group") == "1" + if is_system and "permissions_submitted" in request.form and not system_unlocked: + flash("Die Rechte der Systemgruppe können nicht geändert werden — über „Freischalten“ kann ein Admin dies gezielt erlauben.", "danger") conn.close() return redirect(url_for("groups")) if is_system and target_group and name != target_group["name"]: @@ -4715,7 +6502,7 @@ def groups(): # wurde (leere Checkbox-Listen wären sonst nicht von "nichts # ausgewählt" zu unterscheiden). try: - conn.execute("UPDATE groups SET name=? WHERE id=?", (name, group_id)) + conn.execute("UPDATE groups SET name=? WHERE id=? AND deleted_at IS NULL", (name, group_id)) if "permissions_submitted" in request.form: selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS) @@ -4742,7 +6529,7 @@ def groups(): log_action("group.edit", name, ", ".join(detail_bits) or None) flash(f"Gruppe '{name}' aktualisiert.", "success") except sqlite3.IntegrityError: - flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") + 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 — @@ -4755,7 +6542,7 @@ def groups(): if not selected_admins: flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") else: - conn.execute("UPDATE users SET is_admin=0") + conn.execute("UPDATE users SET is_admin=0 WHERE deleted_at IS NULL") conn.executemany( "UPDATE users SET is_admin=1 WHERE id=?", [(uid,) for uid in selected_admins], @@ -4777,24 +6564,33 @@ def groups(): flash("Keine Berechtigung, Gruppen zu löschen.", "danger") return redirect(url_for("groups")) group_id = request.form.get("delete_group") - target = conn.execute("SELECT name, is_default, is_system FROM groups WHERE id=?", (group_id,)).fetchone() + target = conn.execute( + "SELECT name, is_default, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,) + ).fetchone() if target and (target["is_default"] or target["is_system"]): flash(f"Die Systemgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") else: - conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) - conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) - conn.execute("DELETE FROM groups WHERE id=?", (group_id,)) + # 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), + ) conn.commit() - log_action("group.delete", target["name"] if target else group_id) - flash("Gruppe gelöscht.", "success") + log_action("group.delete", target["name"] if target else group_id, "in den Papierkorb verschoben") + flash("Gruppe in den Papierkorb verschoben.", "success") conn.close() return redirect(url_for("groups")) group_rows = conn.execute( - "SELECT id, name, is_default, is_system FROM groups ORDER BY is_default DESC, name ASC" + "SELECT id, name, is_default, is_system FROM groups WHERE deleted_at IS NULL " + "ORDER BY is_default DESC, name ASC" + ).fetchall() + all_users = conn.execute( + "SELECT id, username FROM users WHERE is_admin=0 AND deleted_at IS NULL ORDER BY username ASC" ).fetchall() - all_users = conn.execute("SELECT id, username FROM users WHERE is_admin=0 ORDER BY username ASC").fetchall() groups_data = [] for g in group_rows: @@ -4804,7 +6600,7 @@ def groups(): member_rows = conn.execute(""" SELECT users.id, users.username FROM users JOIN user_groups ON user_groups.user_id = users.id - WHERE user_groups.group_id = ? + WHERE user_groups.group_id = ? AND users.deleted_at IS NULL ORDER BY users.username ASC """, (g["id"],)).fetchall() groups_data.append({ @@ -4821,13 +6617,17 @@ def groups(): # 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 ORDER BY username ASC").fetchall() + admin_rows = conn.execute( + "SELECT username FROM users WHERE is_admin=1 AND deleted_at IS NULL ORDER BY username ASC" + ).fetchall() admin_virtual_group = { "name": "Admin", "permissions": set(ALL_PERMISSION_KEYS), "member_names": [u["username"] for u in admin_rows], } - all_users_all = conn.execute("SELECT id, username, is_admin FROM users ORDER BY username ASC").fetchall() + all_users_all = conn.execute( + "SELECT id, username, is_admin FROM users WHERE deleted_at IS NULL ORDER BY username ASC" + ).fetchall() conn.close() return render_template( diff --git a/srv/poe_manager/create_admin.py b/srv/tesm/create_admin.py similarity index 100% rename from srv/poe_manager/create_admin.py rename to srv/tesm/create_admin.py diff --git a/srv/poe_manager/create_db.py b/srv/tesm/create_db.py similarity index 96% rename from srv/poe_manager/create_db.py rename to srv/tesm/create_db.py index 061bbc5..e96670f 100644 --- a/srv/poe_manager/create_db.py +++ b/srv/tesm/create_db.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Initialisiert die SQLite-Datenbank für den PoE Manager.""" +"""Initialisiert die SQLite-Datenbank für TESM.""" import os import sqlite3 @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS switches ( c.execute(""" CREATE TABLE IF NOT EXISTS devices ( mac TEXT PRIMARY KEY, - rpi_ip TEXT NOT NULL, + ip TEXT NOT NULL, switch_hostname TEXT, port TEXT, name TEXT NOT NULL, @@ -58,7 +58,10 @@ CREATE TABLE IF NOT EXISTS users ( is_admin INTEGER DEFAULT 0, first_name TEXT, last_name TEXT, - avatar_filename TEXT + avatar_filename TEXT, + auth_source TEXT NOT NULL DEFAULT 'local', + email TEXT, + is_locked INTEGER NOT NULL DEFAULT 0 ); """) diff --git a/srv/poe_manager/generate_ips.py b/srv/tesm/generate_ips.py similarity index 93% rename from srv/poe_manager/generate_ips.py rename to srv/tesm/generate_ips.py index 3bf6f3c..fc428f1 100644 --- a/srv/poe_manager/generate_ips.py +++ b/srv/tesm/generate_ips.py @@ -23,9 +23,9 @@ def generate_ips_list(): } devices = conn.execute(""" - SELECT mac, rpi_ip, port, name, switch_hostname + SELECT mac, ip, port, name, switch_hostname FROM devices - WHERE is_active=1 + WHERE is_active=1 AND deleted_at IS NULL """).fetchall() conn.close() @@ -44,7 +44,7 @@ def generate_ips_list(): port = dev["port"] or "" print( - f"{dev['rpi_ip']}|" + f"{dev['ip']}|" f"{dev['name']}|" f"{switch_ip}|" f"{switch_ssh_port}|" diff --git a/srv/poe_manager/poe_wrapper.py b/srv/tesm/poe_wrapper.py similarity index 100% rename from srv/poe_manager/poe_wrapper.py rename to srv/tesm/poe_wrapper.py diff --git a/srv/poe_manager/requirements.txt b/srv/tesm/requirements.txt similarity index 75% rename from srv/poe_manager/requirements.txt rename to srv/tesm/requirements.txt index 6c9e267..226ede6 100644 --- a/srv/poe_manager/requirements.txt +++ b/srv/tesm/requirements.txt @@ -9,8 +9,10 @@ Flask-Login==0.6.3 Flask-Sock==0.7.0 itsdangerous==2.2.0 Jinja2==3.1.6 +ldap3==2.9.1 MarkupSafe==3.0.2 paramiko==3.5.1 +pyasn1==0.6.4 pycparser==2.23 PyNaCl==1.5.0 PyYAML==6.0.2 @@ -25,3 +27,7 @@ Werkzeug==3.1.3 # PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen # gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest # die eigene Override-Datei unter /etc/netplan/. +# +# ldap3 (reines Python, keine System-Bibliothek wie libldap nötig) für die +# optionale Active-Directory/LDAP-Anmeldung (Systemeinstellungen → LDAP) — +# Search+Bind gegen einen AD-Domain-Controller oder generischen LDAP-Server. diff --git a/srv/poe_manager/static/css/style.css b/srv/tesm/static/css/style.css similarity index 98% rename from srv/poe_manager/static/css/style.css rename to srv/tesm/static/css/style.css index 7abb75a..015ece6 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/tesm/static/css/style.css @@ -1002,6 +1002,7 @@ select { display: flex; align-items: center; justify-content: center; + gap: 5vw; background: radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%), radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%), @@ -1009,6 +1010,16 @@ select { padding: 20px; } +/* TESM-Wortmarke (mit Funktionszeile) als eigenes Panel neben der Login-Karte + -- nebeneinander statt überlagert, damit beides gleichzeitig sichtbar + bleibt. Auf schmalen Bildschirmen ausgeblendet (Platz reicht sonst nicht). */ +.login-branding { display: none; pointer-events: none; } +.login-branding img { width: min(34vw, 520px); height: auto; } + +@media (min-width: 860px) { + .login-branding { display: block; } +} + .login-card { width: 100%; max-width: 380px; diff --git a/srv/poe_manager/static/css/vendor/xterm.css b/srv/tesm/static/css/vendor/xterm.css similarity index 100% rename from srv/poe_manager/static/css/vendor/xterm.css rename to srv/tesm/static/css/vendor/xterm.css diff --git a/srv/tesm/static/images/icon-dark.svg b/srv/tesm/static/images/icon-dark.svg new file mode 100644 index 0000000..0802b3d --- /dev/null +++ b/srv/tesm/static/images/icon-dark.svg @@ -0,0 +1,12 @@ + + + + + + + TESM + + + diff --git a/srv/tesm/static/images/icon-light.svg b/srv/tesm/static/images/icon-light.svg new file mode 100644 index 0000000..67cbfd9 --- /dev/null +++ b/srv/tesm/static/images/icon-light.svg @@ -0,0 +1,12 @@ + + + + + + + TESM + + + diff --git a/srv/tesm/static/images/logo-dark-subline.svg b/srv/tesm/static/images/logo-dark-subline.svg new file mode 100644 index 0000000..e5fe857 --- /dev/null +++ b/srv/tesm/static/images/logo-dark-subline.svg @@ -0,0 +1,13 @@ + + + + + + TESM + + + ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE) + diff --git a/srv/tesm/static/images/logo-dark.svg b/srv/tesm/static/images/logo-dark.svg new file mode 100644 index 0000000..921f6ff --- /dev/null +++ b/srv/tesm/static/images/logo-dark.svg @@ -0,0 +1,9 @@ + + + + + TESM + + diff --git a/srv/tesm/static/images/logo-light-subline.svg b/srv/tesm/static/images/logo-light-subline.svg new file mode 100644 index 0000000..fdce623 --- /dev/null +++ b/srv/tesm/static/images/logo-light-subline.svg @@ -0,0 +1,13 @@ + + + + + + TESM + + + ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE) + diff --git a/srv/tesm/static/images/logo-light.svg b/srv/tesm/static/images/logo-light.svg new file mode 100644 index 0000000..1c67792 --- /dev/null +++ b/srv/tesm/static/images/logo-light.svg @@ -0,0 +1,9 @@ + + + + + TESM + + diff --git a/srv/poe_manager/static/images/logo.png b/srv/tesm/static/images/logo.png similarity index 100% rename from srv/poe_manager/static/images/logo.png rename to srv/tesm/static/images/logo.png diff --git a/srv/poe_manager/static/js/app.js b/srv/tesm/static/js/app.js similarity index 96% rename from srv/poe_manager/static/js/app.js rename to srv/tesm/static/js/app.js index 84671c0..d543894 100644 --- a/srv/poe_manager/static/js/app.js +++ b/srv/tesm/static/js/app.js @@ -14,6 +14,17 @@ document.querySelectorAll("[data-theme-icon]").forEach((el) => { el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun; }); + // Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App + // (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) -- + // "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte + // Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode). + const suffix = theme === "light" ? "light" : "dark"; + const favicon = document.getElementById("app-favicon"); + if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg"; + const sidebarLogo = document.getElementById("sidebar-logo"); + if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg"; + const loginBgLogo = document.getElementById("login-bg-logo"); + if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg"; } function initTheme() { diff --git a/srv/poe_manager/static/js/vendor/xterm-addon-fit.js b/srv/tesm/static/js/vendor/xterm-addon-fit.js similarity index 100% rename from srv/poe_manager/static/js/vendor/xterm-addon-fit.js rename to srv/tesm/static/js/vendor/xterm-addon-fit.js diff --git a/srv/poe_manager/static/js/vendor/xterm.js b/srv/tesm/static/js/vendor/xterm.js similarity index 100% rename from srv/poe_manager/static/js/vendor/xterm.js rename to srv/tesm/static/js/vendor/xterm.js diff --git a/srv/poe_manager/templates/_dashboard_tiles.html b/srv/tesm/templates/_dashboard_tiles.html similarity index 96% rename from srv/poe_manager/templates/_dashboard_tiles.html rename to srv/tesm/templates/_dashboard_tiles.html index f04c0a8..a4bb5db 100644 --- a/srv/poe_manager/templates/_dashboard_tiles.html +++ b/srv/tesm/templates/_dashboard_tiles.html @@ -31,7 +31,7 @@ data-status="{{ 'disabled' if d['is_active'] == 0 else st }}" data-mac="{{ d['mac'] }}" data-name="{{ d['name'] }}" - data-ip="{{ d['rpi_ip'] }}" + {% if current_user.is_authenticated %}data-ip="{{ d['ip'] }}"{% endif %} data-switch="{{ d['switch_hostname'] or '-' }}" data-port="{{ d['port'] or '-' }}" data-active="{{ d['is_active'] }}" @@ -48,7 +48,7 @@ {% endif %}
{{ d['name'] }}
-
{{ d['rpi_ip'] }}
+ {% if current_user.is_authenticated %}
{{ d['ip'] }}
{% endif %} {% endmacro %} diff --git a/srv/poe_manager/templates/account.html b/srv/tesm/templates/account.html similarity index 53% rename from srv/poe_manager/templates/account.html rename to srv/tesm/templates/account.html index 94c064c..5a26252 100644 --- a/srv/poe_manager/templates/account.html +++ b/srv/tesm/templates/account.html @@ -39,80 +39,26 @@

Passwort ändern

-
Erfordert Eingabe des aktuellen Passworts.
+
+ {% if current_user.is_ldap_user %}Wird über Active Directory verwaltet.{% else %}Erfordert Eingabe des aktuellen Passworts.{% endif %} +
+ {% if current_user.is_ldap_user %} +

+ Dieses Konto meldet sich über Active Directory/LDAP an — das Passwort wird dort verwaltet + und kann in dieser App nicht geändert werden. Bitte das Domänen-Passwort wie gewohnt ändern. +

+ {% else %}
+ {% endif %} -{% if current_user.is_admin %} -{% macro nav_order_buttons() %} - -{% endmacro %} -
-
-
-

Navbar-Reihenfolge

-
Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte.
-
-
- -
-{% endif %} - -{% endblock %} - -{% block scripts %} - {% endblock %} diff --git a/srv/poe_manager/templates/activity_log.html b/srv/tesm/templates/activity_log.html similarity index 100% rename from srv/poe_manager/templates/activity_log.html rename to srv/tesm/templates/activity_log.html diff --git a/srv/poe_manager/templates/base.html b/srv/tesm/templates/base.html similarity index 93% rename from srv/poe_manager/templates/base.html rename to srv/tesm/templates/base.html index d5aec55..10291c0 100644 --- a/srv/poe_manager/templates/base.html +++ b/srv/tesm/templates/base.html @@ -3,8 +3,8 @@ -{{ title or "PoE Manager" }} - +{{ title or "TESM" }} + {% block extra_head %}{% endblock %} @@ -25,6 +25,7 @@ "transfer": '', "network": '', "wrench": '', + "trash": '', } %}
@@ -34,10 +35,7 @@
+ {% endif %} diff --git a/srv/poe_manager/templates/credentials.html b/srv/tesm/templates/credentials.html similarity index 91% rename from srv/poe_manager/templates/credentials.html rename to srv/tesm/templates/credentials.html index a49f7c8..7892c0e 100644 --- a/srv/poe_manager/templates/credentials.html +++ b/srv/tesm/templates/credentials.html @@ -23,8 +23,14 @@
+
+
+ + +
+
- +
@@ -171,6 +177,14 @@ function openEditCredentialModal(id, name, username, category) { PoeUI.openModal("editCredentialModal"); } +function filterTable(inputId, tableId) { + const q = document.getElementById(inputId).value.trim().toLowerCase(); + document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => { + if (row.classList.contains("empty-row")) return; + row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none"; + }); +} + function validateCredentialForm(form, id) { const pass = document.getElementById("password_" + id); const confirm = document.getElementById("password_confirm_" + id); diff --git a/srv/poe_manager/templates/devices.html b/srv/tesm/templates/devices.html similarity index 96% rename from srv/poe_manager/templates/devices.html rename to srv/tesm/templates/devices.html index dbaea21..9a03531 100644 --- a/srv/poe_manager/templates/devices.html +++ b/srv/tesm/templates/devices.html @@ -50,9 +50,9 @@ {% for d in devices %} - + - + @@ -76,7 +76,7 @@
{% if can_edit %}
- +
Bitte eine gültige IP-Adresse eingeben.
@@ -196,7 +196,7 @@
- +
Bitte eine gültige IP-Adresse eingeben.
@@ -361,7 +361,7 @@ function validateNewCredentialPassword(form) { return true; } function validateDeviceForm(form) { - const ipInput = form.querySelector("input[name='rpi_ip']"); + const ipInput = form.querySelector("input[name='ip']"); const macInput = form.querySelector("input[name='mac']"); let valid = true; if (ipInput) valid = validateIP(ipInput) && valid; @@ -370,7 +370,7 @@ function validateDeviceForm(form) { return valid; } document.addEventListener("input", (e) => { - if (e.target.name === "rpi_ip") validateIP(e.target); + if (e.target.name === "ip") validateIP(e.target); if (e.target.name === "mac") validateMAC(e.target); if (e.target.name === "new_credential_password_confirm") { const form = e.target.closest("form"); @@ -395,7 +395,7 @@ function resetCredentialChoice(id) { // ------------------------------------------------------------------------- // SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum -// Muster in switches.html, nur mit rpi_ip statt ip als Feldname. +// Identisches Muster wie in switches.html. // ------------------------------------------------------------------------- let term = null, fitAddon = null, termSocket = null, activePasswordInput = null; @@ -444,7 +444,7 @@ function getCredentialInfo(form) { } function openTerminal(form) { - const host = (form.querySelector("input[name='rpi_ip']") || {}).value?.trim(); + const host = (form.querySelector("input[name='ip']") || {}).value?.trim(); const port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22; const { username, passwordInput } = getCredentialInfo(form); activePasswordInput = passwordInput; diff --git a/srv/poe_manager/templates/groups.html b/srv/tesm/templates/groups.html similarity index 75% rename from srv/poe_manager/templates/groups.html rename to srv/tesm/templates/groups.html index 4819e20..aa4560d 100644 --- a/srv/poe_manager/templates/groups.html +++ b/srv/tesm/templates/groups.html @@ -82,8 +82,9 @@
R = Read (Lesen) · W = Write (Anlegen) · - E = Edit (Ändern, inkl. Löschen) · - D = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) + E = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) · + D = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) · + X = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
{% endmacro %} @@ -104,8 +105,14 @@
+
+
+ + +
+
-
Name Username
{{ d['name'] }}{{ d['rpi_ip'] }}{{ d['ip'] }} {{ d['mac'] }} {{ d['switch_hostname'] or '—' }} {{ d['port'] or '—' }}
+
@@ -168,6 +175,7 @@ + {% set can_unlock_system = g.is_system and current_user.is_admin %}
Gruppe Mitglieder