# 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)
mit komplett neuem, modernem Frontend und einem granularen Rechtesystem.

**Das Projekt ist ausschließlich für Linux ausgelegt** (Zielsystem: eine
Linux-VM). Entwickelt wird unter WSL2 (Ubuntu) unter Windows — siehe
[Entwicklung unter WSL](#entwicklung-unter-wsl) weiter unten.

Die App ermöglicht:

- **Öffentliches Kachel-Dashboard ohne Login** — zeigt nur aktive (nicht
  deaktivierte) Geräte, nur Online/Offline/Gesamt-Statistik; volle Verwaltung,
  Aktivieren/Deaktivieren und PoE-Neustart bleiben hinter dem Login. Eingeloggt
  sind zusätzlich deaktivierte Geräte sichtbar (eigene Kachel-Sektion). Alle
  Kacheln sind durchsuchbar und in drei Abschnitten sortiert: Offline, Online,
  Deaktiviert — jeweils alphabetisch. Die vier Stat-Kacheln oben (Online/
  Offline/Deaktiviert/Gesamt) sind klickbare Filter — ein Klick blendet alle
  anderen Abschnitte aus, „Gesamt“ oder ein erneuter Klick auf die aktive
  Kachel hebt den Filter wieder auf. Jeder Abschnitt lässt sich unabhängig
  davon per Klick auf seinen Titel ein-/ausklappen (z.B. um eine lange
  Offline-Liste vorübergehend zu verstecken, ohne den Filter zu nutzen) —
  beide Zustände bleiben über die automatischen Live-Updates hinweg erhalten.
  Abmelden führt zurück auf dieses (auch ohne Login einsehbare) Dashboard
  statt auf die Login-Seite
- Der Hauptinhaltsbereich nutzt die **volle verfügbare Breite** (kein
  Bootstrap-typisches, festes `max-width` mehr) — nur Modals/Dialoge behalten
  bewusst ihre kompakte, feste Breite
- Verwaltung von Devices und Switches
- **Zugangsdaten als eigene, wiederverwendbare Entität**: SSH-Logins werden
  einmal angelegt und können mehreren Switchen zugeordnet werden; beim
  Anlegen eines Switches lassen sich bestehende Zugangsdaten auswählen oder
  direkt neue hinterlegen, ohne vorher zu „Zugangsdaten“ wechseln zu müssen
- **Browser-SSH-Terminal beim Anlegen/Bearbeiten eines Switches**, um den
  Host-Key zu bestätigen und die Zugangsdaten direkt zu prüfen, bevor der
  Switch gespeichert wird — funktioniert auf jedem Betriebssystem, die
  SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut
- Manueller PoE-Neustart einzelner Geräte per Klick — ausschließlich über
  den zugeordneten Switch (`poe.sh`); der Button erscheint im Detail-Popup
  nur, wenn dem Gerät tatsächlich Switch **und** Port zugeordnet sind (sonst
  wäre kein PoE-Reset möglich), und ist immer der am weitesten links stehende
  Aktions-Button — „Schließen“ steht unabhängig davon immer ganz rechts. Bei
  deaktivierten Geräten bietet das Detail-Popup stattdessen direkt eine
  Aktivieren-Option
- **SSH-basierte Wartung für Linux-Clients** (eigene Seite „Wartung“,
  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
  läuft **global in der Topbar auf jeder Seite** (nicht nur im Dashboard)
- Live-Log-Ansicht mit farblicher Hervorhebung (online/offline/Neustart);
  Dashboard und Live-Log aktualisieren sich automatisch im Hintergrund
  (per AJAX, ohne vollen Seiten-Reload). Ein „Jetzt prüfen"-Icon neben dem
  Timer (nur Admins) startet manuell einen sofortigen Prüf-Durchlauf —
  der Countdown setzt sich dabei auf den manuell ausgelösten Zeitpunkt zurück
- **Einheitliches Seiten-Layout**: die Topbar zeigt rechts ausschließlich
  den „Nächste Prüfung“-Timer; Anlegen-Buttons und eine kurze Beschreibung
  sitzen stattdessen direkt über der jeweiligen Tabelle. Die Listen
  (Geräte, Switche, Zugangsdaten, Benutzer, Gruppen) sind per Klick auf die
  Spaltenüberschrift sortierbar
- **Rechtesystem mit Gruppen**: Admins dürfen immer alles (als Gruppe „Admin“
  überall mit aufgeführt); normalen Benutzern lassen sich über Gruppen
  einzelne Verwaltungsrechte gezielt zuweisen, inkl. reiner Ansichtsrechte
  (siehe [Rechtesystem](#rechtesystem-gruppen--berechtigungen))
- Einklappbare Sidebar (Desktop: dauerhaft, über Neuladen hinweg gemerkt;
  mobil: temporäres Überlagern)
- **Änderungslog**: jede Anlage/Bearbeitung/Löschung sowie Aktivieren/
  Deaktivieren von Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen
  wird protokolliert (wer, wann, was) — einsehbar unter „Logs → Änderungslog“;
  Geräte und Switche merken sich zusätzlich direkt am Datensatz, wer sie
  zuletzt geändert hat. PoE-Neustarts werden bewusst **nicht** geloggt
  (siehe [Änderungslog](#änderungslog))
- **Verschlüsselter Import/Export**: Geräte, Switche und Zugangsdaten lassen
  sich unter „Einstellungen → Im-/Export" als ein mit selbstgewählter
  Passphrase verschlüsseltes Bundle exportieren und auf einer neuen Umgebung
  wieder importieren (siehe [Import/Export](#importexport))
- **Eigenes Konto**: das Zahnrad-Symbol neben dem eigenen Namen unten in der
  Sidebar führt zu „Mein Konto“ — Vor-/Nachname, eigenes Passwort und
  Profilbild pflegen (siehe [Eigenes Konto](#eigenes-konto--profilbild))
- **Anpassbare Navigation**: Admins legen dort zusätzlich die Reihenfolge der
  Sidebar-Hauptpunkte per Auf-/Ab-Buttons fest; jeder Benutzer sieht davon
  ohnehin nur, wofür er berechtigt ist — auch pro Unterpunkt innerhalb einer
  Gruppe (siehe [Navigation anpassen](#navigation-anpassen))

## Was ist neu gegenüber dem Original?

- **Komplett neues Frontend**: eigenes Design-System (kein Bootstrap mehr —
  der Hauptinhaltsbereich nutzt bewusst die volle verfügbare Breite statt
  eines festen, Bootstrap-typischen `max-width`; nur Modals/Dialoge behalten
  ihre kompakte, feste Breite), Logo mittig in der Topbar (auf jeder Seite,
  skaliert auf die Balkenhöhe), Sidebar-Navigation (nur für eingeloggte
  Nutzer sichtbar), Dark/Light-Umschalter, Karten-Dashboard mit Live-Zählern,
  moderne Modals/Toasts statt Browser-`confirm()` und Bootstrap-Alerts. Der
  Akzent-Ton (`--accent` in `style.css`) ist exakt das Orange aus dem
  WiS-Logo (`#ff7100`, per Pixel-Sampling aus `logo.png` ermittelt), nicht
  nur eine ähnliche, frei gewählte Nuance.
- **Dashboard jetzt teilweise öffentlich**: `/` ist ohne Login erreichbar und
  zeigt eine reduzierte Lese-Ansicht (Hostname, IP, Status, letzte Prüfung)
  mit Login-Button; alle Verwaltungsseiten und der PoE-Neustart bleiben
  `@login_required` und zusätzlich rechtebasiert (s.u.).
- **Browser-SSH-Terminal für Switche**, implementiert mit **paramiko** (reines
  Python) statt eines lokalen pty + System-`ssh`-Client — läuft dadurch
  plattformunabhängig. Die SSH-Verbindung zum Switch wird immer serverseitig
  (vom PoE-Server) aufgebaut, nie vom Browser/Client aus.
- **Granulares Rechtesystem mit Gruppen** (neu, siehe unten) statt der
  ursprünglichen reinen Admin/User-Unterscheidung.
- **Gleiche Kernlogik**: Datenbankschema (erweitert um Gruppen/Rechte) und
  Login sind ansonsten unverändert. Der PoE-Restart-Mechanismus selbst
  (`poe.sh` per SSH/`expect` auf die Aruba-Switche) wurde dagegen
  ü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 (`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.

> ⚠️ **Sicherheitshinweis zum Original-Repo:** Im Original sind `sqlite.db`
> (Nutzer-Passwort-Hashes, verschlüsselte Switch-Passwörter) und `fernet.key`
> (der zugehörige Schlüssel) mit im Git-Repo eingecheckt — die `.gitignore`
> dort schließt sie über die `!`-Regeln versehentlich wieder ein. In diesem
> Rebuild sind beide Dateien in `.gitignore` ausgeschlossen und werden beim
> ersten Start frisch erzeugt. Empfehlung fürs Original-Repo: beide Dateien
> aus der Git-Historie entfernen und Fernet-Key sowie alle Switch-/User-
> Passwörter rotieren.

## Rechtesystem (Gruppen & Berechtigungen)

Admins dürfen immer alles. Zusätzlich gibt es **Gruppen** mit einem
**vollständig granularen, an der Navbar gespiegelten Rechtebaum** — jeder
Bereich der App (nicht nur Devices/Switches) lässt sich einzeln freischalten.
Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich
(Vereinigung, nicht Schnittmenge).

### Zweistufiger Rechtebaum

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  X
  Clients   ☑ ☑ ☑ ☑          Live      ☑ ☑ ☑         Benutzer       ☑ ☑ ☑ ▢
  Switche   ☑ ☑ ☑ ▢          Änderungen☑ ☑ ☑         Gruppen        ☑ ☑ ☑ ▢
  Zugangsd. ☑ ☑ ☑ ▢          Kea-DHCP  ☑ ☑ ☑         Systemeinst.   ☑ ▢ ☑ ▢
  Wartung   ☑ ▢ ☑ ▢                                  LDAP           ☑ ▢ ☑ ▢
                                                      DHCP           ☑ ▢ ☑ ▢
                                                      Im-/Export     ☑ ▢ ☑ ☑
```

Das jeweilige „Bereich sichtbar“-Recht (`devices_group.view` /
`logs_group.view` / `settings_group.view`, das Kästchen in der Kopfzeile
einer Spalte) wirkt als **Kill-Switch**: ist es für eine Gruppe nicht
gesetzt, greift kein einziges Recht darunter mehr — selbst wenn z.B.
`devices.view` einzeln noch angehakt ist. So lässt sich ein ganzer Bereich
mit einem Klick sperren, ohne jedes Unterrecht einzeln zurücknehmen zu
müssen (`User.has_permission()` in `app.py`). Auf der Gruppen-Seite sind
die Unterpunkt-Zeilen einer Spalte per JavaScript so lange gesperrt UND
sichtbar ausgegraut (nicht nur das dezente native disabled-Styling), bis
das Kopfzeilen-Kästchen angehakt ist.

### Systemgruppen

Zwei Gruppen sind fest und **weder umbenennbar noch in ihren Rechten
änderbar** (auch nicht durch Admins über die UI) — Mitgliedschaft bleibt bei
beiden frei verwaltbar:
- **Admin** — virtuell (kein echter `groups`-Datensatz), intern über den
  `is_admin`-Schalter der Benutzer gesteuert, immer alle Rechte. Mindestens
  ein Admin muss bestehen bleiben (serverseitig erzwungen).
- **Benutzer** — die Standardgruppe (`is_system`-Flag), der jeder neu
  angelegte Nicht-Admin automatisch zugeordnet wird. Fester Rechtesatz:
  Geräte-Bereich + Clients/Switche lesen, Logs-Bereich + Live-Log lesen.

Alle anderen Gruppen sind vom jeweiligen Rechteinhaber (`groups.edit`, das
auch Anlegen und Löschen von Gruppen umfasst) frei konfigurierbar.

### Eskalationsschutz

Da jetzt auch **Benutzer-** und **Gruppenverwaltung** delegierbar sind (z.B.
eine Gruppe mit nur `users.edit`, ohne Admin zu sein), gelten zusätzlich zu
den granularen Rechten diese fest einprogrammierten Schranken, unabhängig
davon, was eine Gruppe an Rechten hat:
- Ein Benutzer als Admin anlegen/dazu machen (`group_id=admin` bei
  Anlegen/Zuweisen) bleibt echten Admins vorbehalten.
- Ein bestehendes Admin-Konto bearbeiten, löschen oder ihm die Gruppe ändern
  bleibt echten Admins vorbehalten.
- Wer Admin ist, wird ausschließlich über „Admin-Mitglieder verwalten“ auf
  der Gruppen-Seite gesteuert — bleibt admin-exklusiv, unabhängig von
  `groups.edit`.

Sowohl das Anzeigen jeder Seite als auch jede einzelne Aktion (Buttons,
Formulare) ist an das jeweilige Recht gekoppelt — im Frontend ausgeblendet
**und** im Backend serverseitig durchgesetzt, unabhängig vom Frontend.

Datenmodell: `groups` (inkl. `is_default`- und `is_system`-Flag),
`group_permissions` (Gruppe → Recht), `user_groups` (Benutzer → Gruppe).
Bestehende Datenbanken werden beim App-Start automatisch migriert
(`_ensure_schema()` in `app.py`): `devices.toggle` → `devices.edit`,
`switches.*` → gespiegelte `credentials.*`, nachträgliches Setzen der neuen
Bereichs-Rechte für bereits vergebene Unterrechte, sowie (spätere Revision)
sämtliche `*.delete`-Rechte → `*.edit` (Löschen wurde nachträglich überall in
Ändern gefaltet). Jede dieser Migrationen läuft **nur einmalig** über einen
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
(`credentials`-Tabelle) statt direkt am Switch/Gerät zu hängen — mehrere
Switche/Geräte mit demselben Login teilen sich einen Eintrag. Beim Anlegen/
Bearbeiten eines Switches **oder Geräts** lässt sich per Dropdown ein
bestehender Eintrag auswählen oder über „+ Neue Zugangsdaten anlegen“ direkt
im selben Formular ein neuer anlegen, ohne vorher zur eigenen
**Zugangsdaten**-Seite wechseln zu müssen. Löschen ist nur möglich, wenn
weder ein Switch noch ein Gerät mehr auf den Eintrag verweist. Bestehende
Datenbanken aus einer älteren Version (Username/Passwort direkt am Switch)
werden beim ersten Start automatisch migriert.

Jede Zugangsdaten-Zeile hat außerdem eine explizite **Kategorie** (Switch /
Linux-Client / Windows-Client / Router-Sonstiges), vom Admin beim Anlegen
gesetzt — bewusst **nicht** automatisch per Ping/TTL-Heuristik erkannt (zu
unzuverlässig, siehe Diskussion in der Commit-Historie). Aktuell wertet nur
die Kategorie **Linux-Client** etwas aus: nur Geräte mit einer so
kategorisierten Zugangsdaten-Zuordnung erscheinen unter **Wartung** (siehe
unten). Windows/PowerShell-Wartung ist als eigener, separater Schritt
vorgesehen (kein Testsystem dafür verfügbar) — die Kategorie existiert
bereits, die Aktion dahinter noch nicht.

Sowohl die Switche- als auch die Clients-Tabelle zeigen dieselbe
„Zugangsdaten“-Spalte (Name + Username, oder „— keine —“), damit auf einen
Blick erkennbar ist, welches Gerät bereits per SSH verwaltet werden kann,
ohne extra zur Zugangsdaten-Seite wechseln zu müssen.

Jeder Switch kann außerdem einen individuellen **SSH-Port** hinterlegen
(Feld „SSH-Port“ beim Anlegen/Bearbeiten) — bleibt er leer, wird überall
automatisch **Port 22** angenommen (`SWITCH_DEFAULT_SSH_PORT` in `app.py`).
Der Port wird konsistent an allen drei Stellen verwendet, an denen die App
sich per SSH mit einem Switch verbindet: dem Web-Terminal (Verbindungstest),
`generate_ips.py` (liefert ihn als eigenes Feld an `poe.sh`) und den
`expect`-Aufrufen in `poe.sh` selbst (`ssh -p <port> ...`) für automatische
wie manuelle PoE-Neustarts.

## Wartung (SSH-Bulk-Update & -Neustart)

Eigene Seite unter **Geräte → Wartung** (eigenes Rechtepaar
`maintenance.view`/`maintenance.edit`), komplett getrennt vom PoE-Neustart
auf dem Dashboard:

- **Dashboard-Neustart** = immer PoE-Reset über den Switch (`poe.sh`),
  funktioniert unabhängig davon, ob das Gerät SSH-Zugangsdaten hat.
- **Wartung** = immer eine direkte SSH-Verbindung zum Gerät selbst,
  funktioniert unabhängig davon, ob/an welchem Switch es hängt.

Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie **Linux-Client**
(siehe oben) erscheinen in der Liste. Zwei Aktionen:

- **Update** (Mehrfachauswahl per Checkbox, ein Klick startet alle
  ausgewählten parallel als eigene Hintergrund-Threads): führt serverseitig
  `env DEBIAN_FRONTEND=noninteractive apt-get update && env
  DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef"
  -o Dpkg::Options::="--force-confold" upgrade` per SSH aus — nicht
  interaktiv (kein Terminal, keine Rückfragemöglichkeit), die
  `Dpkg::Options`-Flags verhindern, dass ein Paket-Postinst-Skript auf eine
  Config-Datei-Rückfrage wartet, die nie kommt und sonst bis zum Timeout
  hängen bliebe.
- **Neustart** (pro Gerät): `sudo reboot` per SSH, danach automatisch
  weiterverfolgt bis das Gerät wieder online ist — ohne das bliebe der
  Status für immer auf „Neustart ausgelöst“ stehen, obwohl der eigentlich
  interessante Zeitpunkt (kommt das Gerät zurück?) noch gar nicht erfasst
  wäre. Ablauf: das SSH-Neustart-Kommando selbst gilt als erfolgreich
  ausgelöst, sobald die Verbindung abbricht (erwartet — die Maschine fährt
  herunter, bevor sie noch antworten kann; nur ein Verbindungs-/Auth-Fehler
  **vor** dem Kommando ist ein echter Fehler). Danach wartet eine kurze
  Schonfrist (15s, damit ein zu früher Ping nicht noch die absterbende alte
  Instanz erreicht) und pingt anschließend alle 5s (`ping -c 1 -W 2` — exakt
  dieselbe Invocation wie das bestehende Online/Offline-Monitoring in
  `poe.sh`), bis eine Antwort kommt oder ein Timeout von 5 Minuten erreicht
  ist; erst dann wechselt der Status auf „wieder online“ (Erfolg) bzw. auf
  einen Fehler, falls das Gerät nicht rechtzeitig zurückkommt.

Live-Status pro Gerät (läuft/erfolgreich/fehlgeschlagen inkl. Ausgabe der
letzten Aktion, per Klick ein-/ausblendbar) wird per Polling (`/maintenance/status`,
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
`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
ihn noch live mitverfolgt, ein späterer Seitenaufruf soll keine ggf.
stunden-/tagealte Erfolgsmeldung dauerhaft zeigen. Fehlgeschlagene Aktionen
bleiben dagegen bewusst sichtbar, bis eine neue Aktion sie überschreibt, um
nicht durch einen einfachen Reload versehentlich übersehen zu werden.

**Sicherheitsmodell**: dieselbe `known_hosts`-Datei wie beim
Browser-SSH-Terminal (`SSH_KNOWN_HOSTS_PATH`) — ein Host muss vorher
mindestens einmal interaktiv über „Verbindung testen“ bestätigt worden sein.
Anders als das interaktive Terminal (dort kann bei einem unbekannten
Host-Key wie bei einem echten `ssh`-Client live nachgefragt werden) läuft
hier nichts vor einem Menschen ab — ein unbekannter oder nicht vorher
bestätigter Host-Key wird deshalb per `paramiko.RejectPolicy` hart
abgelehnt statt automatisch akzeptiert, da ohne Rückfragemöglichkeit ein
automatisches Akzeptieren ein MITM-Risiko ohne jede Prüfung wäre.

Live gegen ein dediziertes Testsystem verifiziert (separat vom gemeinsam
genutzten App-Host, um dort kein echtes `apt upgrade` auszulösen): ein
echtes `apt update && apt upgrade` (inkl. Kernel-/systemd-/netplan-Paketen)
lief nicht-interaktiv vollständig durch (`status: success`, komplette
`apt`-Ausgabe im Job-Verlauf sichtbar); der SSH-Neustart samt
Online-Watchdog wurde zweimal gegen dasselbe Testsystem verifiziert —
jeweils per `uptime -s` bestätigt, dass der Boot-Zeitpunkt exakt zum
Auslöse-Zeitpunkt des jeweiligen Jobs passte (kein False-Positive-Ping
gegen die noch nicht heruntergefahrene alte Instanz), und dass der
Job-Status korrekt von „wartet auf Online“ auf „wieder online“
umgeschaltet hat. Das Zurücksetzen auf „Noch keine Aktion“ bei einem
erneuten Seitenaufruf wurde ebenfalls live bestätigt (Status vor einem
`GET /maintenance` noch sichtbar, danach wieder leer).

Windows-Clients/PowerShell-Wartung ist bewusst **noch nicht** umgesetzt
(kein Testsystem verfügbar) — die Kategorie „Windows-Client“ existiert in
den Zugangsdaten bereits als Vorbereitung für einen späteren, separaten
Ausbauschritt.

## DHCP-Reservierungen & eigene Options (Systemeinstellungen → DHCP)

Erzeugt aus den vorhandenen Client-Stammdaten (MAC + IP + Name) automatisch
feste DHCP-Reservierungen inkl. eigener/herstellerspezifischer Options, ohne
die Daten ein zweites Mal pflegen zu müssen. Eigenes Rechtepaar
`settings_dhcp.view`/`settings_dhcp.edit`, gespiegelt in der Navbar unter
Einstellungen.

**Backend: Kea DHCP** (`kea-dhcp4-server`), nicht das ältere
`isc-dhcp-server` — ISC hat isc-dhcp-server 2022 als EOL erklärt, Kea ist
der aktiv weiterentwickelte Nachfolger und bildet „globaler Wert, pro Client
überschreibbar“ nativ ab (siehe unten).

- **Status-Erkennung** (rein lesend): `shutil.which("kea-dhcp4")` +
  `systemctl is-active`/`is-enabled`. Zeigt Version, Lauf- und
  Autostart-Status.
- **Installation & Dienststeuerung**: eigene, jeweils einzeln bestätigte
  Buttons — „kea-dhcp4-server installieren“ (`apt-get install -y`),
  „Aktivieren & (neu) starten“ (`systemctl enable` + `restart`) und
  „Stoppen“ (`systemctl stop` **+ `disable`**, damit ein bewusst gestoppter
  Dienst nach einem Host-Neustart nicht trotzdem automatisch wieder
  hochfährt — ein Host-Neustart übernimmt so genau den zuletzt bewusst
  gewählten Zustand, an oder aus). Bewusst **nicht** automatisch an
  „Konfiguration speichern“ oder „In Datei schreiben“ gekoppelt — ein
  unerwartet aktiver oder fehlkonfigurierter DHCP-Server kann ein ganzes
  Netzsegment stören, jede dieser Aktionen soll ein bewusster, separater
  Klick bleiben.
- **Mehrere Subnetze gleichzeitig**: ein Host kann mehrere IPs/Interfaces
  mit jeweils eigenem Netz haben, für die alle DHCP angeboten werden soll
  (`dhcp_subnets`-Tabelle statt einer einzelnen globalen Range) — jedes
  Subnetz hat eigene Interface/Range sowie optional eigenes Gateway/DNS,
  generiert wird ein eigener `subnet4`-Block je Subnetz in der Kea-Config.
  Domain, Lease-Zeiten und Ausgabepfad bleiben global (Kea-weit gültig).
- **Netzwerk-Kopplung**: Subnet und Netzmaske werden **nie manuell
  eingegeben**, sondern bei jeder Anzeige/Generierung live aus der
  System-Netzwerkkonfiguration des jeweiligen Interfaces gelesen
  (`_detect_interface_network()`/`_detect_interface_networks()`:
  `ip -4 addr show dev <iface>` + `ip route show default`) — berücksichtigt
  dabei ALLE IPv4-Adressen eines Interfaces, nicht nur die erste (ein
  Interface kann mehrere IPs/Subnetze gleichzeitig tragen, z.B. Alias-IPs).
  Die generierte Kea-Konfiguration ist dadurch immer an das Netz gekoppelt,
  in dem der Host tatsächlich hängt, auch wenn sich dessen IP/Subnet mal
  ändert.
- **Range ist Pflicht und muss zu einem echten Netz passen**: kein
  vorausgefüllter Default für Range/DNS (nur Platzhaltertext) — ein
  Subnetz ohne zu einem tatsächlich vorhandenen Netz passende Range wird
  beim Anlegen/Bearbeiten abgelehnt (`_dhcp_matching_network()`), da Kea
  ohne eine echte, physisch vorhandene IP im gewünschten Bereich keine
  gültige Subnet-CIDR zugeordnet werden kann. Dieselbe Prüfung läuft
  zusätzlich bei „In Datei schreiben“ (Subnetze, die inzwischen zu keinem
  echten Netz mehr passen — z.B. nach einem Netzwechsel des Hosts — werden
  übersprungen und in der Erfolgsmeldung benannt, statt die ganze Aktion
  abzubrechen) und bei „Aktivieren & (neu) starten“ (verweigert den Start,
  falls kein einziges gültiges Subnetz mehr existiert). Live auf einer
  echten Fehlkonfiguration reproduziert und verifiziert.
- **Subnetze einzeln deaktivierbar**: ein Subnetz lässt sich ohne Löschen
  pausieren (`enabled`-Flag) — wird dann bei „In Datei schreiben“ und
  „Aktivieren & (neu) starten“ komplett ignoriert (kein Fehler, bewusst
  kein "ungültig", da das Fehlen des Netzes hier Absicht ist), statt es
  jedes Mal neu anlegen zu müssen.
- **DHCP-Options** (`dhcp_option_defs`/`dhcp_option_values`): eine kuratierte
  Auswahl an **56 Standard-Optionen** (analog den „Vordefinierten Optionen“
  eines Windows-DHCP-Servers — u.a. NTP-/WINS-/TFTP-/SMTP-Server, Boot-Datei,
  Domain-Search, IP-Forwarding) ist automatisch vorbefüllt und **nicht
  löschbar** (Backend lehnt es ab, UI zeigt statt eines Löschen-Buttons ein
  Schloss-Symbol) — Router/DNS-Server/Domain-Name/Lease-Zeiten sind bewusst
  ausgenommen, da dafür bereits eigene, dedizierte Felder existieren
  (Subnetze bzw. globale Einstellungen oben); ebenso client-/
  protokollinterne Optionen (message-type, parameter-request-list,
  requested-address, ...), die serverseitig nicht sinnvoll setzbar sind.
  „host-name“ (12) ist ebenfalls bewusst **nicht** enthalten (war es
  zwischenzeitlich, wurde aber wieder entfernt): Kea beantwortet die
  Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch
  über deren eigenes `hostname`-Feld (aus dem Namen der Reservierung
  abgeleitet) — ein zusätzlicher `option-data`-Override für dieselbe Option
  wäre laut Kea-Dokumentation eine Dopplung und Quelle für Widersprüche,
  live an einer echten Reservierung mit beiden gleichzeitig gesetzten
  Werten nachvollzogen. Alle verbleibenden Namen sind live gegen eine echte
  Kea-2.4.1-Instanz verifiziert (`kea-dhcp4 -t`, jede Option einzeln
  getestet). Standard-Optionen bekommen bewusst **kein eigenes
  `option-def`** in der generierten Konfiguration — Kea kennt sie bereits
  nativ, eine Neudefinition würde die eingebaute duplizieren; nur
  zusätzlich angelegte, wirklich eigene/herstellerspezifische Options (z.B.
  eine Terminal-Boot-URL) bekommen eines und bleiben frei löschbar.
  In der Tabelle sind grundsätzlich nur **tatsächlich genutzte** Options
  sichtbar (eigene immer, Standard-Optionen nur mit gesetztem globalen Wert)
  — weitere Standard-Optionen kommen über ein Dropdown „+ Standard-Option
  hinzufügen“ dazu, statt alle 56 dauerhaft als leere Zeilen anzuzeigen.
  Jede Option hat einen **globalen** Wert sowie optional einen **Wert pro
  Client** — ein Client-Override überschreibt den globalen Wert
  ausschließlich für dieses eine Gerät (technisch: globale Werte landen im
  Top-Level `option-data`, Overrides im `option-data` der jeweiligen
  Kea-Reservierung, was Kea automatisch pro Client Vorrang geben lässt). Im
  Reservierungs-Options-Dialog gilt dasselbe Prinzip: nur Options mit
  gesetztem Override sind sichtbar, weitere kommen über ein Dropdown dazu.
  Das ausgewählte Feld wird dabei direkt unter das Dropdown verschoben und
  fokussiert, statt an seiner ursprünglichen Stelle irgendwo in der u.U.
  langen Liste (bis zu 56 mögliche Options) zu erscheinen — sonst lag es
  oft außerhalb des sichtbaren Modal-Ausschnitts und wirkte, als wäre nichts
  passiert (live genau so reproduziert und behoben).
- **Reservierungen aus zwei Quellen**: automatisch für jedes aktive Gerät mit
  gültiger MAC + IP (Client-Stammdaten dieser App) sowie zusätzlich **manuell
  anlegbar** („Reservierung hinzufügen“, MAC/IP/Name) — für Geräte, die
  nicht als Client gepflegt werden (z.B. ein externes Gerät), sodass ihnen
  bewusst und mit voller Kontrolle eine feste IP gegeben werden kann, statt
  sie unüberwacht automatisch anzubinden. Automatische Reservierungen können
  in dieser Tabelle **nicht gelöscht** werden (Schloss-Symbol statt Button —
  sie entstehen/verschwinden mit den Client-Stammdaten), manuelle schon. Bei
  einer MAC in beiden Quellen gewinnt immer die automatische. Jede
  Reservierung wird ins **passende Subnetz** einsortiert (die IP entscheidet,
  zu welchem der ggf. mehreren konfigurierten Subnetze sie gehört); der
  Hostname wird aus dem Namen abgeleitet (nur `a-z0-9-`, Kollisionen
  innerhalb desselben Subnetzes bekommen automatisch ein `-2`/`-3`-Suffix).
  Einträge ohne MAC/IP oder mit einer IP außerhalb aller konfigurierten
  Subnetze werden mit Hinweis übersprungen — Kea lehnt eine Reservierung
  außerhalb ihres Subnets als Konfigurationsfehler ab (mit einer echten
  Kea-Instanz verifiziert, inkl. einer Mischung aus automatischer und
  manueller Reservierung im selben Subnetz).
- **Aktive Leases**: eigene Karte, liest die tatsächlich vergebenen Adressen
  direkt aus Keas Lease-Datenbank (`kea-leases4.csv`, memfile-Backend —
  kein Control-Agent nötig, reiner Lesezugriff auf die Datei). Zeigt damit
  auch Clients **ohne eigene Reservierung**, die sich einfach eine freie IP
  aus dem Pool genommen haben — deutlich als „Ohne Reservierung“ markiert,
  statt nur die (unvollständige) Reservierungsliste zu zeigen. Die
  Ablaufzeit wird explizit als **UTC** ausgewiesen (Kea arbeitet intern in
  UTC-Epoch) und zusätzlich als live tickender Countdown angezeigt — eine
  reine Absolutzeit auf einer nicht selbst aktualisierenden Seite wirkte bei
  den kurzen Standard-Lease-Zeiten (600s) schnell „schon abgelaufen“, sobald
  etwas Zeit vergangen war, bis man tatsächlich hinschaut (live auf der
  Test-VM als genau dieses Missverständnis reproduziert).
- **Schreiben**: „Speichern“ bei den Reservierungen (nur mit
  `settings_dhcp.edit`) schreibt die vollständige, generierte Kea-JSON-
  Konfiguration an den konfigurierten Ausgabepfad. Wirksam wird sie erst
  nach einem Dienst-Neustart über den separaten Button.
- **Ampel im Topbar**: neben dem Prüfintervall-Timer zeigt ein grüner/roter
  Punkt, ob der Kea-Dienst läuft — nur sichtbar mit `settings_dhcp.view`.
- Reservierungen mit aktiven eigenen Options zeigen in der Tabelle, welche
  Option greift (global oder Client-Override).

Live gegen eine echte, frisch installierte Kea-Instanz verifiziert:
Installation, generierte Konfiguration besteht `kea-dhcp4 -t`, Dienststart
übernimmt sie fehlerfrei (`DHCP4_CONFIG_COMPLETE`), globaler Options-Wert
sowie Client-spezifischer Override erscheinen korrekt getrennt in der
Konfiguration.

Die Mehrfach-Subnetz-Unterstützung selbst (Anlegen/Bearbeiten/Löschen samt
Validierung, mehrere generierte `subnet4`-Blöcke mit korrekten Pools/
Interfaces, Migration einer bestehenden Einzel-Range) ist live auf einem
echten Host verifiziert. Zwei tatsächlich **unabhängige** Netze (z.B. über
zwei physische NICs) standen beim Test nicht zur Verfügung — verifiziert
wurde daher zusätzlich zwei Ranges innerhalb desselben real vorhandenen
Netzes (identischer `subnet`-CIDR, unterschiedliche Pools); ob Kea zwei
`subnet4`-Blöcke mit identischem `subnet`-Wert akzeptiert oder als Fehler
ablehnt, wurde nicht gegen eine echte Kea-Instanz geprüft — bei tatsächlich
unterschiedlichen Netzen (der eigentliche Anwendungsfall) besteht dieses
Risiko nicht. Die Leases-Anzeige wurde vollständig End-to-End im echten
Betrieb bestätigt: ein tatsächlicher, unreservierter Client im Testnetz
(„shellyhtg3-...“) hat sich selbstständig eine IP aus dem Pool geholt und
wurde korrekt mit der „Ohne Reservierung“-Markierung angezeigt.

## Geräte-Prüfung (`poe.sh`)

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
einem echten Import mit deutlich mehr Geräten (46 statt einer Handvoll
Testgeräte):

- **Parallele statt sequentielle Prüfung**: vorher wurde jedes Gerät strikt
  nacheinander in einer einzigen Schleife geprüft — ein einzelnes bereits
  ausgefallenes Gerät mit ebenfalls nicht erreichbarem Switch (jeder
  `expect`-Verbindungsschritt bis zu 5s Timeout) hat dadurch die Prüfung
  aller nachfolgenden Geräte in der Liste verzögert; bei vielen gleichzeitig
  ausgefallenen Geräten konnte ein Prüfdurchlauf so erheblich länger dauern
  als das eigentlich konfigurierte Intervall. Jetzt läuft jede
  Geräte-Prüfung (Ping + ggf. PoE-Neustart) als eigener Hintergrund-Job
  (`&` + `wait` am Ende des Durchlaufs, über Process Substitution statt
  einer Pipe, damit `wait` die Jobs auch tatsächlich erfasst) — mehrere
  Geräte, auch an unterschiedlichen Switchen, werden dadurch gleichzeitig
  geprüft statt einzeln nacheinander.
- **Echte Erfolgskontrolle statt blind angenommenem Erfolg**: `disable_poe()`/
  `enable_poe()` haben vorher unabhängig vom tatsächlichen Ergebnis der
  SSH/`expect`-Sitzung immer wie ein Erfolg gewirkt — lief die Verbindung
  zum Switch in einen Timeout (Switch nicht erreichbar, falsche
  Zugangsdaten, falscher Port), endete das `expect`-Skript trotzdem mit
  Exit-Code 0, und `poe.sh` loggte fälschlich „PoE deaktiviert“/„PoE
  aktiviert", obwohl am Switch nichts passiert war. Jeder kritische
  `expect`-Schritt (Verbindungsaufbau, `configure terminal`-Prompt,
  Interface-Prompt, Bestätigung des `power-over-ethernet`-Kommandos) hat
  jetzt einen eigenen `timeout`-Zweig mit explizitem `exit 1`; `poe.sh`
  wertet diesen Rückgabewert aus und loggt bei einem nicht erreichbaren
  oder ablehnenden Switch stattdessen klar „Switch ... nicht erreichbar
  oder Kommando abgelehnt — PoE-Deaktivierung/-Reaktivierung
  fehlgeschlagen“, ohne einen Neustart vorzugaukeln, der nie stattgefunden
  hat. Gilt für die automatische Prüfschleife, den manuellen Neustart
  (`poe.sh restart <mac>`) und damit auch für den Dashboard-Neustart-Button.

Live nach einem echten Import (46 Geräte, 8 Switche aus dem Altsystem)
verifiziert: alle 46 Geräte wurden innerhalb von ~2 Sekunden geprüft (statt
sequentiell), und alle 43 als offline erkannten Geräte mit Switch-Zuordnung
zeigten korrekt die neue Fehlermeldung, da die realen Switch-IPs
(192.168.200.x) vom Test-Host aus nicht erreichbar sind — vorher hätte das
fälschlich als 43× „PoE deaktiviert“/„PoE aktiviert“ geloggt.

## SSH-Login-Banner (dynamisches MOTD)

Ein SSH-Login auf den TESM-Host selbst zeigt statt der Standard-Ubuntu-
Willkommensmeldung einen kompakten Live-Status der Flotte — Bernstein/Amber
als Akzentfarbe (passend zum Logo), fett hervorgehobene Zahlen:

```
TESM · TimEShepManager
überwachen · booten · anbinden (DHCP) · Betriebssystem geben (PXE)

3 Clients online · 45 down · DHCP: installiert, gestoppt
```

Umgesetzt als zwei Teile:
- **`srv/tesm/motd_banner.py`** — liest den Live-Status aus genau denselben
  Quellen wie das Web-Dashboard, ohne selbst zu prüfen: `get_device_status()`
  parst dieselbe Check-Log-Datei (`live.log`) wie das Dashboard,
  `_detect_dhcp_status()`/`_dhcp_read_leases()` lesen Dienststatus bzw. die
  Kea-Lease-CSV direkt. Läuft über `venv/bin/python3` (importiert `app.py`,
  das Flask u.a. braucht, die nur dort installiert sind — dasselbe Muster
  wie `generate_ips.py`). Ein Fehler beim Datenzugriff lässt die jeweilige
  Zeile nur einen Platzhaltertext zeigen, statt den Login zu stören.
- **`etc/update-motd.d/50-tesm`** — der eigentliche, ausführbare
  Ubuntu-„dynamic MOTD"-Hook (`run-parts /etc/update-motd.d`, von
  `pam_motd.so` bei jedem SSH-Login neu ausgeführt und nach
  `/run/motd.dynamic` geschrieben). `timeout 3` verhindert, dass ein
  hängender DB-/Dateizugriff den Login blockiert.

**Bewusst kein PXE-Status** in der Live-Zeile: anders als Überwachen/
Booten/DHCP ist PXE bislang nur Teil der Tagline, keine echte, mit Daten
hinterlegte Funktion der App — ein erfundener Wert wäre keine Live-Daten.

Die Standard-Ubuntu-MOTD-Skripte (`00-header`, `10-help-text`,
`50-motd-news`, `60-unminimize`, `91-release-upgrade`,
`92-unattended-upgrades`) werden vom Installer per `chmod -x` deaktiviert
(nicht gelöscht — jederzeit reversibel mit `chmod +x`), damit nur noch der
TESM-Banner erscheint.

## Netzwerkeinstellungen des Hosts (Systemeinstellungen)

Eigene, klar getrennte Funktion von obigem DHCP-*Server*: hier geht es um
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,
da eine Änderung nicht die Erreichbarkeit kappt).

- **Backend-Erkennung**: prüft, ob NetworkManager oder dhcpcd aktiv ist
  (`systemctl is-active`); ist keins von beiden aktiv, aber `systemd-networkd`
  aktiv und `netplan` installiert, wird **netplan** angenommen — der Standard
  auf einem frisch installierten Ubuntu (Server wie Cloud-Image). Ist keins
  der drei erkennbar, bleibt die Seite bewusst **rein lesend** (Status wird
  trotzdem angezeigt) — ohne zuverlässige Erkennung des Backends keine
  Änderung, um nicht ins Blaue zu raten.
- **Anzeige**: aktuelles Interface, IP/Prefix, Gateway, DNS-Server sowie
  Modus (DHCP/Statisch) — Modus wird backend-spezifisch ermittelt
  (`nmcli ipv4.method`, ein `static ip_address=`-Eintrag im betreffenden
  `interface`-Block von `/etc/dhcpcd.conf`, bzw. bei netplan am `dynamic`-Flag
  des Kernels auf der aktuellen Adresse — zuverlässiger als die u.U. mehreren
  überlagernden YAML-Dateien unter `/etc/netplan/` selbst zu parsen).
  **DNS-Server-Anzeige**: nicht aus `/etc/resolv.conf` gelesen — unter
  systemd-resolved (Standard auf aktuellem Ubuntu) steht dort immer nur der
  lokale Stub-Resolver `127.0.0.53`, nie die echten Upstream-Server (live
  genau so reproduziert). Stattdessen `resolvectl dns <interface>`, dessen
  mehrzeilige Ausgabe bei vielen Servern korrekt geparst wird (eine
  Fortsetzungszeile ohne `Link N (iface):`-Label trägt selbst
  Doppelpunkte — eine IPv6-Adresse — ein naives Aufteilen an jedem
  Doppelpunkt hätte sie sonst am Anfang abgeschnitten, live genau so
  reproduziert und gefixt). Zusätzlich auf **IPv4** gefiltert, da diese
  Seite ausschließlich die IPv4-Konfiguration des Hosts verwaltet — von
  `resolvectl` ungefragt mitgelieferte, per IPv6-Router-Advertisement
  gelernte DNS-Server gehören nicht dazu und wären hier nur irreführend.
- **Änderung**: Interface, Modus (DHCP/Statisch), bei Statisch zusätzlich
  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-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
  vorherige Konfiguration gesichert. Nach dem Anwenden läuft ein
  Hintergrund-Timer (Standard 45s) — wird die Änderung nicht **explizit
  bestätigt** (nur möglich, wenn die Seite über die neue Konfiguration noch
  erreichbar ist), rollt die App automatisch auf die gesicherte
  Konfiguration zurück. Verhindert, dass ein Tippfehler bei IP/Gateway den
  Host von der Web-UI/SSH abschneidet.
- **DHCP-Server wird vorsorglich gestoppt UND deaktiviert**: eine vorher
  passende Range (siehe DHCP-Konfiguration oben) kann nach einer
  IP-/Subnetzänderung plötzlich zum falschen Netz gehören. Läuft Kea beim
  Anwenden oder beim Auto-Rollback, wird der Dienst deshalb gestoppt und
  deaktiviert (nicht automatisch neu gestartet — die Range muss der Admin
  aktiv auf der DHCP-Seite prüfen und den Dienst dort bewusst wieder
  starten). Das zusätzliche Deaktivieren verhindert, dass ein Host-Neustart
  vor dieser Prüfung den Dienst trotzdem automatisch mit der ggf. falschen
  Range wieder hochfährt, statt dass die App mit einer möglicherweise
  falschen Konfiguration weiterläuft oder blind rät.
- Live gegen eine echte Ubuntu-24.04-VM verifiziert (Standard-Installation,
  Netz per DHCP über netplan/systemd-networkd bezogen) — sowohl die
  Erkennung/Statusanzeige als auch der komplette Anwenden-Pfad inkl. der
  eigenen Override-Datei. NetworkManager und dhcpcd sind zusätzlich per
  gemockten `subprocess.run`/Dateizugriffen verifiziert (inkl. eines echten
  `dhcpcd.conf`-Rewrites und eines vollständigen Anwenden-dann-Auto-
  Rollback-Durchlaufs), da in der WSL-Entwicklungsumgebung keins von beiden
  verfügbar ist.

## Änderungslog

Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von
Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen wird in der Tabelle
`audit_log` protokolliert (Zeitpunkt, Benutzer, Aktion, Ziel, Details) —
einsehbar unter **Logs → Änderungen** (Recht `logs_activity.view`). Zusätzlich merken
sich Geräte und Switche direkt am Datensatz (`last_modified_by`,
`last_modified_at`), wer sie zuletzt geändert hat, damit man das nicht erst
im Log nachschlagen muss. Jeder Eintrag wird zusätzlich in eine eigene Datei
gespiegelt (siehe [Logs & Log-Rotation](#logs--log-rotation)) — die
`audit_log`-Tabelle bleibt dabei die Quelle für diese durchsuchbare/
sortierbare Seite.

PoE-Neustarts werden **bewusst nicht** im Änderungslog erfasst — die stehen
bereits im Live-Log von `poe.sh`. Eine Ausnahme ist die Änderung des
Prüfintervalls selbst (eine Einstellungsänderung, kein Neustart), die wird
mitprotokolliert.

## Logs & Log-Rotation

Vier getrennte, eigene Log-Dateien statt allem in `/var/log` vermischt oder
nur im journalctl-Output — jede mit derselben, unter **Systemeinstellungen →
Logs** einstellbaren Rotation (Intervall täglich/wöchentlich/monatlich,
Standard wöchentlich; Aufbewahrung als Anzahl Rotationen, Standard 4 ≈ 1
Monat Historie):

| Log | Datei | Einsehbar unter |
|-----|-------|------------------|
| 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 `tesm-check-restart.timer`
  automatisch ausgelösten) Dienst-Neustart ein neues `rpi-<timestamp>.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/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
  ... .lock` abgelehnt (live so reproduziert). Statt das AppArmor-Profil
  eines Systemdienstes aufzuweichen, nutzt die App Keas eigenen, bereits
  erlaubten Pfad; die logrotate-Konfiguration deckt trotzdem beide
  Verzeichnisse mit derselben Regel ab.
- **`copytruncate` statt Signal-/Neustart-basierter Rotation**: keiner der
  drei schreibenden Prozesse (`poe.sh`, Flask, `kea-dhcp4-server`)
  implementiert ein Log-Reopen-Signal (SIGHUP o.ä.) — `copytruncate`
  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 `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 `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
  Kachel) und Netzwerkeinstellungen stehen als thematisch zusammengehörige
  Host-Konfiguration nebeneinander, gefolgt von Logs und Prüfintervall.

Live verifiziert: alle vier Log-Dateien werden geschrieben, die
logrotate-Konfiguration korrekt generiert und bei einer Änderung der
Einstellung neu geschrieben, ein Zeitzonenwechsel (UTC → Europe/Berlin)
wirkte sofort — die Log-Zeile, die den Wechsel selbst protokolliert, trug
bereits den neuen Zeitstempel.

## Import/Export

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 oder ein
selektives Backup einzelner Kategorien.

**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

Das Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar führt zu
**„Mein Konto“** (`/account`): Vor-/Nachname ändern, ein Profilbild
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. Die Navbar-Reihenfolge (für Admins)
liegt dagegen unter Systemeinstellungen, siehe
[Navigation anpassen](#navigation-anpassen).

## Warnung bei ungespeicherten Änderungen

Generischer, app-weiter Mechanismus (kein Opt-in pro Seite nötig): erkennt
automatisch, ob ein Formular mit echten Eingabefeldern (nicht nur
versteckten Aktions-Feldern wie bei Löschen-/Toggle-Buttons) seit dem Laden
der Seite verändert wurde, und fragt beim Verlassen (Klick auf einen Link,
z.B. in der Navigation) per eigenem **Ja/Nein-Dialog**, ob zuerst gespeichert
werden soll, statt Änderungen stillschweigend zu verwerfen:

- **„Ja, speichern“**: sendet das betroffene Formular ganz normal ab (echter
  POST, kein AJAX-Nachbau) und merkt sich das eigentliche Navigationsziel in
  `sessionStorage`, um nach dem Speichern automatisch dorthin
  weiterzuleiten — der Link muss dafür nicht ein zweites Mal angeklickt
  werden.
- **„Nein, verwerfen“**: verwirft die Änderungen und wechselt direkt zur
  Zielseite.
- Klickt man stattdessen auf das eigene „Speichern“/„Anlegen“ der Seite
  direkt (ohne über einen Link zu einer anderen Seite zu gehen), passiert
  **nichts zusätzlich** — kein Dialog, kein Browser-Popup, es wird einfach
  ganz normal gespeichert.
- Für Browser-eigene Navigation (Tab schließen, Neuladen, Adresszeile) gibt
  es zusätzlich `beforeunload` — dort erlaubt der Browser aus
  Sicherheitsgründen nur eine generische Warnung ohne eigene Buttons, kein
  Ja/Nein/Speichern-Dialog. Sie wird bewusst unterdrückt, sobald die Seite
  ohnehin schon bewusst verlassen wird (eigenes Speichern oder der
  Ja/Nein-Dialog) — sie soll ausschließlich bei tatsächlich ungefragter
  Navigation erscheinen, nicht zusätzlich bei einem ganz normalen Speichern
  (live als genau dieses Doppel-Popup reproduziert und behoben).
- Ausnahmen über `data-no-unsaved-guard` auf Formular oder Link (z.B. das
  Login-Formular — Zugangsdaten eintippen ist keine "Einstellung").

Live per Playwright auf mehreren Seiten (Systemeinstellungen, DHCP-Options,
Gruppen anlegen) verifiziert: eigener Dialog erscheint nur bei tatsächlich
ungespeicherten Änderungen, „Ja“ speichert und leitet danach automatisch
zum ursprünglichen Ziel weiter, „Nein“ verwirft und wechselt direkt, ein
normales Speichern über den Seiten-eigenen Button zeigt weder eigenen noch
Browser-Dialog.

Nebenbei wurde ein generisches Cache-Busting für `app.js`/`style.css`
ergänzt (`asset_url()`-Helper, hängt die Änderungszeit der Datei als
`?v=...` an) — vorher konnte ein bereits gecachtes, veraltetes `app.js` im
Browser nach einem Update scheinbar "nicht funktionierende" neue Funktionen
verursachen, obwohl der Code auf dem Server längst aktuell war.

## Navigation anpassen

Die Sidebar besteht aus zwei aufklappbaren Gruppen und mehreren flachen
Top-Level-Punkten dazwischen:

- **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 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

Da das Projekt ausschließlich für Linux gebaut ist, empfiehlt sich die
Entwicklung direkt unter WSL2 (Ubuntu), auch wenn der Editor unter Windows
läuft — das Projektverzeichnis liegt dabei ganz normal unter
`C:\...\aruba-poe-modern` und wird aus WSL über `/mnt/c/...` erreicht:

```bash
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/<dich>/aruba-poe-modern/srv/tesm
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
./venv/bin/python create_db.py
./venv/bin/python create_admin.py
./venv/bin/python app.py         # http://localhost:5000 — via WSL2-Portweiterleitung
                                  # auch aus dem Windows-Browser erreichbar
```

**Hinweis zu `tesm-check.service`:** Der „Speichern & Service neustarten“-Button
unter Systemeinstellungen sowie das „Jetzt prüfen“-Icon funktionieren nur,
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/
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/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.

## Lokal starten (Entwicklung)

```bash
cd srv/tesm
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

python create_db.py             # legt sqlite.db + Tabellen an
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/tesm`)

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/tesm/srv/tesm/…`):

```bash
sudo git clone <dieses-repo> /opt/aruba-poe-modern-src
cd /opt/aruba-poe-modern-src
sudo bash install.sh
sudo /srv/tesm/venv/bin/python3 /srv/tesm/create_admin.py
```

Nach der Installation liegt die laufende App vollständig unter
**`/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.

`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
(`tesm.service`, `tesm-check.service`, `tesm-check-restart.timer`) und
konfiguriert nginx als Reverse-Proxy auf Port 80.

### Relevante Umgebungsvariablen (optional)

| Variable              | Zweck                                   | Default                              |
|-----------------------|------------------------------------------|---------------------------------------|
| `TESM_DB_PATH`         | Pfad zur SQLite-DB                        | `<app-dir>/sqlite.db`                |
| `POE_SCRIPT`          | Pfad zum PoE-Restart-Skript                | `/usr/local/bin/custom/poe.sh`       |
| `TESM_LOG_DIR`        | Verzeichnis für `live.log`/`changes.log`/`app.log` | `/var/log/tesm`               |
| `TESM_FERNET_KEY`      | Pfad zum Fernet-Schlüssel                  | `<app-dir>/fernet.key` (auto-erzeugt)|
| `TESM_KNOWN_HOSTS`     | Pfad zur SSH-known_hosts-Datei des Terminals| `<app-dir>/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                         |

### Frische Neuinstallation aus einem Release-Paket (`update.sh`)

Für einen Host, dessen `/srv/tesm` bislang ad-hoc gepflegt wurde (z.B. per
manuellem Datei-Upload statt über `install.sh`), für das ursprüngliche,
noch ältere reine Bash-Setup ganz ohne Datenbank/Web-UI (`rpi-check.service`
+ statische `ips.list`), oder um einen sauberen Stand aus einem Gitea-
Release herzustellen, gibt es zusätzlich zum `git pull` + `install.sh`-
Update oben `update.sh` — es lädt ein fertig gepacktes **Gitea-Release**
direkt per HTTP herunter, **kein `git clone`/Git auf dem Zielsystem nötig**:

```bash
sudo ./update.sh
# oder eine bestimmte Version statt "latest":
sudo TESM_RELEASE_TAG=v1.0.0 ./update.sh
# für ein privates Repo mit Zugriffstoken:
sudo TESM_GITEA_USER=alientim TESM_GITEA_TOKEN=<token> ./update.sh
```

Das Skript erkennt und ersetzt dabei jede der drei historischen Ausbaustufen
dieses Hosts, egal welche gerade aktiv ist:

1. das ursprüngliche, reine Bash-Setup aus dem separaten Repo
   [WiS/Aruba-PoE](https://gitea.int.eertmoed.net/WiS/Aruba-PoE)
   (`rpi-check.service`/`rpi-check-restart.*`, statische `ips.list`, keine
   Datenbank/Web-UI),
2. die spätere Flask-App „PoE Manager" unter `/srv/poe_manager`
   (`poe_web.service`, der Vorgänger von TESM in diesem Repo vor der
   Umbenennung),
3. eine bereits während der Entwicklung ad-hoc gepflegte TESM-Instanz unter
   `/srv/tesm`.

Es stoppt/deaktiviert alle davon vorhandenen Dienste, verschiebt jedes
vorhandene `/srv/tesm` bzw. `/srv/poe_manager` **ohne es zu löschen** nach
`<Verzeichnis>-backup-<Zeitstempel>` (bleibt inkl. `sqlite.db` als
Rückfalloption und Datenquelle erhalten), lädt dann das Release-Paket
(`tesm-<tag>.tar.gz`, standardmäßig `latest`) von den Gitea-Releases dieses
Repos herunter und führt darin `install.sh` aus.

**Ergebnis ist eine komplett frische Installation** — neue, leere Datenbank,
keine Geräte/Switche/Zugangsdaten/Benutzer/Einstellungen aus der alten
Instanz. Vor dem Ausführen deshalb unbedingt unter **Einstellungen →
Im-/Export** ein Backup der alten Instanz exportieren; danach entweder alles
manuell neu einrichten oder dieses Backup in die neue Installation
importieren (siehe [Import/Export](#importexport)). Das Skript weist sowohl
vor dem Start als auch am Ende explizit darauf hin.

Ein neues Release paketieren (als Maintainer, nicht auf dem Zielsystem):

```bash
git archive --format=tar.gz --prefix=tesm/ -o tesm-vX.Y.Z.tar.gz vX.Y.Z
# als Release-Asset zum passenden Tag "vX.Y.Z" UND zum beweglichen Tag
# "latest" (zeigt immer auf den neuesten Stand) auf Gitea hochladen.
```

## Browser-SSH-Terminal (Switch-Verbindungstest)

„Verbindung testen“ im Switch-Modal öffnet eine WebSocket-Verbindung zu
`/ws/ssh_terminal`. Serverseitig baut **paramiko** eine echte SSH-Sitzung
zum Switch auf (inkl. interaktiver Host-Key-Bestätigung beim Erstkontakt,
danach dauerhaft in `known_hosts` gemerkt) und reicht eine interaktive Shell
1:1 an ein xterm.js-Terminal im Browser durch. Die Zugangsdaten aus dem
Formular werden direkt für die SSH-Authentifizierung verwendet (nicht als
Tastatureingabe in die Shell getippt) — Erfolg/Fehlschlag der Anmeldung wird
sofort und eindeutig im Terminal angezeigt. Ein Klick auf „Passwort
einfügen“ sendet den aktuell im Formular eingetragenen Wert zusätzlich als
Tastatureingabe (z.B. für ein `sudo`-Passwort in der geöffneten Sitzung).

Voraussetzungen:
- Läuft auf **jedem Betriebssystem** (paramiko ist reines Python) — die
  SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, nie vom
  Browser/Client.
- Nur für eingeloggte **Admins** erreichbar.
- Der Flask-Entwicklungsserver muss mit `threaded=True` laufen (ist bereits
  so konfiguriert) — für einen echten Produktivbetrieb empfiehlt sich
  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/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 `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, `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/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
install.sh               Produktiv-Installer
```
