Compare commits

..
40 Commits
Author SHA1 Message Date
alientimandClaude Opus 5 64e25bb9ae Nur noch, was zum Installieren und Betreiben von TESM noetig ist
Entfernt: Tests, Architekturdokumentation und die Serverhaelfte des
Lizenzprotokolls. Beides liegt im Entwicklungsrepository alientim/TESM-DEV.

Das Lizenzprotokoll ist geteilt. TESM braucht nur die gemeinsame Haelfte
(tesm-licensing): Lizenzen verifizieren, Status bewerten, Anfragen stellen,
Antworten pruefen. Ausstellen, erneuern, Antworten signieren und
Schluesselerzeugung liegen jetzt in tesm-licensing-server und damit
ausschliesslich beim Lizenzserver -- ein Client soll den Code zum Ausstellen
nicht einmal mitbringen. Nachgeprueft: kein Modul von TESM oder tesm-core
importiert eine der verschobenen Funktionen.

install.sh installiert entsprechend je Anwendung nur die noetigen Pakete und
bricht mit klarer Meldung ab, wenn die verlangte Anwendung nicht im Baum liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:37:11 +02:00
alientimandClaude Opus 5 7354ff352b TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen
Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich
jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:00:06 +02:00
alientimandClaude Sonnet 5 a193b9d6e9 licensing.py: optionales report-Feld fuer Sub-Lizenzserver-Meldungen
Rein synchronisierende Aenderung (harte Invariante: licensing.py bleibt
byte-identisch in beiden Repos). build_client_request() akzeptiert
jetzt einen optionalen report-Parameter, der bei Heartbeats einer
role='license_server'-Lizenz den eigenen Kunden-/Ticket-/Lizenzstand
signiert mitschickt -- Kern des neuen Sub-Lizenzserver-Reportings auf
dem Lizenzserver (siehe dortiges Release). Bestehende Aufrufer (TESM-
Client-Heartbeats, activate/deactivate) uebergeben report nie -- das
Feld fehlt dann wie bisher komplett in der Payload, keine
Verhaltensaenderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:43:17 +02:00
alientimandClaude Sonnet 5 56974a4308 licensing.py: neues role-Feld fuer Lizenzserver-Lizenzen (Master-Feature)
Rein synchronisierende Aenderung, um die harte Invariante 'licensing.py
byte-identisch in beiden Repos' zu wahren -- der TESM-Client selbst
nutzt role aktuell nicht aktiv (er ist immer 'customer'), aber das
Sub-Lizenzserver-Feature auf dem Lizenzserver (siehe dortiges Release)
braucht die Erweiterung in derselben Datei.

- LICENSE_ROLES = ('master', 'license_server', 'customer'), neues
  role-Feld in der signierten Lizenzdatei-Payload (issue_license()).
- license_status() liefert role mit, faellt bei fehlendem Feld
  (aeltere Lizenzdateien) auf 'customer' zurueck.
- Verifiziert: role ist Teil der signierten Payload -- eine nachtraeg-
  liche Manipulation der Rolle macht die Signatur ungueltig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:06:16 +02:00
alientimandClaude Sonnet 5 b69d0745dd Eigenes Recht fuer die Lizenzseite (settings_license.view/.edit)
Die Lizenzseite (Einstellungen -> Lizenz) hatte im Rechtebaum keinen
eigenen Eintrag -- sie lief komplett ueber settings_system.view/.edit
mit. Damit konnte 'Lizenz verwalten' nicht unabhaengig von 'Systemein-
stellungen verwalten' vergeben/eingeschraenkt werden (Hostname/Zeitzone/
Pruefintervall/Log-Rotation/Papierkorb-Frist/Nav-Reihenfolge).

- Neues Rechtepaar settings_license.view/settings_license.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_view_settings_license.
- Nav-Sichtbarkeit und alle /settings/license*-Routen auf das neue
  Recht umgestellt (view-Route: can_view_settings_license; alle
  mutierenden Routen -upload/-activate(/confirm)/-deactivate(/confirm):
  settings_license.edit). Das "auch ohne aktive Lizenz sichtbar
  bleiben"-Verhalten bleibt unveraendert (andere Pruefung, license_active()).
- settings_license.html: can_edit auf settings_license.edit umgestellt.
- Migration (_migrated_settings_license_permission_v1, einmalig
  geguarded): jede Gruppe mit settings_system.view/.edit bekommt
  automatisch settings_license.view/.edit dazu -- niemand verliert beim
  Update stillschweigend Zugriff, den er vorher implizit hatte.
- Getestet gegen isolierte Kopie der Dev-DB: Backfill korrekt (inkl.
  Gruppe mit nur .view, die korrekt KEIN .edit bekommt), idempotent bei
  zweitem Lauf, has_permission()/_nav_key_visible() liefern die
  erwarteten Werte je Testgruppe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:48:27 +02:00
alientimandClaude Sonnet 5 7ab6358b9f Anbieter-Logo auf der Lizenz-Seite anzeigen (v1.2.5)
Der Lizenzserver erlaubt bereits laenger das Hochladen eines Anbieter-
Logos (Einstellungen -> Lizenz -> Anbieter & Lizenzserver) und bettet es
seit jeher als vendor.logo_base64 in jede ausgestellte Lizenzdatei ein
(siehe _get_vendor_info() im Lizenzserver) -- die Kundeninstanz zeigte
auf ihrer eigenen Lizenz-Seite zwar bereits Firma/Telefon/E-Mail/
Anschrift des Anbieters an (dieselbe eingebettete vendor-Struktur), aber
nie das Logo selbst. Reine Anzeige-Ergaenzung, keine Aenderung am
Lizenzformat oder an der Uebertragung noetig -- das Logo steckt in jeder
schon ausgestellten Lizenz bereits drin, muss also fuer bestehende
Kunden nicht neu ausgestellt werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:14:47 +02:00
alientimandClaude Sonnet 5 f02db4c6b5 Meldet Hostname bei Aktivierung/Heartbeat an den Lizenzserver (v1.2.4)
Der neue Master-Lizenzserver (tesm-license v1.2.0) zeigt auf seinem
Dashboard jetzt eine Kachel-Uebersicht der aktiven Kundeninstanzen inkl.
Hostname -- dafuer braucht er den Hostnamen jeder Instanz, den der Master
bisher gar nicht kannte (nur den gehashten Fingerprint, siehe
licensing.system_fingerprint()).

build_client_request() (licensing.py, identisch auf Client UND Master
gepflegt) sendet jetzt zusaetzlich socket.gethostname() mit -- rein
informativ, fliesst in keine Sicherheitsentscheidung ein (das bleibt
Sache des Fingerprints), wird aber wie jedes andere Feld mitsigniert.
Abwaerts-kompatibel in beide Richtungen: ein alter Client ohne dieses
Feld liefert dem (bereits aktualisierten) Master einfach '', der das
dann per COALESCE(NULLIF(...,''), hostname) NICHT als Ueberschreiben
eines schon bekannten Hostnamens wertet; ein neuer Client gegen einen
noch nicht aktualisierten Master aendert an dessen Verhalten nichts
(zusaetzliches Feld wird dort schlicht ignoriert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:00:24 +02:00
alientimandClaude Sonnet 5 c7ad0d3d49 KRITISCH: install.sh loeschte license.json bei jedem Update (v1.2.3)
Live auf POETEST reproduziert: rsync --delete im App-Verzeichnis-Schritt
schliesst venv/sqlite.db/fernet.key/secret.key/known_hosts explizit aus,
vergisst dabei aber license.json (in Phase 1 neu hinzugekommen, als
Ausschluss schlicht uebersehen). Da license.json nur auf dem Zielsystem
existiert (nie im Release-Paket enthalten, korrekt .gitignored), loescht
--delete sie bei JEDEM 'sudo ./update.sh' auf einer bereits aktivierten
Instanz kommentarlos mit -- die Lizenz war danach weg, ohne dass update.sh
oder install.sh irgendeinen Fehler gemeldet haetten.

Der Master-seitige Installer (install.sh im tesm-license-Repo) hatte
diesen Ausschluss von Anfang an korrekt gesetzt -- betraf ausschliesslich
TESM-Kundeninstanzen.

Fix: license.json zur rsync --exclude-Liste hinzugefuegt, analog zu den
anderen laufzeit-generierten Secrets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:08:02 +02:00
alientimandClaude Sonnet 5 f09aaae8a9 Lizenz: sofortiger erster Heartbeat direkt nach Aktivierung (v1.2.2)
Root Cause fuer 'Letzter Heartbeat: noch nie' trotz funktionierender
Verbindung (live auf POETEST untersucht und bestaetigt): der Heartbeat-
Hintergrund-Thread feuert einmal SOFORT beim Start von tesm.service und
danach erst wieder alle 6h. Im realistischen Ablauf (Dienst starten ->
danach erst Lizenz hochladen+aktivieren) faellt genau dieser erste,
sofortige Versuch immer ins Leere (noch keine Lizenz vorhanden, daher
stiller Abbruch ohne Fehlermeldung) -- der naechste ECHTE Versuch waere
erst bis zu 6h spaeter gekommen. Manuell nachgestellt: ein direkter Aufruf
von _send_heartbeat() nach erfolgter Aktivierung funktioniert einwandfrei
(Master verzeichnet den Heartbeat sofort) -- das Protokoll selbst war nie
kaputt, nur die Beobachtbarkeit unmittelbar nach der Aktivierung.

Fix: beide Aktivierungs-Erfolgspfade (online + Offline-Code-Bestaetigung)
loesen jetzt selbst sofort einen ersten Heartbeat aus, statt auf den
naechsten planmaessigen 6h-Durchlauf zu warten. Ein Fehlschlag hier (z.B.
Kunde tatsaechlich offline) wird wie jeder andere Heartbeat-Fehlschlag
behandelt -- rein informativ, schaltet nichts ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:00:15 +02:00
alientimandClaude Sonnet 5 36816c57ef Version 1.2.1: update.sh-Fix fuer 'latest'-Aufloesung
Kein App-Code geaendert (app.py unveraendert) -- reiner Versions-/Release-
Bump, damit 'latest' auch als heruntergeladenes Paket das bereits
korrigierte update.sh enthaelt (siehe vorheriger Commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:49:40 +02:00
alientimandClaude Sonnet 5 4a77a3ba32 update.sh: 'latest' zuverlaessig ueber die Gitea-API aufloesen
Bug (live gefunden): /releases/download/latest/tesm-latest.tar.gz liefert
HTTP 200 -- aber nicht das erwartete Release-Asset, sondern (weil kein
Asset exakt so heisst) Giteas automatisch generiertes Quellcode-Archiv
des AKTUELLEN Default-Branch-Stands, ohne jede Fehlermeldung. Ein
einfaches 'sudo ./update.sh' ohne explizites TESM_RELEASE_TAG haette also
lautlos einen unversionierten Zwischenstand installiert statt des
tatsaechlich neuesten Releases.

Fix: 'latest' wird jetzt zuerst per 'GET /releases/latest' (Gitea-API,
das funktioniert bereits zuverlaessig) auf den echten Tag-Namen aufgeloest,
BEVOR die Download-URL gebaut wird -- der eigentliche Download zielt damit
immer auf ein konkretes, korrekt benanntes Release-Asset. Zusaetzliche
Absicherung: nach dem Entpacken wird explizit geprueft, dass install.sh
im Paket vorhanden ist, statt erst beim Aufruf mit einer kryptischen
'No such file'-Meldung zu scheitern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:47:50 +02:00
alientimandClaude Sonnet 5 7a1f2b164e Master-Lizenzserver in eigenes Repo ausgelagert (tesm-license)
srv/tesm-license/, install-license.sh, update-license.sh sowie die
zugehoerigen systemd-/nginx-Vorlagen leben ab sofort im eigenstaendigen
Repo https://gitea.int.eertmoed.net/alientim/tesm-license (komplette
bisherige Historie per git-filter-repo mit uebernommen, dort als v1.0.0
getaggt/released). Grund: eigener Release-/Versionszyklus getrennt von
TESM selbst, sauberere Trennung zwischen Kundenprodukt und interner
Lizenzierungs-Infrastruktur.

Betrifft nur dieses Repo -- die bereits laufende Installation auf
192.168.82.51 (TESM-Lizenzserver) ist unveraendert, zukuenftige Updates
dort laufen ueber das neue Repo/update.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:40:21 +02:00
alientimandClaude Sonnet 5 682e9b4420 Lizenzserver-Deployment: install-license.sh/update-license.sh + systemd/nginx-Vorlagen
Fork von install.sh/update.sh/tesm.service/nginx-Site (TESM selbst),
beschraenkt auf /srv/tesm-license -- deployt ausschliesslich den in Phase 2
gebauten Master-Lizenzserver, laesst eine evtl. auf demselben Host
vorhandene TESM-Installation komplett unberuehrt (eigener Pfad-/Env-Var-
Namespace, siehe app.py). Gleiches Backup+Health-Check+Rueckroll-
Sicherheitsnetz wie bei TESMs eigenem Installer.

Teil von Phase 3 (Infrastruktur), siehe Plan toasty-twirling-hickey.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:15:02 +02:00
alientimandClaude Sonnet 5 90e07aa310 Master-Lizenzserver Phase 2: neue Schwester-Anwendung srv/tesm-license (v1.0.0)
Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).

Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.

Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
  (master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
  jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
  master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
  Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
  licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
  Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
  eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
  funktionen (_process_activate/_process_deactivate/_process_heartbeat)
  nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
  (Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
  Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
  die eigene Lizenz des Masters (kein externer Super-Master noetig).

Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.

Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:07:30 +02:00
alientimandClaude Sonnet 5 c9ac0bf69b Lizenz-/Modulsystem Phase 1: TESM-seitiges Gating, Aktivierung, Topbar (v1.2.0)
Baut auf licensing.py (Phase 0) auf und schaltet TESM erstmals auf ein
lizenziertes Modulmodell um. Standard-Funktionen (Dashboard, Clients,
Switche, Zugangsdaten, Benutzer, Gruppen, Auditlog, Live-Log, Verlauf,
Import) bleiben immer frei nutzbar.

- license_active()/module_licensed()-Gate-Funktionen + In-Memory-
  Lizenzcache (_load_license, _write_license_file, _delete_license_file),
  Jinja-Globals fuer Templates.
- Vollstaendige Module (DHCP inkl. Kea-Log, Fileshare, Wartung) werden ohne
  passendes Modul in der Lizenz komplett aus der Navigation ausgeblendet
  (_nav_key_visible) UND serverseitig auf Routenebene abgewiesen
  (_require_module) -- ein direkter URL-Aufruf hilft nicht.
- 'Sichtbar, aber ohne Lizenz inaktiv': LDAP- und NGINX-Einstellungen,
  Papierkorb-Bearbeiten, Export sowie das Pruefintervall-Feld (aus dem
  gemeinsamen Systemeinstellungen-Gate herausgeloest, Hostname/Zeitzone/
  Netzwerk/Logs bleiben frei) zeigen ohne Lizenz einen Hinweis statt der
  Aktion.
- Export bleibt als bewusste Ausnahme auch im lizenzlosen Zustand nutzbar,
  wenn dieser durch eine selbst ausgeloeste Deaktivierung (Systemwechsel)
  entstanden ist (license_export_grace).
- Automatischer PoE-Neustart bei Ausfall wird in poe.sh (nicht in Python)
  gegatet -- einmal pro Schleifendurchlauf gelesen, an check_device()
  durchgereicht; der manuelle Neustart-Button (devices.restart) bleibt
  komplett unangetastet.
- Topbar-Lizenzanzeige (Prioritaet: fehlt > abgelaufen > laeuft in <=30
  Tagen ab > Heartbeat seit >=14 Tagen ausgeblieben (rein informativ) >
  alles ok), eigene CSS-Blink-Varianten fuer Rot/Orange (die vorhandene
  pulse-dot-Animation war fest auf Gruen verdrahtet).
- Neue Settings-Unterseite 'Lizenz': Status, Lizenzdatei-Upload, Online-
  Aktivierung/-Deaktivierung mit automatischem Fallback auf einen manuell
  auszutauschenden Code (ein Protokoll, zwei Transportwege, siehe
  licensing.py), Anbieter-Kontaktkarte.
- 6-stuendiger Heartbeat-Hintergrundthread (_license_heartbeat_loop, exakt
  nach dem Vorbild von _audit_archive_loop).

Isoliert auf dem Testsystem (192.168.82.51) verifiziert: alle fuenf
Topbar-/Sichtbarkeitszustaende (keine/gueltige/bald ablaufende/abgelaufene/
heartbeat-stale Lizenz) per Playwright durchgespielt, kompletter Online-
Fallback-auf-Offline-Code-Austausch fuer Aktivierung UND Deaktivierung
gegen einen simulierten Master erfolgreich durchlaufen (inkl. Export-
Grace-Ausnahme nach Deaktivierung).

Bewusst NICHT nach main gemergt/getaggt/ausgerollt: ohne den in Phase 2
noch zu bauenden Master-Lizenzserver kann POETEST noch keine echte Lizenz
beziehen -- ein Rollout jetzt wuerde dort DHCP/Fileshare/Wartung/LDAP/
NGINX/Papierkorb/Export/automatischen Neustart sofort abschalten. Bleibt
auf dev, bis Phase 2 (Master) + Phase 3 (POETEST-Enterprise-Lizenz) so
weit sind, dass beides zusammen ausgerollt werden kann.

Teil des Lizenz-/Modulsystems, siehe Plan toasty-twirling-hickey.md
(Phase 1 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:45:35 +02:00
alientimandClaude Sonnet 5 b70ebefb17 Lizenzsystem Phase 0: Ed25519-Kryptographie, Aktivierungs-/Heartbeat-Protokoll (licensing.py)
Neues gemeinsames Modul fuer Client (TESM) und kuenftigen Master-Lizenzserver:
- Zwei-Ebenen-Ed25519-Schluesselhierarchie (Master-Signaturschluessel +
  Pro-Lizenz-Schluesselpaar), kanonische JSON-Signierung
- issue_license/verify_license_file/license_status (inkl. 30-Tage-Gnadenfrist,
  30-Tage-Vorwarnung, Modul-Set)
- Ein Protokoll fuer Online- (API) und Offline-Aktivierung (Code-Copy-Paste):
  build_client_request/verify_client_request,
  build_master_response/verify_master_response, encode_code/decode_code
- system_fingerprint (Systembindung), heartbeat_stale_warning (14 Tage,
  rein informativ)

Isoliert getestet (Signatur-Rundlauf, Manipulationserkennung, Ablauf-/
Gnadenfrist-Datumslogik, Online- und Offline-Aktivierung/Deaktivierung inkl.
Fingerprint-Bindungspruefung und Reaktivierung nach Deaktivierung,
Eingabevalidierung) -- 41/41 Checks erfolgreich.

Teil von Phase 0 des Lizenz-/Modulsystems, siehe Plan
toasty-twirling-hickey.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:42:32 +02:00
alientimandClaude Sonnet 5 d90c27b8f8 LDAP-Export: Sperrstatus + individuelle Gruppenzuweisung von AD-Nutzern (weiterhin v1.1.11)
_export_users()/_import_users() schliessen AD/LDAP-Konten bewusst komplett
aus (sie werden ueber Active Directory verwaltet). Das hatte aber eine
Luecke: weder eine manuelle Sperrung/Entsperrung noch eine manuell (per
"Gruppe zuweisen") vergebene individuelle Gruppenmitgliedschaft eines
AD-Benutzers wurden bisher irgendwo gesichert -- bei einem Umzug auf ein
neues System (der eigentliche Zweck dieser Seite) waere ein zuvor
gesperrtes AD-Konto dort wieder entsperrt gewesen, und individuell
vergebene Rechte waeren beim naechsten Login des Nutzers verloren
gegangen.

Neu in der "ldap"-Kategorie: ad_user_states exportiert fuer jeden bereits
bekannten AD-Benutzer Sperrstatus + aktuelle Gruppenmitgliedschaft (inkl.
Admin als Sentinel, analog den bestehenden AD-Gruppenzuordnungen). Beim
Import wird ein bereits vorhandenes AD-Konto entsprechend aktualisiert;
existiert es auf dem Zielsystem noch nicht (Nutzer hat sich dort noch nie
eingeloggt), wird ein Platzhalter-Konto mit auth_source='ldap' und
korrektem Sperrstatus VORAB angelegt -- der Login-Pfad prueft is_locked
bereits vor der eigentlichen AD-Authentifizierung, ein vorab gesperrtes
Konto bleibt also gesperrt. Gruppenzuweisungen sind rein additiv
(insert or ignore), die naechste automatische AD-Gruppenzuordnung beim
Login nimmt nichts davon wieder weg.

Ausserdem:
- Die pauschalen "Was exportieren?"/"Was importieren?"-Hinweise entfernt
  (waren durch die praeziseren Kategorie-Hinweise bei Benutzer/LDAP/
  Zugangsdaten/NGINX/Auditlog laengst ueberholt und teils sachlich
  veraltet).
- Kategorie-Hinweise jetzt auch in der Import-Vorschau sichtbar (vorher
  nur beim Export).
- "Nur Admin"-Kennzeichnung aus beiden Kategorie-Listen entfernt (rein
  optisch, die serverseitige Rechtepruefung bleibt unveraendert).

Verifiziert per isoliertem Roundtrip-Test: gesperrter AD-Nutzer bleibt
nach Reset+Import gesperrt, manuell zugewiesene Gruppe eines AD-Nutzers
uebersteht Loeschen+Import, und ein komplett neuer (nie eingeloggter)
AD-Nutzer wird korrekt mit Sperrstatus/Gruppen/Admin-Flag vorangelegt --
inkl. Bestaetigung, dass der echte Login-Sperr-Check dieses vorab
angelegte Konto tatsaechlich blockiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:46:10 +02:00
alientimandClaude Sonnet 5 8577cd24cd Export-Kategorien: kuerzere Namen, Auditlog/Benutzer-Hinweise als Icon (weiterhin v1.1.11)
Sichtbare Checkbox-Namen im Export-Formular verkuerzt (nur die Kachel-
Ansicht betroffen, Funktionalitaet/Schluessel unveraendert):
Custom-Gruppen -> Gruppen, LDAP/AD-Einstellungen -> LDAP/AD,
DHCP-Einstellungen -> DHCP, NGINX-Einstellungen -> NGINX,
Benutzer (lokal) -> Benutzer.

Neue Hinweis-Icons (Klammerzusaetze wandern konsistent zu den bereits
bestehenden bei Zugangsdaten/NGINX ins "i"-Icon statt in den Namen):
- Auditlog: Hinweis, dass nur die aktuell in der DB stehenden Eintraege
  exportiert werden (bereits archivierte Tage liegen als eigene Dateien
  vor).
- Benutzer: Hinweis, dass AD/LDAP-Benutzer NICHT gesichert werden (nur
  lokale Konten sind Teil des Exports -- AD-Konten legen sich beim
  naechsten Login ohnehin automatisch neu an).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:35:49 +02:00
alientimandClaude Sonnet 5 877903bd77 NGINX-Export inkl. Zertifikat/Schluessel, Kategorie-Hinweise als Icon (weiterhin v1.1.11)
- NGINX-Export nimmt jetzt zusaetzlich das aktuell hinterlegte TLS-
  Zertifikat und den privaten Schluessel selbst mit (Base64, falls
  vorhanden) -- der gesamte Export ist ohnehin passphrasenverschluesselt,
  genau wie andere Geheimnisse (Zugangsdaten-Passwoerter, LDAP-Bind-
  Passwort) bereits im Klartext darin landen. Bewusst weiterhin
  AUSGESCHLOSSEN bleibt jeglicher Let's-Encrypt-/certbot-Zustand
  (ssl_source, ACME-Konto, Renewal-Timer) -- an genau diesen Host/diese
  Domain-Registrierung gebunden, kann nicht mitziehen. Ein Import setzt
  ssl_source deshalb immer auf "upload", auch wenn urspruenglich per Let's
  Encrypt ausgestellt.
- Die erklaerenden Klammerzusaetze bei "Zugangsdaten" (know_hosts-Hinweis)
  und "NGINX-Einstellungen" (Zertifikat/Schluessel-Hinweis) sind jetzt ein
  "i"-Hinweis-Icon statt Fliesstext im Label, passend zum bestehenden
  Hover-Tooltip-/Klick-Modal-Muster (siehe v1.1.10).

Verifiziert per isoliertem Test mit einem echten (selbstsignierten)
Test-Zertifikat: Export->Import-Roundtrip liefert bitidentische Cert-/
Key-Dateien mit korrekten Berechtigungen (0600 Schluessel, 0644
Zertifikat) auf einem eigenen Zielpfad, sowie live per Browser gegen
POETEST (Hover zeigt korrekten Tooltip-Text, 2 Icons im Export-Formular).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:33:31 +02:00
alientimandClaude Sonnet 5 2b05d012ee Im-/Export: fehlende Felder ergaenzt, NGINX neu, known_hosts (v1.1.11)
Vollstaendiger Audit aller Export-/Import-Kategorien gegen das tatsaechliche
DB-Schema (7 parallele Pruef-Agents je Kategorie) ergab mehrere Luecken --
alle behoben:

- Auditlog-Export (v1.1.10-Nacharbeit): kein Deckel mehr bei 500 Zeilen,
  exportiert wird das komplette aktuell in der DB stehende Auditlog
  (bereits archivierte Tage liegen ohnehin als eigene Dateien vor).
- Clients: ssh_port und Zugangsdaten-Zuordnung (als portabler
  credential_name statt roher ID) fehlten komplett im Export.
- Switche: ssh_port fehlte komplett im Export.
- Zugangsdaten: category (z.B. "Linux-Client" fuer Wartung) fehlte.
- Custom-Gruppen: is_default fehlte (war beim Import zusaetzlich hart auf 0
  gesetzt statt aus der Importdatei uebernommen).
- LDAP-Gruppenzuordnungen: app_group_id wurde als ROHE lokale Datenbank-ID
  exportiert -- auf einem frisch installierten Zielsystem (der eigentliche
  Zweck dieser Funktion, siehe Seiten-Untertitel "Umzug auf eine neue
  Umgebung") haben Gruppen dort andere IDs, die Zuordnung waere kaputt oder
  auf die falsche Gruppe gezeigt. Jetzt wird der Gruppenname exportiert
  (bzw. "Admin" als Sonderfall) und beim Import auf dem Zielsystem wieder zur
  dortigen ID aufgeloest -- unbekannte Zielgruppen werden übersprungen statt
  eine kaputte Referenz anzulegen.
- ldap_fileshare_mappings (AD-Gruppe -> Freigabe) fehlte im Export/Import
  komplett -- alle Fileshare-Zuordnungen gingen bei jedem Umzug verloren.
- ldap_required_login_group (Login-Beschraenkung auf eine AD-Gruppe) fehlte
  in LDAP_SETTING_KEYS und wurde nie exportiert.
- NGINX-Einstellungen (Domain, Ports, SSL/HSTS-Schalter) hatten bisher gar
  keine Export-/Import-Kategorie. Bewusst AUSGESCHLOSSEN bleiben ssl_source,
  das TLS-Zertifikat und der private Schluessel sowie jeglicher
  Let's-Encrypt-/certbot-Zustand -- das sind host-gebundene reale Dateien
  bzw. eine ACME-Registrierung fuer genau diesen Host, kein portabler
  Einstellungswert.
- known_hosts: sobald Zugangsdaten exportiert werden, wird zusaetzlich der
  Inhalt von known_hosts mitexportiert (jeder per SSH bereits bestaetigte
  Host-Key gehoert inhaltlich zu den Zugangsdaten) und beim Import ergaenzend
  (nicht ueberschreibend) mit Zeilen-Deduplizierung in die eigene
  known_hosts-Datei eingespielt.

avatar_filename (Benutzer) bleibt bewusst vom Export ausgeschlossen -- reiner
Dateiname ohne die zugehoerige Bilddatei waere auf dem Zielsystem nur ein
kaputter Verweis, analog zur bewussten Nichtaufnahme von TLS-Zertifikat/
Schluessel bei NGINX.

Verifiziert per isoliertem Roundtrip-Test gegen eine Kopie der echten
Datenbank (nie die Produktivdaten selbst): Export -> Loeschen -> Re-Import
fuer jede geaenderte Kategorie plus known_hosts-Merge (inkl.
Deduplizierungs-Probe durch zweifachen Import derselben Datei), sowie ein
echter Browser-Exportlauf gegen POETEST zur Bestaetigung der UI/Label-Texte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:27:31 +02:00
alientimandClaude Sonnet 5 0f0fa99e53 Feld-/Funktionserklaerungen als Hover-Tooltip + Klick-Modal (v1.1.10)
Statisch sichtbare Erklaerungstexte (.hint/.field-hint) app-weit auf ein
einheitliches "i"-Icon umgestellt: Hover zeigt den Text neben dem
Mauszeiger, Klick/Tap oeffnet ein wiederverwendetes Modal mit dem vollen
Text. Zentral in app.js (initHintIcons, ensureHintTooltip/-Modal) via
Event-Delegation, kein Aufwand pro Seite. Hover ist bewusst per
matchMedia("(hover: hover) and (pointer: fine)") gated -- auf Touch-
Geraeten faellt automatisch nur Tap-zu-Modal an, da echte
mouseenter/mousemove-Events dort ohnehin nicht zuverlaessig auftreten.

Nicht jede .hint/.field-hint wurde umgestellt -- nach folgenden Kriterien
entschieden:
- Live-Status-Anzeigen (aktuelle Log-Pfade, Netzwerkstatus, Auditlog-
  Schwellenwerte, aktive Reservierungs-/Lease-Zaehler, certbot-Timer-
  Status usw.) bleiben sichtbar, da sie aktuellen Zustand zeigen, keine
  generische Erklaerung.
- Kurze, einzeilige Abschnitts-Untertitel (z.B. "Server, Bind-Konto und
  Suchparameter fuer die Anbindung an AD/LDAP.") bleiben als Orientierung
  direkt unter der Ueberschrift sichtbar.
- Bereits JS-live-aktualisierte Status-Felder (id-tragend, z.B.
  "Fragt live... ab." -> "N Gruppen geladen.") bleiben unangetastet.
- Laengere, mehrsaetzige Abschnitts-Erklaerungen (z.B. AD-Gruppenzu-
  ordnungen, Fileshare-Gruppenzuordnung, Subnetze, DHCP-Options, Wartung)
  sowie so gut wie alle Feld-Erklaerungen wandern ins Icon.

Nebenbei: Karte "Fileshare-Gruppen" in LDAP-Einstellungen zu "Fileshare-
Gruppenzuordnung" umbenannt (passt jetzt zur bereits bestehenden
log_action-Zielbezeichnung in app.py).

Neue gemeinsame Macro-Datei templates/_hint_icon.html, importiert von
15 Templates. Verifiziert: alle 18 betroffenen Seiten laden fehlerfrei
(200, keine JS-Fehler), Icon-Anzahl je Seite geprueft, Hover-Tooltip und
Klick-Modal auf Desktop, Tap-Modal auf simuliertem Mobilgeraet (390x844,
Touch), sowie stichprobenartig bestaetigt dass die bewusst belassenen
Hinweise unveraendert sichtbar bleiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:07:33 +02:00
alientimandClaude Sonnet 5 9b4cf8c2e1 Auditlog: Statistik-Kacheln zeigen geladen/gesamt (weiterhin v1.1.9)
Alle vier Kacheln (Alle, Hinzufuegen, Aenderungen, Loeschungen) zeigen
jetzt "geladen / gesamt" (z.B. "300 / 466"), solange noch nicht alles
geladen ist -- Gesamtzahlen je Kategorie kommen aus einer einzigen
aggregierten SQL-Abfrage (SUM/CASE ueber die bestehenden LIKE-Muster von
category_of), nicht aus einem vollstaendigen Tabellen-Scan. Sobald alles
geladen ist, wird wieder nur die reine Zahl angezeigt (client- wie
serverseitig identische Formatierungslogik).

Dieser Fix wird per Nutzervorgabe unter der GLEICHEN Releasnummer v1.1.9
ausgeliefert (Tag wird bewusst weitergezogen statt neu vergeben, da
v1.1.9 zum Zeitpunkt dieses Commits zwar schon getaggt, aber die
Kachel-Anzeige noch nicht ausgereift war).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:38:30 +02:00
alientimandClaude Sonnet 5 1c0082d3d4 Auditlog: Nachladen aelterer Eintraege in 300er-Schritten (v1.1.9)
Die Auditlog-Seite zeigte bisher unveraenderlich nur die neuesten 500
Zeilen an -- alles Aeltere war komplett unsichtbar (auch wenn es noch
nicht archiviert war, siehe v1.1.6). Jetzt:

- Seitengroesse von 500 auf 300 reduziert (Erstladung UND jeder Nachlade-
  Schritt).
- Neuer Button "Mehr laden (300)" (orange) laedt per AJAX genau den
  naechsten 300er-Block aelterer Eintraege nach (Cursor-Pagination ueber
  die id-Spalte) und haengt ihn ans bestehende tbody an, ohne die Seite
  neu zu laden.
- Neuer Button "Alle laden" ruft denselben Nachlade-Schritt wiederholt
  auf, bis wirklich alles geladen ist (mit Fortschrittsanzeige im
  Button-Text), statt in einer Anfrage die komplette (potenziell sehr
  grosse) Tabelle auf einmal zu rendern.
- Suche/Sortierung/Kategorie-Filter sowie die vier Statistik-Kacheln
  arbeiten transparent ueber ALLE aktuell geladenen Zeilen (nicht nur die
  urspruenglichen 300) -- Zeilenliste fuer die Filterung wird jetzt bei
  jedem Aufruf frisch aus dem DOM gelesen statt einmalig zwischengespeichert.
- Zeilen-Rendering (Aktions-Label/-Icon/-Farbe) in ein gemeinsames Makro
  (_audit_log_macros.html) ausgelagert, das sowohl die Erstladung als auch
  das AJAX-Fragment (_audit_log_rows.html) nutzen -- verhindert Abweichungen
  zwischen beiden Darstellungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:31:48 +02:00
alientimandClaude Sonnet 5 fc4b165d23 Auditlog: Ziel-Spalte wieder dynamisch statt erzwungen einzeilig (v1.1.8)
Das white-space:nowrap aus v1.1.7 sollte nur den Sonderfall des neuen
Auditlog-Export-Eintrags fixen, hat aber als Nebenwirkung die gesamte
Ziel-Spalte in die Breite gezogen: der bereits laenger bestehenden Aktion
data.export gehoert ein bis zu 135 Zeichen langes Ziel (Liste der
exportierten Kategorien), und table-layout:auto bemisst eine Spaltenbreite
anhand des LAENGSTEN unzeilenumbrochenen Inhalts ueber alle (auch gerade
nicht gefilterte) Zeilen hinweg -- nicht nur der sichtbaren.

Der eigentliche Ausloeser (zu langer Wert im Ziel-Feld) ist mit v1.1.7
bereits behoben (Ziel = kurzer Ressourcenname wie "Auditlog", Dateiliste
in Details) und macht das erzwungene nowrap ueberfluessig. Zeilenumbruch
bei den seltenen laengeren Zielen (wie data.export) ist unproblematisch,
genau wie es die Details-Spalte schon immer handhabt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:22:01 +02:00
alientimandClaude Sonnet 5 e81735d5ec Auditlog-Export: Ziel/Details korrekt getrennt (v1.1.7)
Beim Export archivierter Auditlog-Tage (log_action("auditlog.export_delete", ...))
stand faelschlich die komplette Dateiliste im Feld "Ziel" (dadurch wurde die
Spalte in der Auditlog-Tabelle wieder unschoen breit), statt wie bei allen
anderen Aktionen ueblich einen kurzen Ressourcennamen im Ziel und die
ausfuehrliche Beschreibung in Details zu haben. Jetzt: Ziel = "Auditlog",
Details = Anzahl + exakte Dateinamen. Gleiche Korrektur fuer den
automatischen Archivierungslauf selbst (auditlog.archive). "Ziel"-Spalte
zusaetzlich mit white-space:nowrap gegen Umbrueche bei laengeren Werten
abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:19:07 +02:00
alientimandClaude Sonnet 5 2eaefc9e1f Auditlog-Archivierung, Verlauf-RAW mit Zeilennummern, UI-Politur (v1.1.6)
- Auditlog waechst nur bis zu einem Schwellenwert (20.000 Zeilen): danach
  werden die aeltesten VOLLSTAENDIGEN Kalendertage (der laufende Tag wird
  nie mitten am Tag angefasst) tageweise in je eine Datei
  audit-YYYY-MM-DD.log unter /var/log/tesm/audit-archive archiviert und
  aus der DB geloescht, bis wieder 15.000 Zeilen unterschritten sind.
  Laeuft als taeglich pruefender Hintergrund-Thread (gate: TESM_WEB_PROCESS,
  wie beim bestehenden Fileshare-Sweep), zusaetzlich einmalig sofort beim
  Dienststart.
- Archivierte Auditlog-Tage sind unter Verlauf einsehbar (RAW-Ansicht) und
  koennen dort als ein ZIP exportiert werden, was sie danach vom Server
  loescht -- bewusst nur manuell ausgeloest statt automatisch, mit eigenem
  Recht logs_history.edit (neue "E"-Spalte bei Verlauf in der
  Gruppen-Rechtematrix, ergibt sich automatisch aus GROUP_ROW_TYPES).
  Aufbewahrung der Archivdateien selbst ist unbegrenzt; bei knappem
  Speicherplatz (< 1 GiB frei im Log-Verzeichnis) erscheint ein Warnhinweis
  auf der Verlauf-Seite.
- Verlauf -> "Komplettes Log (RAW)": Inhalt jetzt mit Zeilennummern (reine
  CSS-Counter-Loesung, kein Extra-Markup pro Zeile) -- Live-Log-RAW und
  nginx-RAW-Konfiguration bleiben bewusst unveraendert (nur an dieser
  Stelle angefragt).
- Auditlog-Tabelle: Avatar-Spalte entfernt, Spaltenbreiten (Zeitpunkt/
  Benutzer/Aktion/Ziel) schrumpfen jetzt auf den Inhalt statt zu strecken,
  "Ziel" nimmt nur noch so viel Platz wie noetig; Aktion-Pills brechen
  generell nicht mehr um (white-space: nowrap).
- "Änderungen" endgueltig zu "Auditlog" umbenannt (Recht-Label, Kommentare);
  die "Änderungen"-Filterkachel selbst (Bearbeiten-Kategorie) behaelt
  bewusst ihren Namen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:06:33 +02:00
alientimandClaude Sonnet 5 cb8b929c3c Auditlog: Hinzufuegen gruen, Loeschen rot, Bearbeiten orange (v1.1.5)
- Neue Pill-Farbvarianten (.action-pill--create/--delete/--edit) je nach
  Aktions-Art -- "create"/"upload"/"mkdir" gruen, "delete" rot, alles
  andere (bearbeiten/aktivieren/zuweisen/...) orange als Sammelkategorie.
- Fehlende Klartext-Label fuer die neuen fileshare.*-Aktionen ergaenzt
  (upload/mkdir/delete/rename/download) -- vorher stand dort noch der
  rohe Aktionsschluessel wie "fileshare.delete" statt eines lesbaren
  Textes wie bei allen anderen Aktionen.

Live auf POETEST verifiziert: alle drei Farben korrekt (gruen bei
"Datei(en) hochgeladen"/"Gruppe angelegt", rot bei "Datei/Ordner
gelöscht"/"Gruppe gelöscht", orange bei "Als ZIP heruntergeladen"/
"Gruppe bearbeitet").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:20:13 +02:00
alientimandClaude Sonnet 5 af5dfe30b4 Fileshare: Mehrfachauswahl zum Herunterladen (ZIP) und Loeschen (v1.1.4)
- Checkbox-Spalte in der Dateitabelle (inkl. "alle auswaehlen" im Tabellen-
  kopf) -- sobald mindestens ein Eintrag markiert ist, erscheint eine
  Aktionsleiste mit "Herunterladen (ZIP)" und (mit fileshare.edit)
  "Loeschen".
- Herunterladen buendelt die Auswahl serverseitig zu EINEM ZIP statt
  mehrerer einzelner Downloads (vermeidet Browser-Blockaden bei vielen
  gleichzeitigen Downloads) -- neue Route /fileshare/download-multi,
  Ordner werden dabei rekursiv mit aufgenommen (relativer Pfad als
  Archivpfad).
- Loeschen mehrerer Elemente auf einmal ueber eine neue Route
  /fileshare/delete-multi (ein Bestaetigungsdialog fuer die ganze Auswahl),
  mit Sammel-Erfolgsmeldung analog zum Mehrfach-Upload.
- Die Pfadaufloesung+Validierung fuer ein einzelnes Kind-Element wurde aus
  dem bestehenden Einzel-Loeschen in _fileshare_resolve_child() extrahiert
  und von Einzel-Loeschen, Mehrfach-Loeschen UND Mehrfach-Download
  gemeinsam genutzt (kein duplizierter Sicherheitscode).

Live auf POETEST verifiziert: Mehrfachauswahl inkl. "alle auswaehlen",
ZIP-Download zweier Dateien (Inhalt geprueft), ZIP-Download eines Ordners
(rekursiv, korrekter Archivpfad), Mehrfach-Loeschen mit Sammelmeldung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 20:10:21 +02:00
alientimandClaude Sonnet 5 efb0d2aa01 Gruppen-Bearbeiten als Modal, Mobile-Fix, Mehrfach-Upload (v1.1.3)
- Gruppen bearbeiten (Name + Rechte) laeuft jetzt ueber ein Modal statt der
  bisherigen Inline-Ausklapp-Zeile -- gleiche Optik wie "Neue Gruppe". Der
  Gruppenname war dabei bisher ein verstecktes, nie wirklich editierbares
  Feld; jetzt ein normales Texteingabefeld (Backend unterstuetzte das
  Umbenennen inkl. Systemgruppen-Schutz bereits vollstaendig, es fehlte nur
  die Eingabemoeglichkeit im Formular). Admin-Rechte-Ansicht und die
  "Freischalten"-Ausnahme fuer die Systemgruppe "Benutzer" ziehen ins
  jeweilige Modal mit um.
- Fileshare-Baum + Tabelle nebeneinander sprengte auf Tablet-/Handybreite
  die Seite -- stapelt jetzt (Baum oben, Tabelle darunter) ab der
  bestehenden 900px-Sidebar-Umschaltgrenze.
- Mehrfach-Upload: Datei-Eingabefeld erlaubt jetzt echte Mehrfachauswahl
  (mehrere Dateien in einem Dialog) UND mehrmaliges Hinzufuegen
  nacheinander (per DataTransfer angesammelt, bevor "Hochladen" gedrueckt
  wird) -- funktioniert nativ auch auf Mobilgeraeten, da kein Custom-
  Upload-Mechanismus noetig ist. Backend verarbeitet jetzt eine Liste
  statt einer einzelnen Datei (request.files.getlist), mit Sammel-
  Erfolgsmeldung und pro Datei separater Namensvalidierung.

Live auf POETEST verifiziert: Umbenennen+Rechte-Speichern ueber das neue
Modal, Admin-Modal (readonly), Mobile-Layout (390px, Baum stapelt korrekt),
echte Mehrfachauswahl (2 Dateien in einem Dialog, beide korrekt
hochgeladen und einzeln in der Sammelmeldung genannt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:17:32 +02:00
alientimandClaude Sonnet 5 d461f8ca42 Fileshare: redundanten Breadcrumb entfernt, Baum/Tabelle gleich hoch (v1.1.2)
- Der Breadcrumb-Link-Zeile ueber der Dateitabelle ("LDAP Share / Test")
  war seit der Baum-Navigation redundant -- der Baum zeigt die aktuelle
  Position ja schon (aufgeklappt + hervorgehoben). Entfernt.
- .fileshare-layout auf align-items:stretch umgestellt (statt flex-start)
  und .fileshare-main als Flex-Spalte mit table-wrap{flex:1}, damit die
  Baum- und die Tabellen-Kachel immer exakt gleich hoch sind, unabhaengig
  vom jeweiligen Inhalt. Das inline margin-bottom auf .table-wrap musste
  dafuer weg (sonst blieb trotzdem eine 16px-Luecke zwischen den unteren
  Kanten).

Live auf POETEST verifiziert: beide Kacheln exakt gleiche Hoehe (166.5px
in beiden Fällen), Breadcrumb-Div nicht mehr im DOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:51:57 +02:00
alientimandClaude Sonnet 5 1b7fe81507 Fileshare: Baum-Navigation, Datei-Vorschau, 15MB-Uploads (v1.1.1)
- Freigaben-Auswahl von einem Dropdown auf eine Baum-Navigation (links)
  umgestellt: alle gemounteten Freigaben als Wurzelknoten, Ordner beliebig
  aufklappbar. Der Pfad von der Wurzel bis zur aktuellen Position wird
  serverseitig schon aufgeklappt gerendert (_fileshare_tree_ancestors),
  alles andere laedt der Client per Klick lazy nach (neue Route
  /fileshare/subfolders) -- kein Durchlaufen der kompletten Freigabe auf
  einmal noetig. Dateien stehen bewusst nicht im Baum, nur in der
  bestehenden Tabelle rechts.
- Inline-Vorschau fuer PDF (iframe), Bilder, TXT/CSV/LOG/MD/JSON (per Range-
  Request in Haeppchen bis 512KB, mit Hinweis bei Abschneiden -- Erkennung
  ueber den Content-Range-Header, nicht nur den 206-Status, der bei JEDEM
  Range-Request auftritt) sowie Word/Excel rein clientseitig per mammoth.js
  bzw. SheetJS (beide lokal vendored, kein Cloud-Viewer -- interne Dokumente
  verlassen das Netz nicht). Excel-Zellen werden bewusst per textContent
  statt ueber SheetJS' eigene HTML-Ausgabe gesetzt, um unabhaengig von
  deren Escaping-Verhalten sicher zu sein. Neue Route /fileshare/view mit
  fester Endungs-Positivliste, nosniff + restriktiver CSP.
- Uploads bis 15MB erlaubt (MAX_CONTENT_LENGTH in app.py) -- dafuer musste
  auch nginx' client_max_body_size (Standard 1MB) auf 16MB angehoben werden,
  sowohl im Vorlagen-Code (_NGINX_PROXY_LOCATIONS/_render_nginx_config,
  fuer die naechste Anwendung ueber Einstellungen->NGINX) als auch in der
  Fresh-Install-Vorlage. Neuer 413-Errorhandler zeigt eine Flash-Meldung
  statt Werkzeugs nackter Fehlerseite.
- install.sh: known_hosts (liegt unter /srv/tesm) von rsync --delete
  ausgenommen -- fehlte bisher in der Exclude-Liste (anders als sqlite.db/
  fernet.key/secret.key) und waere bei jedem Update verlorengegangen.

Live auf POETEST getestet: Baum-Vorbefuellung, Lazy-Load von
Geschwisterordnern, alle 5 Vorschautypen (PDF-Route direkt verifiziert,
Bild/Text/Word/Excel zusaetzlich visuell), Abschneide-Erkennung sowohl bei
kleiner als auch bei >512KB-Datei.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 17:40:33 +02:00
alientimandClaude Sonnet 5 2c9ec8a100 Fileshare: Hostname-Aufloesung fuer UNC-Pfade + Zuordnungen bearbeitbar
- Freigabe-Hostnamen (statt nur IP) werden jetzt unterstuetzt: mount.cifs
  bekommt die tatsaechliche Ziel-IP gezielt per "ip="-Option mitgegeben,
  aufgeloest ueber _resolve_unc_host_ip(). Deckt zwei reale Faelle ab, die
  beim Live-Test mit einem echten Server (Kurzname "s2025") auftraten:
  1. Ein bloer Kurzname ohne DNS-Suffixsuche loest auf diesem Server gar
     nicht auf -- Fallback probiert zusaetzlich den aus der LDAP-Server-
     Einstellung abgeleiteten AD-Domaenensuffix (a la ".ad.eertmoed.net").
  2. IPv4 wird bevorzugt aufgeloest, IPv6 nur als Fallback genutzt, falls
     fuer den Hostnamen keine IPv4-Adresse existiert.
  Der Hostname bleibt dabei unveraendert in der UNC sichtbar/gespeichert.
- AD-Gruppenzuordnungen (App-Rechte) und Fileshare-Gruppen sind jetzt per
  Bearbeiten-Button (Stift-Icon) direkt aenderbar statt nur loeschen+neu
  anlegen zu koennen -- neue edit_ldap_group_mapping/edit_fileshare_mapping
  POST-Routen (UPDATE per id), Bearbeiten-Modals vorbefuellt inkl.
  AD-Gruppen-Dropdown (gleiches Lade-Muster wie beim Anlegen).
- UX: "Erforderliche AD-Gruppe fuer Login" ist jetzt ein Dropdown mit
  "Gruppen laden" (identisches Muster wie die anderen AD-Gruppenfelder)
  statt eines Freitextfelds, in das der volle DN von Hand einzutragen war.
- _normalize_share_unc()-Hilfsfunktion aus dem Add-Handler herausgezogen,
  jetzt auch vom Edit-Handler genutzt (kein duplizierter Code).

Live auf POETEST verifiziert: Hostname-Mount ("s2025") erfolgreich nach
Fix, Bearbeiten-Modals korrekt vorbefuellt und persistiert, Dropdown laedt
55 AD-Gruppen und behaelt die aktuelle Auswahl beim Neuladen bei.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:56:40 +02:00
alientimandClaude Sonnet 5 09b222615a Fileshare-Feature: LDAP-gruppengesteuerte Netzlaufwerke im Browser (v1.1.0)
- Neue ldap_fileshare_mappings-Tabelle (AD-Gruppe -> Freigabe-UNC+Label),
  verwaltet in Einstellungen -> LDAP wie die bestehende App-Gruppenzuordnung
  (inkl. AD-Gruppen-Dropdown per "Gruppen laden", UNC-Backslash/Slash-
  Normalisierung).
- Login-Gate: optionale "Erforderliche AD-Gruppe fuer Login" (Dropdown,
  gleiches Lade-Muster) -- ohne Mitgliedschaft scheitert der Login wie bei
  falschen Zugangsdaten, ohne Hinweis auf den eigentlichen Grund.
- Sessionabhaengiges Mounten: beim LDAP-Login werden alle per AD-Gruppe
  zugeordneten Freigaben mit den eigenen (nie gespeicherten) Zugangsdaten
  per mount.cifs unter /mnt/tesm-shares/<session-token>/ gemountet, beim
  Logout wieder ausgehaengt. Passwort läuft ausschliesslich ueber die
  PASSWD-Umgebungsvariable von mount.cifs. Aufraeum-Sweep (30 Min-Takt,
  12h-Schwelle) und Cleanup-on-Startup als Sicherheitsnetz.
- Zwei unabhaengige Gates: TESM-Rechtesystem (fileshare.view/.create/.edit,
  neue PERMISSIONS-Gruppe fileshare_group) UND AD-Gruppenmitgliedschaft
  muessen beide zutreffen, sonst wird nicht gemountet und der Menuepunkt
  "Dateifreigaben" bleibt verborgen.
- Neuer Dateibrowser (/fileshare, .../download, .../upload, .../mkdir,
  .../delete, .../rename) mit Breadcrumbs, Upload/Download, Anlegen/
  Umbenennen/Loeschen -- Pfadaufloesung ausschliesslich ueber
  os.path.realpath-Vergleich gegen die eigene Mount-Root (Path-Traversal-
  Schutz), zusaetzlich secure_filename() fuer Datei-/Ordnernamen.
- KRITISCHER Bugfix waehrend Live-Test gefunden: generate_ips.py importiert
  app.py als Hilfsmodul (u.a. aus poe.sh/tesm-check.service heraus, alle
  paar Sekunden) -- das fuehrte Modul-Level-Code erneut aus und hat aktive
  Fileshare-Mounts anderer Sitzungen sofort wieder ausgehaengt. Neues
  TESM_WEB_PROCESS-Env-Flag (nur in tesm.service gesetzt) schuetzt
  Cleanup-on-Startup und den Sweep-Thread davor, bei einem reinen
  Modul-Import erneut zu laufen.
- install.sh: cifs-utils ergaenzt (liefert mount.cifs).
- SCHEMA_VERSION 1 -> 2, VERSION 1.0.10 -> 1.1.0.

Live auf Testbox (192.168.82.51) und POETEST (192.168.80.202) getestet:
Login-Gate (erlauben/verbieten/reset), Rechte-Gate (Mount+Menuepunkt nur
mit fileshare.view), vollstaendiger Datei-Workflow (Upload/Download/
Umbenennen/Anlegen/Loeschen) gegen eine echte SMB-Freigabe, Path-Traversal
gegen alle 5 Routen (serverseitig verifiziert, kein Escape), sauberes
Unmount bei /logout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 16:34:28 +02:00
alientimandClaude Sonnet 5 0074e48e09 Responsive-Fixes per echtem Playwright/DevTools-Test verifiziert, zwei Bugs behoben
Auf Nachfrage tatsaechlich mit Browser-DevTools (Playwright/Chromium,
lokal installiert) statt nur per CSS-Analyse geprueft -- echte
Screenshots + automatisierte Overflow-Pruefung auf iPhone-16-Pro- (393x852),
iPad- (820x1180 Hoch-/1180x820 Querformat) und Desktop-Viewport (1440x900)
gegen die echte Live-Instanz auf Testsystem-Update-Restart.

Dabei zwei echte Bugs gefunden und behoben:

1. Topbar-Ueberlappung (ALLE Seiten, jeder eingeloggte Nutzer, jedes
   iPhone/kleine Handy): .topbar-left (Seitentitel) und .topbar-right
   (Pruef-Countdown- + DHCP-Pille) sind beide flex:1 ohne Zeilenumbruch --
   auf 393px Breite quetscht das den Titel auf eine Breite, die kleiner
   ist als sein eigener Inhalt braucht, wodurch der Titeltext sichtbar
   ueber seine Spalte hinauslief und mit der Pille kollidierte (per
   getBoundingClientRect nachgewiesen: 24.5px Ueberlauf). Fix: ab 560px
   Breite bricht die Topbar um, Titel und Pillen bekommen je eine eigene
   Zeile.

2. HTTP-/HTTPS-Port-Felder auf der NGINX-Seite stapelten trotz
   entsprechender Media-Query-Regel nicht auf Handy-Breite -- Ursache:
   inline style="flex:1" auf den .field-Divs hat Vorrang vor jeder externen
   @media-Regel, ganz gleich wie spezifisch. Fix: inline style entfernt,
   neue Klasse .field--half stattdessen -- die Mobile-Stapel-Regel greift
   jetzt wie vorgesehen.

Zusaetzlich EINE eigene Aenderung aus v1.0.9 wieder zurueckgenommen, weil
sie sich beim Testen als unnoetig und riskant herausstellte:
- .table-wrap: overflow-x:auto (aus v1.0.9) wieder auf overflow:hidden
  zurueckgesetzt. Grund: JEDES Template mit .table-wrap hat bereits einen
  eigenen <div style="overflow-x:auto;"> exakt um die <table> -- die
  zusaetzliche Scroll-Faehigkeit der Karte selbst war komplett redundant
  und erzeugte einen verschachtelten doppelten Scroll-Container um
  denselben Inhalt. Beim Testen der aufklappbaren Rechte-Tabelle
  (Gruppen-Seite) fuehrte das zu Inhalten, die bei negativem x dauerhaft
  unsichtbar wurden. table.data-table { min-width: 560px; } bleibt
  bestehen (harmlos, der jeweils vorhandene innere Wrapper scrollt
  weiterhin korrekt).

Eine dritte vermutete Ursache (aufgeklappte Rechte-Tabelle "verschwindet"
beim Anklicken des Chevrons) stellte sich NICHT als echter App-Bug heraus,
sondern als Artefakt von Playwrights eigenem automatischem
"scroll-into-view-vor-Klick"-Verhalten (der Chevron sitzt in der letzten
Tabellenspalte) -- per direktem JS-Aufruf von toggleDetail() ohne
Playwright-Klick liess sich das nicht reproduzieren, die Seite verhaelt
sich für echte Nutzer korrekt.

Alle 8 getesteten Seiten x 4 Viewports (32 Kombinationen) zeigen nach den
Fixes keinerlei horizontalen Overflow mehr (dokumentseitenweite
scrollWidth == clientWidth in jedem Fall).

VERSION 1.0.9 -> 1.0.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 14:29:48 +02:00
alientimandClaude Sonnet 5 a3f5f028f2 Responsives CSS ausgebaut: Handy (iPhone) und Tablet (iPad) sauber nutzbar
Bestehendes eigenes Design-System beibehalten (kein Bootstrap-Framework
eingebunden), stattdessen die vorhandenen Media Queries/Breakpoints
(Sidebar kollabiert bereits ab 900px) um die fehlenden Faelle ergaenzt:

- .table-wrap: overflow:hidden -> overflow-x:auto. Tabellen mit vielen
  Spalten (Geraete, Switche, Papierkorb, Benutzer, ...) haben auf
  schmalen Screens bisher entweder Spalten lautlos abgeschnitten oder
  die ganze Seite waagerecht aufgerissen -- jetzt scrollt NUR die
  Tabelle selbst (gleiches Prinzip wie Bootstraps .table-responsive).
  table.data-table bekommt zusaetzlich eine min-width als sinnvolle
  Scroll-Schwelle.
- .settings-grid: minmax(340px, 1fr) -> minmax(min(340px, 100%), 1fr) --
  ein nackter Pixel-Mindestwert erzwingt auf einem 375px-iPhone sonst
  waagerechten Overflow der ganzen Seite, sobald die Spalte breiter als
  der verfuegbare Platz ist. Neue Modifier-Klasse .settings-grid--wide
  (420px-Variante) ersetzt zwei betroffene inline
  style="grid-template-columns:minmax(420px,...)"-Overrides (LDAP-,
  NGINX-Seite), die denselben 100%-Fallback nicht hatten.
- .permission-group-col: eigenes overflow-x:auto zusaetzlich zum
  bestehenden flex-wrap der Elternzeile -- falls eine einzelne
  Rechte-Spalte (z.B. Geraete mit R/W/E/D) auch nach dem Umbruch auf
  eine eigene Zeile noch zu breit ist.
- Ab 900px (bestehender Sidebar-Breakpoint) zusaetzlich: .flex (generische
  Button-/Toolbar-Zeilen), .section-head, .term-toolbar/.log-toolbar und
  .detail-row brechen um statt seitlich ueberzulaufen; .modal-footer
  ebenso; .search-input in Tabellen-Toolbars schrumpft flexibel.
- Ab 640px: nebeneinander angeordnete Formularfelder (z.B. HTTP-/HTTPS-
  Port bei NGINX) stapeln sich auf volle Breite statt zwei sehr schmale
  Eingaben zu erzwingen; Modal-Innenabstand etwas schmaler.
- Ab 560px: Buttons in Kopfzeilen (section-head) nehmen die volle Breite
  ein statt als schmaler Block rechts zu kleben.

Live auf Testsystem-Update-Restart verifiziert: alle Kernseiten
(Dashboard, Clients, Switche, Gruppen, LDAP, NGINX, Live-Log, Wartung)
laden weiterhin fehlerfrei (200) nach dem CSS-/Template-Austausch.

WICHTIGER HINWEIS: In dieser Umgebung steht kein Browser-Screenshot-
Werkzeug zur Verfuegung, die Aenderungen wurden also nicht pixelgenau auf
echten iPhone-16-Pro-/iPad-Bildschirmgroessen gegengeprueft, sondern rein
ueber CSS-Analyse und Seiten-Ladetests. Ein Blick auf einem echten Geraet
(oder per Browser-Devtools-Emulation) wird empfohlen.

VERSION 1.0.8 -> 1.0.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:34:35 +02:00
alientimandClaude Sonnet 5 5bca29f3da NGINX-Seite: Sichtbarkeit/Kontrolle der automatischen Let's-Encrypt-Verlaengerung
certbot richtet die automatische Verlaengerung (certbot.timer, zweimal
taeglich) zwar schon beim Paket-Install selbst ein -- das war fuer den
Admin in der Weboberflaeche bisher aber unsichtbar, reine Behauptung im
Code-Kommentar. Jetzt sichtbar/nachpruefbar:

- Neue Zeile "Automatische Verlaengerung" im Zertifikat-Status (nur bei
  Let's-Encrypt-Zertifikaten): Timer aktiv/inaktiv + naechster geplanter
  Lauf (_certbot_timer_status(), liest systemctl is-active/list-timers).
- "Verlaengerung testen (Dry-Run)" -- certbot renew --dry-run, aendert
  nichts, zeigt nur ob die Verlaengerung im Ernstfall funktionieren wuerde.
- "Jetzt manuell verlaengern" -- certbot renew --force-renewal, fuer
  Testzwecke oder eine sofortige Schluessel-Rotation (mit Warnhinweis auf
  Let's Encrypts Rate-Limits).
- Hinweistext beim Anfordern zeigt den Timer-Status schon VOR der ersten
  Anforderung.

VERSION 1.0.7 -> 1.0.8 (ab hier wieder normale fortlaufende
Versionierung statt erneutem Release derselben Versionsnummer).

Live auf Testsystem-Update-Restart verifiziert: Seite laedt weiterhin
fehlerfrei (_certbot_timer_status crasht nicht ohne aktives
Let's-Encrypt-Zertifikat), certbot.timer selbst bestaetigt aktiv
(naechster Lauf laut systemctl list-timers korrekt angezeigt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:24:15 +02:00
alientimandClaude Sonnet 5 4de5828573 NGINX-Seite ausgebaut: Domain/Ports, RAW-Config, Let's Encrypt mit Auto-Verlaengerung; Flask nur noch lokal erreichbar
Umbenennung: Einstellungen -> SSL/TLS heisst jetzt Einstellungen -> NGINX
(Route /settings/ssl -> /settings/nginx, Recht settings_ssl.* ->
settings_nginx.*, Icon "server" statt "shield") -- die Seite verwaltet
den kompletten Reverse-Proxy, nicht mehr nur SSL.

Neu:
- Domain (server_name) und HTTP-/HTTPS-Port sind jetzt einstellbar, nicht
  mehr hart "_" bzw. 80/443. Aendert man den HTTP-Port weg von 80, wird
  automatisch zusaetzlich ein minimaler Nur-ACME-Block auf Port 80
  mitgeschrieben, da Let's Encrypt (HTTP-01) protokollbedingt immer Port 80
  braucht, unabhaengig vom konfigurierten HTTP-Port.
- "RAW-Konfiguration"-Button zeigt die komplette aktuelle nginx-Config im
  selben Popup-Stil wie das Live-Log (Wiederverwendung von
  window.openRawLogModal, kein neuer Code dafuer noetig).
- Let's Encrypt: Zertifikat per certbot anfordern (certonly --webroot,
  bewusst NICHT das certbot-nginx-Plugin -- das wuerde die App-eigene,
  komplett selbstgeschriebene Config direkt editieren und bei der
  naechsten Aenderung ueber diese Seite wieder verlieren). Automatische
  Verlaengerung laeuft ueber certbots eigenen systemd-Timer/Cron
  (Standard-Paketverhalten), ein Deploy-Hook
  (/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh, wird wie
  die logrotate-Config bei jedem App-Start neu geschrieben) laedt nginx
  nach jeder Verlaengerung neu. Fehlgeschlagene Anforderung (kein
  oeffentlicher DNS-Eintrag o.ae.) wird sauber abgefangen und als
  Fehlermeldung angezeigt, ohne etwas zu veraendern -- live mit einer
  ungueltigen Test-Domain verifiziert.
- Zertifikatsquelle (Upload vs. Let's Encrypt) wird als eigenes Setting
  (ssl_source) verfolgt, das Anwenden/Anzeigen/Entfernen beruecksichtigt
  beide Quellen jeweils passend (Entfernen einer LE-verwalteten
  Installation laeuft ueber "certbot delete", nicht ueber rohes
  Datei-Loeschen, damit certbots eigene Renewal-Konfiguration nicht
  verwaist).
- certbot zu install.sh's Paketliste hinzugefuegt.

Flask/gunicorn bindet jetzt an 127.0.0.1 statt 0.0.0.0 (tesm.service) --
direkter Zugriff auf Port 5000 aus dem Netz ist damit nicht mehr moeglich,
nur noch ueber den nginx-Reverse-Proxy (der ohnehin nur ueber 127.0.0.1
zugreift). Beantwortet die Nachfrage, ob Flask unerreichbar gemacht werden
kann.

Live auf Testsystem-Update-Restart verifiziert: Domain/Port-Aenderung
(HTTP-Port 8080 inkl. automatischem Zusatz-Block auf Port 80),
RAW-Config-Anzeige, Zertifikat-Upload, Let's-Encrypt-Fehlschlag sauber
abgefangen, Rueckbau auf Standardwerte (_, 80, 443, kein SSL) ueber
denselben Anwenden/Bestaetigen-Sicherheitsnetz-Pfad wie zuvor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:15:32 +02:00
alientimandClaude Sonnet 5 f27dde466b SSL/TLS-Verwaltung (Upload+Aktivieren), install.sh-Idempotenz, poe_web/rpi-check-Cleanup entfernt
SSL/TLS (neue Seite Einstellungen -> SSL/TLS, eigenes Recht settings_ssl.view/.edit):
- Zertifikat+privater Schluessel per Upload hinterlegbar (PEM), Validierung
  VOR dem Speichern: gueltiges Zertifikat, noch nicht abgelaufen, Schluessel
  passt zum Zertifikat (Abgleich ueber die oeffentlichen Schluessel als
  DER-Bytes -- funktioniert algorithmus-unabhaengig fuer RSA/EC/...).
  Speicherort /etc/tesm/ssl (bewusst ausserhalb von /srv/tesm, damit
  rsync --delete beim Update das Zertifikat nicht mitloescht), Key mit
  chmod 600.
- HTTPS aktivieren schreibt/validiert (nginx -t) eine neue nginx-Konfiguration
  und laedt erst danach neu; Port 80 leitet dann automatisch auf https weiter.
  HSTS optional zuschaltbar, setzt aktives SSL voraus.
- Gleiches Sicherheitsnetz wie bei den Netzwerkeinstellungen: nach dem
  Anwenden 45s Zeitfenster zum Bestaetigen, sonst automatischer Rueckroll
  der nginx-Konfiguration UND der ssl_enabled/hsts_enabled-Einstellungen.
  Beide Pfade (Timeout-Rollback und Bestaetigen-verhindert-Rollback) live
  auf Testsystem-Update-Restart verifiziert, inklusive eines ECHTEN,
  ungeplant ausgeloesten Rollbacks waehrend des Testens.
- Noch KEINE Let's-Encrypt-Automatisierung (siehe Kommentar im Code) --
  alle bekannten Instanzen laufen intern ohne oeffentliche Domain, HTTP-01
  wuerde dort nicht funktionieren; kann bei Bedarf separat ergaenzt werden.

install.sh: Update-Idempotenz ("nur anfassen, was sich geaendert hat"):
- install_if_changed()-Helfer: Custom-Skript/systemd-Units/MOTD-Banner
  werden nur noch bei tatsaechlich geaendertem Inhalt neu geschrieben,
  daemon-reload nur wenn sich mindestens eine Unit geaendert hat.
- nginx-Konfiguration wird nur noch bei einer Frischinstallation aus dem
  Repo-Template geschrieben -- eine bereits vorhandene (egal ob unveraendert
  oder von der App selbst um SSL erweitert) wird nie wieder ueberschrieben.
  Voraussetzung dafuer, dass die neue SSL-Funktion ein Update ueberlebt.

update.sh: komplette Sonderbehandlung fuer die zwei TESM vorausgehenden
Generationen (rpi-check.service, poe_web.service/poe_manager) entfernt --
auf allen aktuellen Installationen laengst migriert. Deaktivierte
Unit-Dateien dieser Generationen wurden auf Testsystem-Update-Restart
zusaetzlich manuell endgueltig geloescht (waren dort seit einer Migration
im September 2025 nur deaktiviert, nicht entfernt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:56:55 +02:00
alientimandClaude Sonnet 5 8253ab3f0e Verlauf: aktuelle live.log als Eintrag, Auswahl per Zeitraum statt Dateiname; RAW-Darstellung
- _live_log_history_files() liefert jetzt zusätzlich generation 0 = die
  aktuelle, noch laufende live.log selbst (nicht nur die rotierten
  Kopien .1/.2/...) -- _resolve_log_history_file akzeptiert 'live.log'
  als gültige Auswahl.
- Jeder Eintrag bekommt ein range_label ('von' - 'bis'): 'bis' ist das
  eigene mtime (Zeitpunkt der Rotation) bzw. 'laufend' bei der aktuellen
  Datei, 'von' ist das mtime der naechstaelteren Generation -- rename()
  bei einer Rotation aendert mtime nicht, jede Generation behaelt also
  dauerhaft ihren eigenen Entstehungszeitpunkt. Live verifiziert: die
  Zeitraeume reihen sich lueckenlos aneinander (Ende von Generation N ==
  Anfang von Generation N-1).
- Dropdown und Toolbar zeigen jetzt den Zeitraum statt des rohen
  Dateinamens.
- logs_history.html: Farbcodierung (colorizeLine) entfernt, log-box durch
  ein <pre class="raw-log-content"> ersetzt -- reine RAW-Darstellung
  ohne Client-seitiges Span-pro-Zeile-Aufbauen, auf Wunsch zur weiteren
  Reduktion der Ladezeit im Verlauf (Live-Ansicht selbst bleibt farbig).
- VERSION 1.0.6 -> 1.0.7.

Live getestet auf Testsystem-Update-Restart: 4 Eintraege (aktuell + 3
rotierte Generationen), Zeitraeume lueckenlos verkettet, RAW-Endpoint für
'file=live.log' funktioniert, Path-Traversal-Versuch weiterhin mit 404
abgewiesen.

Zusaetzlich untersucht (kein Code-Fix, siehe Konversation): der Verdacht
eines install.sh-Bugs, der tesm-check.service nach einem Update gestoppt
liesse. journalctl zeigt auf beiden Boxen durchgehend 'active (running)'
ueber den fraglichen Zeitraum -- keine Bestaetigung eines Defekts in der
Dienstverwaltung gefunden. Wahrscheinlichste Erklaerung: ein manuell zu
Testzwecken erzwungener logrotate-Lauf (logrotate -f) direkt nach dem
Update hat live.log geleert, wodurch die Live-Log-Seite und die
Naechste-Pruefung-Anzeige fuer die Dauer eines Pruefintervalls leer
wirkten, bis der naechste Zyklus neuen Inhalt schrieb.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:28:15 +02:00
alientimandClaude Sonnet 5 befc76819e Logrotate: TESM_LOG_DIR world-writable verhinderte jede Rotation
Root Cause (live auf POETEST reproduziert): /var/log/tesm war 0o777
(noch aus einer Zeit vor dem Umstieg auf root-laufende systemd-Units).
logrotate verweigert seit den aktuellen Haertungen jede Rotation in
einem group-/world-writable Verzeichnis ("insecure permissions") komplett
-- live.log wuchs auf POETEST seit der letzten Neuinstallation (9 Tage)
unbegrenzt, /var/lib/logrotate/status hatte fuer tesm ueberhaupt keinen
Eintrag. Direkte Folge: die neue Verlauf-Seite blieb leer, weil nie
live.log.1/.2/... entstanden.

Fix:
- os.chmod(TESM_LOG_DIR) und os.chmod(TESM_CLIENT_UPDATE_LOG_DIR) in
  app.py: 0o777 -> 0o755 (tesm.service/tesm-check.service laufen beide
  als root, kein anderer Dienstbenutzer schreibt hierher -- Keas eigenes
  Log liegt separat unter /var/log/kea/). Laeuft bei jedem App-Start,
  heilt bestehende Installationen also automatisch beim naechsten
  Neustart/Update.
- install.sh: chmod 777 -> chmod 755 fuer /var/log/tesm bei einer
  Frischinstallation, veraltete/falsche Kommentar-Begruendung (angeblich
  auch fuer Kea noetig) korrigiert.
- _write_logrotate_config(): zusaetzlich 'su root root' in der
  generierten Konfiguration als Absicherung, falls das Verzeichnis aus
  irgendeinem Grund wieder zu weit geoeffnet wird.
- VERSION 1.0.5 -> 1.0.6.

Live verifiziert auf Testsystem-Update-Restart: logrotate -d zeigt nach
dem Fix keine 'insecure permissions'-Fehler mehr, ein erzwungener Lauf
(logrotate -f) erzeugt sauber live.log.1/live.log.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:11:41 +02:00
208 changed files with 31657 additions and 16236 deletions
+9
View File
@@ -0,0 +1,9 @@
# Auf dem Zielsystem laufen Shell- und Python-Dateien -- CRLF wuerde sie
# unbrauchbar machen ("bad interpreter"). Darum hart auf LF festgelegt.
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
deploy/tesm-helper text eol=lf
+22 -14
View File
@@ -1,16 +1,24 @@
# Virtuelle Umgebung (Linux — das Projekt ist ausschließlich für Linux ausgelegt) # Instanzdaten und Geheimnisse gehoeren nie ins Repo.
/srv/tesm/venv/ instance/
*.db
*.db-wal
*.db-shm
data.keys
secret.key
license.json
license_key.json
master_signing_key.json
known_hosts
# Python
__pycache__/ __pycache__/
*.pyc *.py[cod]
.venv/
*.egg-info/
build/
dist/
# Logfiles # Werkzeuge
*.log .pytest_cache/
.ruff_cache/
# Secrets — niemals committen .mypy_cache/
/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/tesm/static/uploads/
-540
View File
@@ -1,540 +0,0 @@
# TESM — Knowledge Base (aus dem Code entfernte Kommentare)
Diese Datei sammelt alle erklärenden `#`/`//`/`{# #}`/`<!-- -->`-Kommentare,
die am 2026-08-14 aus dem Code entfernt wurden, um den Code selbst schlank
zu halten. Docstrings (`"""..."""`) wurden NICHT angetastet und stehen
weiterhin direkt im Code.
Organisiert nach Datei, innerhalb von `app.py`/`create_db.py` zusätzlich
nach der Funktion, in der der Kommentar stand (Zeilennummer bezieht sich
auf den Stand VOR dem Entfernen, siehe Git-Historie).
---
## app.py
### Modulebene
- **Zeile 29** (srv/tesm/app.py): LDAP_MATCHING_RULE_IN_CHAIN — Microsofts OID für rekursive Gruppen- mitgliedschaft (löst auch verschachtelte Gruppen auf), statt nur das flache, nicht-rekursive memberOf-Attribut eines Benutzers zu lesen.
### `def patched_init`
- **Zeile 81** (srv/tesm/app.py): Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults passend zum produktiven Deployment unter /srv/tesm)
- **Zeile 91** (srv/tesm/app.py): Alle App-eigenen Logs leben unter einem gemeinsamen Verzeichnis statt verstreut in /var/log — vier getrennte Dateien, damit Live-Check, Änderungen, App-Fehler und Kea-DHCP sich nicht gegenseitig zutexten. Das Verzeichnis wird bewusst weltweit beschreibbar angelegt (siehe unten): tesm.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R. als eigener, unprivilegierter Systembenutzer (_kea) — beide müssen in dasselbe Verzeichnis schreiben können, ohne dass wir hier exakt den richtigen UID/GID-Abgleich nachbilden müssen.
- **Zeile 103** (srv/tesm/app.py): NICHT unter TESM_LOG_DIR: kea-dhcp4s AppArmor-Profil (/etc/apparmor.d/usr.sbin.kea-dhcp4) erlaubt Schreibzugriff exklusiv auf genau diesen Pfad (Dateiname hart einprogrammiert, kein Verzeichnis- Wildcard) — ein anderer Pfad wird mit "DENIED ... mknod ... kea-dhcp.log.lock" abgelehnt (live so reproduziert). Bewusst NICHT das AppArmor-Profil aufgeweicht, um die Sandbox eines Systemdienstes auf einem geteilten Host nicht zu schwächen — die App arbeitet stattdessen mit Keas eigenem, bereits erlaubtem Pfad.
- **Zeile 118** (srv/tesm/app.py): Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall explizit eingetragen werden muss.
- **Zeile 124** (srv/tesm/app.py): Zugangsdaten-Kategorien — legt der Admin explizit fest (siehe _ensure_schema()-Kommentar für die Begründung gegen automatische Ping-basierte Erkennung). Nur "linux" ist aktuell für eine Aktion nutzbar (SSH-Update unter "Wartung"); die anderen sind reine Einordnung, z.B. für spätere Erweiterungen (Windows/PowerShell folgt separat).
- **Zeile 136** (srv/tesm/app.py): Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird.
- **Zeile 142** (srv/tesm/app.py): Eigene, rotierte Datei für App-seitige Fehler/Warnungen ("Syslog"-Äquivalent der App) — vorher landete das nur im journalctl-Output von tesm.service, ohne eigene, von der App aus kontrollierbare Aufbewahrung. Bewusst nur WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose, kein Access-Log.
### `def _load_or_create_fernet`
- **Zeile 192** (srv/tesm/app.py): Rechtesystem (Gruppen): Admins (is_admin=1) dürfen immer alles — inkl. Benutzer-/Gruppen-/Settingsverwaltung, die es nur für Admins gibt. Für normale Benutzer lassen sich einzelne Verwaltungsrechte über Gruppen gezielt vergeben, ohne sie zu Admins zu machen.
- **Zeile 197** (srv/tesm/app.py): Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte (Spalten in der Gruppen-Tabelle). Jeder Unterpunkt liefert "rows": eine Teilmenge der vier kanonischen Rechte-Zeilen (view/create/edit/delete = L/S/Ä/D, siehe PERMISSION_ROW_TYPES) — fehlt eine Zeile für einen Unterpunkt (z.B. "delete" bei Systemeinstellungen), wird die Zelle in der Tabelle grau und nicht setzbar dargestellt statt einfach zu fehlen, damit alle Spalten eines Bereichs bündig nebeneinander in einer kompakten Tabelle stehen.
- **Zeile 221** (srv/tesm/app.py): "Ändern" deckt Löschen überall mit ab, auch hier — es gibt nirgends ein eigenes Löschen-Recht. Die vierte Spalte (D) ist bei Clients KEIN Löschen, sondern das Recht, einen PoE-Neustart über das Dashboard-Popup auszulösen (automatische Neustarts bei Ausfall bleiben davon unberührt).
- **Zeile 235** (srv/tesm/app.py): Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab.
- **Zeile 252** (srv/tesm/app.py): Kein eigenes Löschen — "Ändern" (E) steht hier für das Ausführen von Wartungsaktionen (SSH-Update, SSH-Neustart), nicht für Löschen. Getrennt von devices.restart (D oben): das ist ausschließlich der PoE-Neustart über den Switch.
- **Zeile 298** (srv/tesm/app.py): R = Seite ansehen (wie überall). E = Import ausführen (verändert Bestand, passt zu "Ändern"). X = Export ausführen (eigene Zeile, NICHT an E gekoppelt) -- die Export-Datei enthält Passwörter im Klartext, daher bewusst als eigenständiges, separat vergebbares Recht.
- **Zeile 311** (srv/tesm/app.py): L = Konfiguration/Status/Vorschau ansehen, Ä = Konfiguration speichern und die generierte Reservierungsdatei schreiben.
- **Zeile 323** (srv/tesm/app.py): Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt.
- **Zeile 354** (srv/tesm/app.py): Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht gesetzt, greift KEIN darunterliegendes Recht mehr — auch wenn es einzeln gewährt wurde (Kill-Switch, siehe User.has_permission). So lässt sich z.B. der gesamte "Geräte"-Bereich für eine Gruppe mit einem Klick sperren, ohne jedes Kind-Recht einzeln zurücknehmen zu müssen.
- **Zeile 370** (srv/tesm/app.py): Welche Spalten (R/W/E/D) für einen Bereich überhaupt vorkommen — z.B. hat nur "Geräte" (wegen Clients' Dashboard-Neustart) eine D-Spalte, Logs/ Einstellungen brauchen sie gar nicht erst anzuzeigen. Auf der Gruppen- Seite bekommt so jeder Bereich nur die Spalten, die er tatsächlich nutzt, statt überall gleich viele (teils komplett ungenutzte) Spalten zu zeigen.
- **Zeile 389** (srv/tesm/app.py): Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar (siehe /account, gespeichert als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.
### `def can_view_settings_importexport`
- **Zeile 648** (srv/tesm/app.py): Jedes der drei Rechte (Seite ansehen/Import/Export) muss für sich allein zu Seiten-/Nav-Sichtbarkeit führen -- sonst wäre z.B. ein Nutzer mit ausschließlich dem Export-Recht (ohne .view) nie in der Lage, die Seite überhaupt zu erreichen, um sein Recht zu nutzen.
### `def get_db_connection`
- **Zeile 686** (srv/tesm/app.py): Kuratierte Standard-DHCPv4-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) — vorbefüllt in _ensure_schema(), siehe dort für die Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen. (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/ Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte Felder und deshalb bewusst nicht enthalten. Client-/Protokoll-interne Optionen (message-type, parameter-request-list, requested-address, client-identifier, server-identifier, ...) ebenfalls bewusst nicht enthalten — die sind serverseitig nicht sinnvoll setzbar (auch Windows Server listet sie nicht unter "Predefined Options"). "host-name" (12) ebenfalls bewusst NICHT enthalten: Kea beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch mit deren eigenem "hostname"-Feld (aus dem Namen der Reservierung abgeleitet, siehe _dhcp_safe_hostname) — ein zusätzlicher option-data-Override für dieselbe Option in derselben Reservierung wäre eine mit der Kea-Doku belegte Dopplung/Quelle für Widersprüche, kein zusätzlicher Nutzen. Alle restlichen Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option einzeln).
### `def _ensure_schema`
- **Zeile 770** (srv/tesm/app.py): Für den Migrations-Einmal-Guard weiter unten (settings existiert normalerweise schon durch create_db.py, hier nur zur Absicherung defensiv angelegt).
- **Zeile 792** (srv/tesm/app.py): Migration: 'is_default'-/'is_system'-Spalte nachrüsten, falls die Tabelle noch aus einer älteren Version ohne diese Spalten stammt. is_system sperrt Name UND Rechte einer Gruppe komplett gegen Änderungen über die UI (aktuell nur die Standardgruppe 'Benutzer') — getrennt von is_default (steuert nur die automatische Zuordnung neuer Benutzer), damit künftig auch andere, nicht-default Systemgruppen denkbar wären.
- **Zeile 804** (srv/tesm/app.py): Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten.
- **Zeile 812** (srv/tesm/app.py): 'local' (Passwort in dieser DB, bcrypt) oder 'ldap' (Konto stammt aus einem beim ersten erfolgreichen LDAP/AD-Login automatisch angelegten Eintrag -- das Passwort wird NIE lokal geprüft, siehe _ldap_authenticate() bzw. den Login-Handler; das gespeicherte "password" ist dafür nur ein nie verwendeter Platzhalter-Hash, um die NOT-NULL-Spalte zu befüllen).
- **Zeile 819** (srv/tesm/app.py): E-Mail-Adresse — für lokale Konten frei editierbar (Login zusätzlich per E-Mail statt nur Benutzername möglich), für LDAP/AD-Konten aus dem "mail"-Attribut übernommen und bei jedem Login aufgefrischt.
- **Zeile 824** (srv/tesm/app.py): Sperrt einen Login unabhängig vom Passwort/AD-Status -- der einzige Weg, einem AD-Konto den Zugriff wirklich zu entziehen, ohne es in AD selbst zu deaktivieren (ein simples Löschen des lokalen Schatten- Kontos reicht dafür NICHT: bei erneuter gültiger AD-Anmeldung würde es sonst automatisch neu angelegt).
- **Zeile 832** (srv/tesm/app.py): Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt das alte (siehe save_ldap-Handler: INSERT ... ON CONFLICT DO UPDATE).
- **Zeile 847** (srv/tesm/app.py): AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer App-Gruppe zugeordnet werden. Bei Login/Provisionierung wird geprüft, in welchen dieser AD-Gruppen ein Benutzer (auch über verschachtelte Gruppen) Mitglied ist, und die entsprechenden App-Gruppen werden ihm zugewiesen (additiv, siehe _ldap_resolve_app_groups) -- passt zu keine dieser Gruppen, greift die Standardgruppe (ldap_default_group).
- **Zeile 863** (srv/tesm/app.py): Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne PoE-Neustarts (die stehen bereits im Live-Log von poe.sh).
- **Zeile 876** (srv/tesm/app.py): Migration: rpi_ip -> ip (Namensrelikt aus einer früheren, Raspberry-Pi- spezifischen Version -- Geräte sind längst nicht mehr auf Pis beschränkt). RENAME COLUMN erhält bestehende Werte unverändert.
- **Zeile 884** (srv/tesm/app.py): "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log.
- **Zeile 890** (srv/tesm/app.py): Zugangsdaten (Credentials): eigenständige, wiederverwendbare SSH-Logins für Switche, statt Username/Passwort direkt am Switch.
- **Zeile 901** (srv/tesm/app.py): Kategorie (Switch/Linux-Client/Windows-Client/Router-Sonstiges) — bestimmt u.a., ob für ein Gerät mit diesen Zugangsdaten die SSH-Update-Aktion unter "Wartung" angeboten wird (siehe DEVICE_MAINTENANCE_CATEGORY). Bewusst KEINE automatische Erkennung per Ping o.ä. — ein reiner TTL-Hinweis ist keine verlässliche Grundlage dafür, unbeaufsichtigt Systembefehle auszuführen; der Admin legt die Kategorie explizit fest.
- **Zeile 918** (srv/tesm/app.py): SSH-Port je Switch (falls nicht Standard 22) — NULL/leer bedeutet überall "22 verwenden" (siehe SWITCH_DEFAULT_SSH_PORT-Fallback).
- **Zeile 923** (srv/tesm/app.py): Optionale SSH-Anbindung eines Clients (devices) — analog zu Switchen, damit z.B. ein Linux-Client für die Wartungs-/Update-Aktion erreichbar ist. NULL/kein credential_id = kein SSH-Zugriff für dieses Gerät.
- **Zeile 932** (srv/tesm/app.py): Papierkorb: "Löschen" setzt für Clients/Switche/Zugangsdaten/lokale Benutzer/Custom-Gruppen nur noch deleted_at statt die Zeile wirklich zu entfernen -- Wiederherstellen setzt es zurück auf NULL. Endgültiges Löschen (manuell über den Papierkorb oder automatisch nach Ablauf der Aufbewahrungsfrist, siehe trash_retention_days) führt DANN erst das echte DELETE aus. AD/LDAP-Benutzer durchlaufen den Papierkorb nicht (siehe users()-Route delete_user) -- ihr Konto wird beim nächsten gültigen Login ohnehin automatisch neu angelegt.
- **Zeile 945** (srv/tesm/app.py): DHCP: eigene Options-Definitionen + Werte (global/per-Client, siehe create_db.py für die ausführliche Begründung von device_mac='').
- **Zeile 957** (srv/tesm/app.py): Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus einer Version ohne vorbefüllte Standard-Optionen stammt.
- **Zeile 972** (srv/tesm/app.py): Einmalig eine kuratierte Auswahl an Standard-DHCP-Optionen vorbefüllen (analog den "Predefined Options" eines Windows-DHCP-Servers) — Router/ DNS-Server/Domain-Name/Lease-Zeiten sind bereits eigene, dedizierte Felder (siehe dhcp_subnets/DHCP_DEFAULTS) und deshalb hier bewusst ausgenommen. Alle Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t) — Kea kennt diese Standard-Optionen bereits nativ, sie bekommen deshalb (anders als eigene Options) KEIN eigenes option-def in der generierten Konfiguration (siehe _render_kea_config), sonst würde die eingebaute Definition dupliziert. Per Guard nur einmalig, damit ein bewusst vom Admin gelöschter Eintrag nach einem Neustart nicht stillschweigend wieder auftaucht.
- **Zeile 992** (srv/tesm/app.py): v2: Liste um weitere verifizierte Standard-Optionen erweitert — eigener Guard, damit bereits auf v1 migrierte Installationen die neu hinzugekommenen Optionen ebenfalls bekommen, ohne die gesamte Seed-Logik erneut über alle Zeilen laufen zu lassen.
- **Zeile 1005** (srv/tesm/app.py): v3: "host-name" (12) als Standard-Option wieder entfernt — Kea beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung bereits automatisch über deren eigenes "hostname"-Feld; ein zusätzlicher option-data-Override dafür ist laut Kea-Doku eine Dopplung/Quelle für Widersprüche (live an einer echten Reservierung nachvollzogen, wo beide gleichzeitig gesetzt waren). Löscht auch bereits gesetzte Werte für diese Option mit, da sie ohnehin nie sinnvoll gewirkt hätten.
- **Zeile 1021** (srv/tesm/app.py): DHCP: manuelle Reservierungen — im Unterschied zu den automatisch aus den Clients (devices-Tabelle) erzeugten Reservierungen für Geräte, die NICHT als Client in dieser App gepflegt werden (z.B. ein fremdes/ externes Gerät, dem bewusst und mit voller Kontrolle eine feste IP gegeben werden soll, statt es unüberwacht automatisch anzubinden). Können im Gegensatz zu automatischen Reservierungen gelöscht werden.
- **Zeile 1036** (srv/tesm/app.py): DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben, für die alle DHCP angeboten werden soll. gateway/dns sind je Subnetz optional (leer = automatisch erkanntes Gateway des Interfaces bzw. kein DNS-Override); domain/Lease-Zeiten/Ausgabepfad bleiben bewusst global (siehe DHCP_DEFAULTS), da sie Kea-weit gelten.
- **Zeile 1054** (srv/tesm/app.py): Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus einer Version ohne Deaktivieren-Funktion stammt.
- **Zeile 1059** (srv/tesm/app.py): Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/ _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile der neuen Tabelle überführen, statt eine bereits funktionierende Konfiguration beim Upgrade stillschweigend zu verlieren. Per Guard nur einmalig, da die alten settings-Schlüssel absichtlich stehen bleiben (Altlast, wird nicht mehr gelesen) und sonst bei jedem Start erneut eine (dann ggf. schon gelöschte) Zeile anlegen würde.
- **Zeile 1086** (srv/tesm/app.py): Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten (ältere DB-Version) in eigene Credentials-Datensätze überführen.
- **Zeile 1107** (srv/tesm/app.py): Migration (granularer Rechtebaum): devices.toggle -> devices.edit, switches.* -> gespiegelte credentials.*, sowie Backfill des neuen "Bereich sichtbar"-Rechts für Gruppen mit bereits gesetzten Kind-Rechten. WICHTIG: nur EINMALIG (Guard über settings) — sonst würde das Bereichs-Recht bei jedem Neustart automatisch neu gesetzt und den Kill-Switch (siehe User.has_permission) wirkungslos machen.
- **Zeile 1171** (srv/tesm/app.py): Migration v2: "Löschen" als eigenes Recht wieder abgeschafft — "Ändern" deckt das Löschen jetzt überall mit ab (auch bei Clients, keine Ausnahme mehr). Gruppen, die bisher nur *.delete ohne *.edit hatten, würden sonst durch das Update ihr Löschen-Recht stillschweigend verlieren — deshalb erst *.edit nachtragen, dann die veralteten *.delete-Zeilen entfernen. Ebenfalls einmalig per Guard, aus demselben Grund wie bei v1 (kein Zurückschreiben bei jedem Neustart nötig, da *.delete als Recht komplett entfällt und nie wieder vorkommen kann).
- **Zeile 1203** (srv/tesm/app.py): Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu angelegten rpi-<timestamp>.log-Dateien unter /var/log sind seit der Umstellung auf eine einzige, per logrotate rotierte Datei (TESM_LIVE_LOG_PATH unter /var/log/tesm/) verwaist — poe.sh räumt sie nicht mehr selbst auf (das war vorher sein eigener 30-Tage-`find -delete`), und die neue logrotate-Konfiguration überwacht ein anderes Verzeichnis. Ohne diese Migration blieben sie für immer liegen.
- **Zeile 1220** (srv/tesm/app.py): Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in der UI ohnehin komplett gegen Umbenennen/Rechteänderung sperrt — Mitgliedschaft bleibt weiterhin frei verwaltbar.
- **Zeile 1239** (srv/tesm/app.py): Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen durch die Einführung des Rechtesystems keinen Zugriff verlieren).
### `def _build_user`
- **Zeile 1263** (srv/tesm/app.py): deleted_at IS NULL: Mitglied einer in den Papierkorb verschobenen Gruppe verliert deren Rechte sofort (nicht erst nach Wiederherstellen/ endgültigem Löschen) -- einziger Ort, der geändert werden muss, damit das app-weit für jede has_permission()-Prüfung greift.
### `def load_user`
- **Zeile 1442** (srv/tesm/app.py): deleted_at IS NULL: eine in den Papierkorb verschobene Benutzerin verliert damit sofort jede bereits laufende Sitzung, nicht erst beim nächsten Login-Versuch.
- **Zeile 1453** (srv/tesm/app.py): LDAP / Active Directory — optionale Anmeldung mit dem Domänen-Passwort zusätzlich zu lokalen Konten (siehe Systemeinstellungen → LDAP).
- **Zeile 1456** (srv/tesm/app.py): WICHTIG: das ist "gleiches Passwort" (Same Sign-On), KEIN automatisches, promptloses Single Sign-On -- der Benutzer tippt sein AD-Passwort weiterhin auf der Login-Seite ein, es wird nur live gegen AD statt gegen ein lokal gespeichertes Passwort geprüft. Echtes automatisches SSO bräuchte zusätzlich Kerberos/SPNEGO (eigenes SPN + Keytab vom AD-Admin) oder SAML/OIDC über Entra ID -- beides technisch klar unterschiedliche, deutlich aufwändigere Bausteine, die sich später darauf aufsetzen ließen.
- **Zeile 1464** (srv/tesm/app.py): Search+Bind-Muster statt eines direkten UPN-Binds: zuerst mit dem konfigurierten Service-Konto verbinden, den Benutzer per konfigurierbarem Attribut (Standard sAMAccountName, für generisches LDAP z.B. "uid") suchen, dann eine ZWEITE, separate Verbindung mit dessen exakter DN + dem eingegebenen Passwort aufbauen, um es zu prüfen. Das funktioniert unabhängig davon, ob sAMAccountName und der UPN-Präfix übereinstimmen, und damit auch mit generischem (Nicht-AD-)LDAP.
### `def _ldap_settings`
- **Zeile 1513** (srv/tesm/app.py): Fallback für Benutzer, die zu KEINER konfigurierten AD-Gruppen- Zuordnung passen (siehe ldap_group_mappings/_ldap_resolve_app_groups) -- leer = Systemstandardgruppe.
### `def _ldap_connect_service`
- **Zeile 1533** (srv/tesm/app.py): tls nur relevant, wenn use_ssl gesetzt ist -- ldap3 ignoriert es sonst ohnehin, aber so bleibt die Absicht im Code eindeutig.
### `def _attr`
- **Zeile 1633** (srv/tesm/app.py): ACCOUNTDISABLE-Bit, nur bei AD vorhanden
- **Zeile 1641** (srv/tesm/app.py): "E-Mail" meint hier bewusst userPrincipalName (z.B. "user@ad.firma.local"), nicht das separate "mail"-Attribut -- in AD ist der UPN die praxisübliche Login-/Kontaktadresse, "mail" ist oft gar nicht gepflegt (genau wie beim Test-Account, der zu dieser Entscheidung geführt hat) und wird deshalb hier gar nicht erst abgefragt.
### `def _ldap_group_mappings`
- **Zeile 1791** (srv/tesm/app.py): LEFT JOIN mit deleted_at-Filter: eine inzwischen in den Papierkorb verschobene (oder endgültig gelöschte) Rechtegruppe lässt die Zuordnung sichtbar als "(gelöscht)" statt sie stillschweigend so anzuzeigen, als würde sie noch normal funktionieren.
### `def _ldap_authenticate`
- **Zeile 1853** (srv/tesm/app.py): Welche App-Gruppen dem Benutzer laut AD-Gruppenmitgliedschaft zustehen (siehe ldap_group_mappings) -- ohne konfigurierte Zuordnung dürfen sich weiterhin alle Domänen-Benutzer anmelden (Standardgruppe greift dann beim Provisionieren).
- **Zeile 1879** (srv/tesm/app.py): Auth
### `def _assign_ldap_groups`
- **Zeile 1892** (srv/tesm/app.py): Zielgruppe könnte inzwischen in den Papierkorb verschoben oder sogar endgültig gelöscht worden sein -- dann keine (dann sinnlose oder gar auf nichts mehr zeigende) Mitgliedschaft eintragen.
### `def _ldap_default_group_id`
- **Zeile 1903** (srv/tesm/app.py): Konfigurierte Gruppe könnte inzwischen in den Papierkorb verschoben worden sein -- dann auf den Systemstandard zurückfallen, statt eine (dann möglicherweise sogar endgültig gelöschte) Gruppen-ID stumpf weiterzuverwenden.
### `def login`
- **Zeile 1924** (srv/tesm/app.py): 1) Exakter Benutzername-Treffer (Groß-/Kleinschreibung wie eingegeben) -- deckt lokale Konten UNVERÄNDERT wie bisher ab, und bereits per LDAP angelegte Konten, wenn der Benutzername konsistent geschrieben wird. deleted_at IS NULL bei allen drei Lookups: ein in den Papierkorb verschobenes lokales Konto darf sich nicht mehr anmelden UND soll als "nicht gefunden" behandelt werden (nicht z.B. mit einer extra Fehlermeldung, die verriete, dass der Name existiert).
- **Zeile 1936** (srv/tesm/app.py): 2) Kein exakter Treffer -- per E-Mail suchen (case-insensitiv). Deckt sowohl lokale Konten mit hinterlegter E-Mail als auch per LDAP angelegte Konten ab (deren E-Mail-Spalte den UPN enthält). Login-Logik umfasst damit genau vier Fälle: lokaler Benutzername, lokale E-Mail, AD sAMAccountName, AD userPrincipalName.
- **Zeile 1948** (srv/tesm/app.py): 3) Immer noch nichts -- unter den PER LDAP angelegten Konten zusätzlich case-insensitiv nach dem Benutzernamen suchen (AD-Benutzernamen sind case-insensitiv; ohne das könnte "JDoe" und "jdoe" sonst zwei verschiedene lokale Schatten-Konten erzeugen). Wirkt sich NIE auf lokale Konten aus.
- **Zeile 1960** (srv/tesm/app.py): Gesperrt (siehe Benutzerverwaltung) -- unabhängig von Passwort/AD-Status. Der einzige zuverlässige Weg, einem AD-Konto den Zugriff über die App wirklich zu entziehen, ohne es in AD selbst zu deaktivieren.
- **Zeile 1969** (srv/tesm/app.py): Passwort wird NIE lokal geprüft (der gespeicherte Hash ist nur ein nie verwendeter Platzhalter) -- bei JEDEM Login live gegen AD verifiziert, damit eine Passwortänderung in AD sofort greift, ohne dass hier etwas synchronisiert werden müsste.
- **Zeile 1975** (srv/tesm/app.py): Name/E-Mail bei jedem Login aus AD auffrischen (können sich dort ändern), und ggf. neu hinzugekommene AD-Gruppen- Zuordnungen zusätzlich zuweisen (nie etwas wegnehmen).
- **Zeile 1999** (srv/tesm/app.py): Kein lokales Konto mit diesem Namen/dieser E-Mail -- bei aktivierter LDAP-Anmeldung gegen AD prüfen (per Benutzername, UPN oder E-Mail, siehe _ldap_find_entry) und beim ersten erfolgreichen Login automatisch ein lokales Konto anlegen.
### `def logout`
- **Zeile 2044** (srv/tesm/app.py): Landet auf dem (auch ohne Login erreichbaren) Dashboard statt auf /login -- wer sich abmeldet, will i.d.R. trotzdem noch den Online/Offline-Status sehen können, ohne sich extra erneut einloggen zu müssen, nur um zur Übersicht zu gelangen.
- **Zeile 2052** (srv/tesm/app.py): Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"- Bereich, der andere Benutzer verwaltet. Die Navbar-Reihenfolge (siehe save_nav_order()) konfigurieren Admins unter Systemeinstellungen, nicht hier.
### `def profile`
- **Zeile 2083** (srv/tesm/app.py): Das gespeicherte Passwort ist nur ein nie geprüfter Platzhalter (siehe Login-Handler) -- der bcrypt-Vergleich unten würde ohnehin nie zutreffen, aber mit einer irreführenden "aktuelles Passwort falsch"-Meldung statt einer klaren Ansage.
- **Zeile 2132** (srv/tesm/app.py): Log-Auswertung (Erreichbarkeits-Status je Gerät)
### `def get_device_status`
- **Zeile 2290** (srv/tesm/app.py): Dashboard
### `def by_name`
- **Zeile 2319** (srv/tesm/app.py): für Kompatibilität/Zähler
### `def _restart_check_service`
- **Zeile 2367** (srv/tesm/app.py): z.B. lokale Entwicklungsumgebung ohne systemd
### `def run_check_now`
- **Zeile 2385** (srv/tesm/app.py): Kurz warten, bis der frisch gestartete Dienst den ersten Durchlauf ins neue Logfile geschrieben hat — dann kann die Antwort den aktuellen Zeitpunkt direkt mitliefern, ohne dass das Frontend dafür einen zweiten Request/Endpunkt braucht (Flask läuft threaded, blockiert also keine anderen Anfragen währenddessen).
- **Zeile 2400** (srv/tesm/app.py): Settings
- **Zeile 2404** (srv/tesm/app.py): System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht der Kea-DHCP-*Server* für Client-Geräte weiter unten). Eine falsche Änderung kann die Erreichbarkeit kappen, daher: Backend-Erkennung statt Annahme (unbekannt -> rein lesend), Backup vor jeder Änderung, und ein Sicherheits-Timer (analog "netplan try") der ohne explizite Bestätigung innerhalb von NETWORK_REVERT_SECONDS automatisch zurückrollt.
- **Zeile 2413** (srv/tesm/app.py): {"timer": Timer, "backend":..., "interface":..., "backup": {...}} Eigene netplan-Override-Datei (siehe _apply_network_config) — bewusst nicht die von cloud-init/dem Distributor mitgelieferte Datei, damit ein Rollback nie versehentlich fremden Inhalt überschreibt.
### `def _detect_network_backend`
- **Zeile 2432** (srv/tesm/app.py): Standard-Ubuntu (Server- wie Cloud-Images) verwaltet sein Netz inzwischen über netplan + systemd-networkd statt NetworkManager oder dhcpcd — mit Abstand der häufigste Fall auf einem "normalen" frisch installierten Ubuntu-Host (live an einer echten Ubuntu-24.04-VM verifiziert, die ihre IP per DHCP bezieht).
### `def _read_configured_dns`
- **Zeile 2470** (srv/tesm/app.py): Nur die erste Zeile trägt das "Link N (iface):"-Label; bei mehr Servern, als auf eine Zeile passen, folgen reine Fortsetzungszeilen OHNE Label -- ein naives split(":", 1) auf JEDER Zeile würde bei einer IPv6-Fortsetzungszeile (die selbst Doppelpunkte enthält) fälschlich deren Anfang abschneiden (live reproduziert: "2003:fe:7714:..." wurde zu "fe:7714:...").
- **Zeile 2485** (srv/tesm/app.py): Nur IPv4: diese Seite verwaltet ausschließlich die IPv4-Konfiguration des Hosts (IP-Adresse/Prefix/Gateway sind alles IPv4-Felder) — per IPv6-Router-Advertisement gelernte DNS-Server (die resolvectl ungefragt mit auflistet) gehören nicht zu dieser Konfiguration und wirken hier nur verwirrend/falsch neben den IPv4-Werten.
### `def _read_network_state`
- **Zeile 2533** (srv/tesm/app.py): Netplan/systemd-networkd markiert eine per DHCP bezogene Adresse im Kernel mit "dynamic" — zuverlässiger als die YAML-Dateien unter /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern).
### `def _backup_network_config`
- **Zeile 2566** (srv/tesm/app.py): Eigene Override-Datei sichern statt der von cloud-init/dem Distributor mitgelieferten Dateien — None heißt "existierte vorher nicht", dann löscht der Revert sie wieder statt sie leer zu lassen.
### `def _apply_network_config`
- **Zeile 2638** (srv/tesm/app.py): Ohne diesen Override würde networkd die eigenen DNS-Server mit den per DHCP gelieferten zusammen verwenden, statt sie wie bei den anderen Backends (ipv4.ignore-auto-dns / dhcpcd) vollständig zu ersetzen.
- **Zeile 2645** (srv/tesm/app.py): Bestehende Datei einlesen und nur den Block DIESES Interfaces ersetzen/ergänzen -- ein Host kann mehrere Interfaces haben, jedes über einen eigenen Aufruf dieser Seite konfiguriert. Ein kompletter Neuaufbau des Dokuments (wie ursprünglich) hätte bei jedem Speichern alle ANDEREN, bereits konfigurierten Interfaces aus der Datei geworfen -- deren Konfiguration fiele dadurch auf die Distributor-Datei zurück (i.d.R. DHCP), obwohl an ihnen gar nichts geändert wurde (live reproduziert: zweites Interface eingerichtet, erstes verlor dadurch seine statische IP).
- **Zeile 2662** (srv/tesm/app.py): Eigene, niedrig priorisierte Datei statt der von cloud-init mitgelieferten (z.B. 50-cloud-init.yaml) — netplan wendet Dateien in alphabetischer Reihenfolge an und lässt spätere Werte pro Schlüssel gewinnen, das ist hier der etablierte Weg für eigene Overrides statt die Distributor-Datei zu editieren.
### `def _revert_network_config`
- **Zeile 2701** (srv/tesm/app.py): Datei existierte vor der Änderung nicht (erste Netplan- Änderung über diese Seite) — dann muss sie beim Rollback auch wieder verschwinden statt leer zurückzubleiben.
- **Zeile 2709** (srv/tesm/app.py): Das Rollback ändert die Netzwerkkonfiguration erneut — falls zwischenzeitlich (z.B. manuell auf der DHCP-Seite) wieder gestartet, aus demselben Grund wie beim ursprünglichen Anwenden vorsorglich stoppen statt mit einer ggf. nicht mehr passenden Range weiterlaufen zu lassen.
### `def settings`
- **Zeile 2767** (srv/tesm/app.py): nur eine ausstehende Änderung gleichzeitig
- **Zeile 2770** (srv/tesm/app.py): Damit die Seite beim nächsten Laden das zuletzt bearbeitete statt immer nur das alphabetisch erste Interface zeigt -- gerade bei mehreren Interfaces sonst verwirrend, siehe net_interface-Fallback weiter unten.
- **Zeile 2780** (srv/tesm/app.py): Eine vorher passende DHCP-Range kann nach einer IP-/Subnetzänderung plötzlich zum falschen Netz gehören — der Dienst wird deshalb vorsorglich gestoppt statt (ggf. falsch) weiterzulaufen. Bewusst kein automatischer Neustart: die Range muss der Admin aktiv auf der DHCP-Seite prüfen/bestätigen, siehe Kommentar oben am Kea-Block.
- **Zeile 2815** (srv/tesm/app.py): Ohne tzset() würde diese bereits laufende tesm.service- Instanz die neue Zeitzone für datetime.now() (Änderungslog, App-Log) erst nach einem Neustart übernehmen -- glibc cached die Zeitzone sonst ab dem ersten Zugriff im Prozess. poe.sh/date liest sie dagegen ohnehin bei jedem Aufruf frisch, da es ein neuer Prozess pro Durchlauf ist.
- **Zeile 2867** (srv/tesm/app.py): LDAP/Active Directory hat seit dem Umzug auf eine eigene Unterseite (Übersichtlichkeit) eine eigene Route -- siehe settings_ldap().
- **Zeile 2877** (srv/tesm/app.py): Übersicht ganz oben zeigt JEDES tatsächlich aktive Interface (eigene IPv4-Adresse vorhanden) gleichzeitig, nicht nur das gerade im Formular ausgewählte -- bei mehreren Interfaces sonst leicht der Eindruck, das zweite existiere für die App gar nicht (live so gemeldet: die Übersicht zeigte nach dem Umschalten der Auswahl weiterhin nur die Werte des ersten Interfaces).
### `def settings_network_state`
- **Zeile 2935** (srv/tesm/app.py): "network" ist ein ipaddress.IPv4Network-Objekt (siehe _detect_interface_network) -- nicht JSON-serialisierbar und vom Frontend hier auch nicht benötigt (nur ip/prefix/gateway/dns/mode).
### `def settings_import_export`
- **Zeile 2956** (srv/tesm/app.py): DHCP-Reservierungen (Kea DHCP)
- **Zeile 2958** (srv/tesm/app.py): Backend bewusst Kea statt isc-dhcp-server (seit 2022 EOL) — Kea bildet "globaler Wert, pro Client überschreibbar" nativ über Reservation-Options ab. Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen statt an "Konfiguration speichern" gekoppelt, da ein unerwarteter DHCP-Server ein Netzsegment stören kann.
- **Zeile 2967** (srv/tesm/app.py): Explizit statt dem eingebauten Kea-Default gesetzt (siehe _render_kea_config) — Anzeige der aktiven Leases (_dhcp_read_leases) liest genau diesen Pfad, damit beide Seiten garantiert übereinstimmen, statt sich auf einen distributionsabhängigen impliziten Default zu verlassen.
- **Zeile 2973** (srv/tesm/app.py): Interface, Range, Gateway und DNS sind seit der Mehrfach-Subnetz- Unterstützung KEINE globalen Einstellungen mehr, sondern je Zeile in der dhcp_subnets-Tabelle gepflegt (siehe _dhcp_subnets/_dhcp_matching_network) — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben. Domain, Lease-Zeiten und Ausgabepfad gelten dagegen für den gesamten Kea-Dienst und bleiben deshalb global.
### `def _dhcp_subnet_matches`
- **Zeile 3000** (srv/tesm/app.py): Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible Teilmenge statt der vollen Liste, orientiert an den in der Praxis gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex).
### `def _detect_interface_networks`
- **Zeile 3106** (srv/tesm/app.py): Fallback: irgendeine Default-Route, falls keine speziell an dieses Interface gebunden ist (typisch bei Hosts mit nur einer aktiven Schnittstelle).
- **Zeile 3122** (srv/tesm/app.py): Der Kernel markiert eine per DHCP bezogene Adresse mit "dynamic" — unabhängig vom Backend eine zuverlässige Quelle für den aktuellen DHCP/Statisch-Modus (genutzt von der netplan-Erkennung unten).
### `def _dhcp_reservation_candidates`
- **Zeile 3239** (srv/tesm/app.py): Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer. MAC (upper) -> {mac, ip, name, source, manual_id}
- **Zeile 3265** (srv/tesm/app.py): Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
### `def _dhcp_read_leases`
- **Zeile 3312** (srv/tesm/app.py): Kea's memfile-Backend HÄNGT bei jeder Verlängerung eine neue Zeile an, statt die alte zu ersetzen -- dieselbe Adresse steht oft dutzendfach in der Datei, mit jeweils weiter in die Zukunft geschobenem "expire". Da Kea eine Lease schon bei ~50% der Laufzeit erneuert (nicht erst bei Ablauf), sind kurz nach einer Verlängerung sowohl die alte als auch die neue Zeile noch "nicht abgelaufen" -- ohne Deduplizierung nach Adresse würde dieselbe Lease doppelt in der Übersicht auftauchen.
- **Zeile 3321** (srv/tesm/app.py): state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt), 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist eine tatsächlich vergebene, gültige Adresse.
- **Zeile 3330** (srv/tesm/app.py): Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc) noch nicht aus der CSV entfernt — nicht als aktiv anzeigen.
- **Zeile 3337** (srv/tesm/app.py): bereits eine neuere (oder gleich neue) Zeile für diese IP gesehen
- **Zeile 3346** (srv/tesm/app.py): Absolut UND als Epoch-ms für einen live tickenden Countdown im Browser (siehe initLeaseCountdowns in app.js) — eine reine Absolutzeit beim Seitenaufruf wirkt bei kurzen Lease-Zeiten (Default 600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen ist, bis man sie tatsächlich anschaut. Explizit als UTC ausgewiesen, da Kea intern in UTC-Epoch arbeitet.
### `def _dhcp_delete_lease`
- **Zeile 3383** (srv/tesm/app.py): lineterminator="\n" ist entscheidend: der csv-Modul-Default ist "\r\n" (RFC 4180), Keas eigener Writer schreibt aber reines "\n" -- ohne diese explizite Angabe interpretiert Kea beim nächsten Start das "\r" als Teil des letzten Spaltennamens ("pool_id\r") und lehnt die ganze Datei mit "invalid column" ab (live reproduziert).
- **Zeile 3392** (srv/tesm/app.py): Diese App läuft als root, öffnet die Datei aber mit "w" -- das legt sie neu an und würde sie root:root statt dem Kea-Dienstnutzer gehören lassen. Kea (i.d.R. eigener Systemnutzer, z.B. "_kea") kann dann zwar noch lesen, aber keine neuen Leases mehr hineinschreiben. Eigentümer/Rechte der Originaldatei deshalb explizit wiederherstellen (live reproduziert).
### `def _render_kea_config`
- **Zeile 3429** (srv/tesm/app.py): Standard-Optionen (is_standard) bekommen KEIN eigenes option-def — Kea kennt sie bereits nativ, eine Neudefinition würde die eingebaute duplizieren (siehe DHCP_STANDARD_OPTIONS). Nur eigene Options brauchen ein option-def, damit Kea Name/Typ/Code überhaupt kennt.
- **Zeile 3448** (srv/tesm/app.py): Gateway: expliziter Override hat Vorrang, sonst automatisch erkanntes Gateway dieses Hosts — für den Fall, dass Clients einen anderen Router bekommen sollen als den, über den dieser Host für dieses Netz selbst geht.
- **Zeile 3487** (srv/tesm/app.py): Kea nennt den Datei-Pfad des memfile-Backends "name", nicht "lease-file" (mit falschem Schlüssel lehnt Kea die Config mit "unexpected keyword" ab — live so reproduziert und gefixt).
- **Zeile 3496** (srv/tesm/app.py): Eigene Log-Datei statt Keas Distributions-Default (meist Syslog) — damit sie sich zusammen mit den anderen drei App-Logs (Live, Änderungen, App) über dieselbe logrotate-Konfiguration verwalten lässt. Bewusst OHNE maxsize/maxver (Keas eigene, größenbasierte Rotation) — die externe, zeitbasierte logrotate-Rotation (siehe _write_logrotate_config) soll die einzige Quelle für alle vier Logs sein, nicht zwei parallele, unterschiedliche Schemata. "loggers" gehört NACH Live-Test gegen eine echte Kea-Instanz zwingend UNTER "Dhcp4" -- ein Top-Level- "Logging"-Geschwisterobjekt (wie in älterer/anderer Dokumentation zu finden) lehnt Kea 2.4.1 mit einem Syntax-Fehler ab.
### `def settings_dhcp`
- **Zeile 3605** (srv/tesm/app.py): Nur noch die globalen, Kea-weiten Einstellungen — Interface/ Range/Gateway/DNS werden seit der Mehrfach-Subnetz-Unterstützung separat je Subnetz gepflegt (siehe add/edit/delete_dhcp_subnet).
- **Zeile 3873** (srv/tesm/app.py): Bewusst auch beim Starten nochmal live gegen das aktuelle Netz geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich der Dienst mit einer Range starten, die z.B. nach einem Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr zu einem echten Subnetz passt.
- **Zeile 3895** (srv/tesm/app.py): Auch deaktivieren, nicht nur stoppen — sonst würde systemd den Dienst nach einem Neustart des Hosts automatisch wieder starten, obwohl der Admin ihn hier bewusst abgeschaltet hat. Damit übersteht ein Host-Neustart genau den zuletzt bewusst gewählten Zustand (an oder aus), statt Kea unabhängig davon immer wieder hochzufahren.
- **Zeile 3924** (srv/tesm/app.py): Aktive Leases direkt aus Kea auslesen (siehe _dhcp_read_leases) und um ein reserved-Flag ergänzen — Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben, sollen auffallen.
- **Zeile 3932** (srv/tesm/app.py): Für die Anzeige je Subnetz-Zeile (Erkannt/Nicht erkannt + Grund) — als einfaches Dict statt verschachteltem Jinja-selectattr, aus demselben Grund wie option_values.get(...) unten im Template.
### `def settings_ldap`
- **Zeile 4027** (srv/tesm/app.py): Bind-Konto liegt in der eigenen service_accounts-Tabelle (siehe _ldap_settings()), nicht in settings -- ein neu eingegebenes Konto ERSETZT das alte komplett (purpose ist UNIQUE). Passwort nur überschreiben, wenn tatsächlich ein neues eingegeben wurde; Username kann auch ohne neues Passwort aktualisiert werden (dann bleibt das alte, verschlüsselte Passwort bestehen).
- **Zeile 4059** (srv/tesm/app.py): Prüft die AKTUELL im Formular eingetragenen Werte (auch wenn noch nicht gespeichert) -- gleiches Prinzip wie "Verbindung testen" bei Switchen/Geräten, das ebenfalls die Formularwerte statt gespeicherter Zugangsdaten testet.
- **Zeile 4082** (srv/tesm/app.py): Ohne Bind-Konto kann LDAP nicht funktionieren -- vorsorglich mit deaktivieren, statt eine "aktivierte", aber unbenutzbare Konfiguration stehen zu lassen.
### `def save_nav_order`
- **Zeile 4149** (srv/tesm/app.py): Unterpunkt-Reihenfolge je Gruppe — ein Feld "nav_child_order_<group_key>" pro Gruppe mit Kindern, Werte in der gewünschten Reihenfolge.
- **Zeile 4167** (srv/tesm/app.py): Import/Export — für Umzug auf eine neue Umgebung. Wählbare Kategorien (Clients, Switche, Zugangsdaten, Benutzer, Custom-Gruppen, LDAP/AD, DHCP, Logs) werden als EIN mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt. Der Import läuft zweistufig: 1) Datei+Passphrase hochladen -> entschlüsseln, Vorschau zeigt NUR die in der Datei tatsächlich enthaltenen Kategorien samt Anzahl. 2) Aus dieser Vorschau gezielt einzelne Kategorien zum Einspielen auswählen (siehe _pending_imports/import_apply) — eine Vollsicherung muss also nicht komplett eingespielt werden.
- **Zeile 4192** (srv/tesm/app.py): Diese Kategorien betreffen Konten/Rechte/Zugangsdaten für den gesamten Betrieb (können u.a. neue Admin-Konten anlegen oder das LDAP-Bind-Konto überschreiben) — Auswahl bleibt echten Admins vorbehalten, unabhängig vom sonst delegierbaren "Im-/Export"-Recht (gleiches Eskalationsschutz-Prinzip wie bei group_id=="admin" an anderer Stelle im Code).
### `def _export_devices`
- **Zeile 4207** (srv/tesm/app.py): Papierkorb-Einträge werden nie mitexportiert -- ein Restore/Purge ist eine lokale Aufräum-Entscheidung dieser Instanz, kein Bestand, der auf eine andere Umgebung übertragen werden soll.
### `def _export_users`
- **Zeile 4229** (srv/tesm/app.py): Nur lokale Konten -- AD/LDAP-Konten werden nie exportiert: Konto und Passwort werden über Windows/Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login ohnehin automatisch wieder an (siehe login()).
### `def _export_dhcp`
- **Zeile 4302** (srv/tesm/app.py): unveränderte Standard-Option -- wird auf dem Ziel automatisch angelegt
### `def _import_devices`
- **Zeile 4328** (srv/tesm/app.py): WHERE deleted_at IS NULL an der DO-UPDATE-Klausel: ein Import darf ein im Papierkorb liegendes Gerät nicht stillschweigend wiederbeleben -- die MAC bleibt dann einfach reserviert, bis es wiederhergestellt oder endgültig gelöscht wird (siehe Papierkorb-Doku).
### `def _import_users`
- **Zeile 4378** (srv/tesm/app.py): AD/LDAP-Konten mit gleichem Benutzernamen werden nie überschrieben -- deren Konto/Passwort wird über Active Directory/Windows verwaltet, nicht über einen Import hier.
- **Zeile 4389** (srv/tesm/app.py): Im Papierkorb liegender Benutzername bleibt reserviert -- ein Import darf ihn nicht stillschweigend wiederbeleben (siehe Wartung-Seite: erst wiederherstellen oder endgültig löschen).
### `def _import_groups`
- **Zeile 4420** (srv/tesm/app.py): Systemgruppen (aktuell nur "Benutzer") nie über Import überschreiben, ebenso wenig im Papierkorb liegende Gruppen -- der Name bleibt dann reserviert, bis wiederhergestellt oder endgültig gelöscht wird.
### `def _import_ldap`
- **Zeile 4451** (srv/tesm/app.py): set_setting() bewusst NICHT verwendet: das öffnet eine EIGENE sqlite3-Verbindung -- import_apply() ruft mehrere IMPORT_APPLIERS nacheinander auf DERSELBEN, bereits offenen "conn" auf und committet erst ganz am Ende. Ist zu diesem Zeitpunkt (z.B. durch eine vorher im selben Import verarbeitete Kategorie) bereits eine Schreib-Transaktion auf "conn" offen, blockiert set_settings()' eigene Verbindung darauf, bis der SQLite-Standard-Timeout zuschlägt -- "sqlite3.OperationalError: database is locked", live in Produktion reproduziert. Direkt auf der übergebenen "conn" schreiben vermeidet die zweite Verbindung komplett.
### `def _import_dhcp`
- **Zeile 4485** (srv/tesm/app.py): set_setting() bewusst NICHT verwendet -- siehe ausführlicher Kommentar in _import_ldap() (dieselbe "database is locked"-Falle bei mehreren IMPORT_APPLIERS auf derselben offenen Transaktion).
- **Zeile 4496** (srv/tesm/app.py): Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen.
### `def _import_logs`
- **Zeile 4550** (srv/tesm/app.py): Entschlüsselte Import-Vorschau zwischen Upload (import_data) und tatsächlichem Einspielen (import_apply) -- analog _pending_network_revert: In-Memory, token-basiert, TTL-begrenzt, damit die Passphrase nicht ein zweites Mal im Browser landen muss, um gezielt einzelne Kategorien auszuwählen.
### `def export_data`
- **Zeile 4569** (srv/tesm/app.py): Eigenes, von Import getrenntes Recht (settings_importexport.export, eigene X-Spalte in der Gruppentabelle) -- Export enthält entschlüsselte Passwörter im Klartext, daher bewusst nicht an das generische "Ändern"-Recht gekoppelt. Admins haben es über has_permission() automatisch, für andere Gruppen entscheidet ein Admin gezielt.
### `def import_data`
- **Zeile 4646** (srv/tesm/app.py): Legacy-Exporte (version 1, vor der Sektionsauswahl) hatten Devices/ Switches/Zugangsdaten direkt unter denselben Schlüsseln wie heute -- Struktur ist identisch, daher ohne Migration weiterverwendbar.
### `def import_apply`
- **Zeile 4724** (srv/tesm/app.py): Devices
### `def devices`
- **Zeile 4764** (srv/tesm/app.py): SSH-Anbindung optional — die meisten Geräte sind keine per SSH erreichbaren Hosts, nur wer bewusst Zugangsdaten auswählt/anlegt bekommt sie zugeordnet (siehe Wartungsseite).
- **Zeile 4803** (srv/tesm/app.py): --- Nur Switch-Zuordnung ändern (Modal "Switch ändern") ---
- **Zeile 4826** (srv/tesm/app.py): --- Normales Gerät bearbeiten ---
- **Zeile 4868** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht).
- **Zeile 4879** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen.
### `def toggle_device`
- **Zeile 4915** (srv/tesm/app.py): Aktivieren/Deaktivieren zählt jetzt zu "Ändern" (devices.edit) statt eines eigenen devices.toggle-Rechts.
- **Zeile 4940** (srv/tesm/app.py): Switches
### `def delete_switch`
- **Zeile 5077** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht).
- **Zeile 5092** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen.
- **Zeile 5106** (srv/tesm/app.py): Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche, damit nicht jeder Switch sein eigenes Login braucht. Eigene credentials.*- Rechte, unabhängig von switches.* (bewusst entkoppelt).
### `def credentials`
- **Zeile 5177** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht).
- **Zeile 5195** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- siehe Wartung-Seite für Wiederherstellen/endgültiges Löschen.
- **Zeile 5221** (srv/tesm/app.py): Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients.
- **Zeile 5223** (srv/tesm/app.py): Bewusst komplett getrennt vom PoE-Neustart auf dem Dashboard: der Dashboard-Button (devices.restart) löst IMMER einen PoE-Reset über den zugeordneten Switch aus. Die Aktionen hier laufen IMMER per SSH direkt auf dem Gerät selbst -- unabhängig davon, ob/wie es an einen Switch angeschlossen ist. Job-Status lebt bewusst nur im Prozessspeicher (wie das SSH-Terminal auch keine Sitzung persistiert) -- ein Neustart des tesm.service verwirft laufende Job-Anzeigen, nicht aber die zugrundeliegende SSH-Aktion selbst (die läuft serverseitig auf dem Zielgerät unabhängig vom Anzeigezustand hier weiter).
- **Zeile 5235** (srv/tesm/app.py): mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error", "message": str, "output": str, "started": str, "finished": str|None}
### `def _maintenance_devices`
- **Zeile 5271** (srv/tesm/app.py): Papierkorb — Clients/Switche/Zugangsdaten/Benutzer (nur lokale Konten)/ Custom-Gruppen landen beim "Löschen" nur noch hier (siehe delete_device/ delete_switch/delete_credential/delete_user/delete_group weiter oben), bis sie wiederhergestellt oder endgültig gelöscht werden -- manuell über die Buttons auf der Wartung-Seite, oder automatisch nach Ablauf von trash_retention_days (siehe Systemeinstellungen). AD/LDAP-Benutzer durchlaufen den Papierkorb NICHT (siehe delete_user) -- sie legen sich bei jedem gültigen AD-Login ohnehin automatisch neu an.
### `def _purge_device`
- **Zeile 5324** (srv/tesm/app.py): Per-Client-DHCP-Options sind an die MAC gekoppelt (kein FK, siehe dhcp_option_values.device_mac) -- werden hier mit aufgeräumt, statt nach einem endgültigen Löschen verwaist stehen zu bleiben.
### `def _purge_group`
- **Zeile 5347** (srv/tesm/app.py): AD-Gruppenzuordnungen, die auf genau diese Gruppe zeigten, würden sonst dauerhaft ins Leere laufen (siehe _ldap_group_mappings()-Anzeige "(gelöscht)" für den Fall, dass das trotzdem irgendwo übersehen wird).
### `def maintenance`
- **Zeile 5564** (srv/tesm/app.py): Abgeschlossene ERFOLGREICHE Aktionen werden bei jedem (erneuten) Laden der Seite zurückgesetzt (Status wieder "Noch keine Aktion") -- ein Erfolg ist nur so lange relevant, wie man ihn noch live über das Polling mitverfolgt; ein späterer Aufruf der Seite soll nicht dauerhaft eine ggf. Stunden alte Erfolgsmeldung zeigen. Fehlgeschlagene Aktionen bleiben dagegen bewusst sichtbar, bis eine neue Aktion sie überschreibt -- ein Fehler soll nicht durch einen einfachen Seiten-Reload versehentlich übersehen werden.
### `def papierkorb`
- **Zeile 5589** (srv/tesm/app.py): Vor dem Laden der Papierkorb-Listen abgelaufene Einträge endgültig aufräumen -- kein eigener Scheduler nötig (analog dem Import-Vorschau- Token-Aufräumen), die Seite wird ohnehin regelmäßig aufgerufen.
### `def _maintenance_run_update`
- **Zeile 5649** (srv/tesm/app.py): Zeilen kommen live rein statt erst am Ende komplett -- Grundlage für die automatisch mitscrollende Live-Ausgabe im Frontend. Jede Zeile bekommt ihren eigenen Zeitstempel (wann sie tatsächlich ankam, nicht nur Start/Ende des gesamten Updates). Puffer bewusst auf die letzten _MAINTENANCE_OUTPUT_MAX_LINES begrenzt (ganze Zeilen, kein hartes Zeichen-Limit mehr), damit ein sehr langes Update den Prozessspeicher nicht unbegrenzt wachsen lässt.
### `def on_line`
- **Zeile 5664** (srv/tesm/app.py): overall_timeout=600: ein echtes "apt upgrade" kann bei vielen ausstehenden Paketen mehrere Minuten dauern -- der kurze Standard-Timeout der interaktiven SSH-Aktionen (25s) wäre hier viel zu knapp; der bleibt für den reinen Verbindungsaufbau bestehen.
- **Zeile 5673** (srv/tesm/app.py): Eindeutiger Abschluss-Marker als letzte Zeile -- vorher endete die Ausgabe einfach mitten im rohen apt-Output, ohne erkennbar zu machen, ob das Update tatsächlich fertig oder nur abgeschnitten war.
- **Zeile 5695** (srv/tesm/app.py): Nach einem ausgelösten Neustart blieb der Job-Status bisher für immer auf "Neustart ausgelöst" stehen -- der eigentlich interessante Zeitpunkt (ist das Gerät wieder erreichbar?) wurde nie erfasst. Gleiche Ping-Invocation wie das bestehende Online/Offline-Monitoring in poe.sh ("ping -c 1 -W 2 <ip>"), für konsistentes Verhalten mit dem Dashboard.
### `def maintenance_reboot`
- **Zeile 5818** (srv/tesm/app.py): Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
- **Zeile 5820** (srv/tesm/app.py): Öffnet eine echte interaktive SSH-Sitzung im Browser, damit Host-Keys beim Erstkontakt bestätigt und Zugangsdaten geprüft werden können, bevor der Switch überhaupt gespeichert ist. Implementiert mit paramiko (reines Python) statt eines lokalen pty + System-`ssh`-Client — das läuft auf jedem Betriebssystem (Windows, Linux, macOS), auf dem die Flask-App läuft, und die SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut, niemals vom Browser/Client aus. Nur für eingeloggte Admins.
### `def _send_and_close`
- **Zeile 5845** (srv/tesm/app.py): Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall.
### `def _terminal_read_line`
- **Zeile 5881** (srv/tesm/app.py): Strg+C
### `def _authenticate_interactive`
- **Zeile 5955** (srv/tesm/app.py): "password" bewusst VOR "keyboard-interactive" versuchen, auch wenn der Server beides anbietet: viele Switches/APs (u.a. diverse ArubaOS- Varianten) bieten zwar "keyboard-interactive" an, deren Server-seitige Umsetzung ist aber teils nicht robust — z. B. eine erste Runde ganz ohne Prompts (laut RFC 4256 gültig, aber unsichtbar für den Nutzer), gefolgt von sofortigem Scheitern statt einer zweiten Runde mit dem eigentlichen Passwort-Prompt. Das erklärt exakt das beobachtete Symptom: im Terminal erscheint NIE ein Prompt, nur direkt "Zugangs- daten falsch". "password" ist der einfachere, zuverlässiger unterstützte Pfad (Ende-zu-Ende gegen einen echten Host verifiziert) und wird deshalb bevorzugt, wenn der Server ihn überhaupt anbietet.
### `def handler`
- **Zeile 5975** (srv/tesm/app.py): title/instructions zusätzlich anzeigen: manche Geräte legen den eigentlichen Hinweistext ("Password:") dort statt in prompt_list ab. Leere prompt_text-Strings (ebenfalls bei manchen Geräten üblich) bekommen einen sinnvollen Fallback, damit dem Nutzer nie eine Eingabeaufforderung ganz ohne sichtbaren Text präsentiert wird.
### `def ssh_terminal`
- **Zeile 6001** (srv/tesm/app.py): Fängt unerwartete Fehler ab und loggt sie, statt sie in flask-sock's Verbindungs-Handling verschwinden zu lassen.
### `def _ssh_terminal_inner`
- **Zeile 6032** (srv/tesm/app.py): Bewusst die Low-Level-Transport-API statt SSHClient.connect(): eine Passwortabfrage MITTEN im Verbindungsaufbau (nach Host-Key-Bestätigung, vor der eigentlichen Shell) ist mit der High-Level-API nicht möglich, da connect() Host-Key-Prüfung UND Authentifizierung in einem blockierenden Aufruf erledigt. Vorher führte das dazu, dass nie ein Passwort abgefragt wurde und jeder Login mit "No authentication methods available" scheiterte, sobald kein Passwort im initialen Payload mitgeschickt wurde (was nie der Fall war/ist).
### `def pump_output`
- **Zeile 6140** (srv/tesm/app.py): Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart)
- **Zeile 6142** (srv/tesm/app.py): Bewusst getrennt von _ssh_terminal_inner() oben: dort kann bei einem unbekannten Host-Key interaktiv "yes/no" nachgefragt werden (wie ein echtes ssh-CLI). Hier läuft nichts vor einem Menschen ab (Bulk-Aktion über mehrere Geräte per Thread) -- ein unbekannter/nicht vorher bestätigter Host-Key wird deshalb hart abgelehnt (RejectPolicy) statt automatisch akzeptiert (AutoAddPolicy wäre hier ein MITM-Risiko ohne jede Prüfung). Ein Host muss also immer zuerst einmal über "Verbindung testen" (das interaktive Terminal) bestätigt worden sein, bevor Wartungsaktionen für ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
- **Zeile 6153** (srv/tesm/app.py): Jeder Schritt einzeln benannt (statt nur mit "&&" verkettet), damit nach jedem Schritt ein eigener "Schritt N -> erfolgreich/fehlgeschlagen"-Marker ins Live-Log geschrieben werden kann (siehe _apt_upgrade_command()) -- apt/apt-get statt der literal vorgegebenen "apt"-Aufrufe, weil apt-get eine stabile, für Skripte gedachte Schnittstelle hat (die apt(8)-Manpage warnt ausdrücklich: "the apt command is intended to be pleasant for end users and does not need to be backward compatible" -- also nichts für Automatisierung).
### `def drain`
- **Zeile 6311** (srv/tesm/app.py): Ein letzter, nicht mit "\n" abgeschlossener Rest zählt noch als Zeile (apt beendet die letzte Ausgabezeile nicht immer mit Zeilenumbruch).
### `def _run_ssh_reboot`
- **Zeile 6442** (srv/tesm/app.py): Verbindungsabbruch durch den Neustart selbst ist erwartet
- **Zeile 6454** (srv/tesm/app.py): Logs
### `def get_log`
- **Zeile 6470** (srv/tesm/app.py): Dateiname als Header mitschicken, damit die Anzeige auch nach einem Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload aktuell bleibt.
### `def activity_log`
- **Zeile 6562** (srv/tesm/app.py): Manueller PoE-Neustart
### `def restart_device`
- **Zeile 6583** (srv/tesm/app.py): Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen (siehe poe.sh: der SSH-Restart-Block wird dort übersprungen) — dann lieber gar nicht erst einen "Neustart gestartet"-Erfolg vorgaukeln.
- **Zeile 6602** (srv/tesm/app.py): Users
### `def users`
- **Zeile 6625** (srv/tesm/app.py): Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl "admin" macht den Benutzer zum Admin, jede andere Auswahl ist eine normale Gruppe (oder keine). Nur echte Admins dürfen jemanden direkt als Admin anlegen (Eskalationsschutz) — delegierte "Benutzer anlegen"-Rechte reichen dafür nicht.
- **Zeile 6662** (srv/tesm/app.py): Nur Stammdaten + optional Passwort — Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert (s.u.).
- **Zeile 6665** (srv/tesm/app.py): Admin-Konten dürfen nur von echten Admins bearbeitet werden (u.a. Passwort-Reset) — verhindert, dass ein delegiertes "Benutzer bearbeiten"-Recht zur Übernahme eines Admin-Kontos missbraucht wird.
- **Zeile 6676** (srv/tesm/app.py): AD/LDAP-Konten werden vollständig über AD verwaltet (Name, E-Mail/UPN, Passwort) — hier bearbeiten würde nur mit dem nächsten Login wieder überschrieben werden. Einzig zulässige Aktion für solche Konten ist Sperren/Entsperren und die Gruppenzuweisung (siehe toggle_lock/assign_group).
- **Zeile 6718** (srv/tesm/app.py): Sowohl das Zuweisen ZUR Admin-Gruppe als auch das Ändern der Gruppe eines BESTEHENDEN Admins bleibt echten Admins vorbehalten (Eskalationsschutz) — ein delegiertes "Gruppe zuweisen"-Recht darf niemanden zum Admin machen oder einem Admin die Rechte entziehen.
- **Zeile 6730** (srv/tesm/app.py): Mindestens ein Admin muss bestehen bleiben.
- **Zeile 6755** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht).
- **Zeile 6764** (srv/tesm/app.py): Admin-Konten dürfen nur von echten Admins gelöscht werden.
- **Zeile 6770** (srv/tesm/app.py): AD/LDAP-Konten durchlaufen den Papierkorb nicht -- sie sind nur ein Schatten-Eintrag, der sich bei jedem gültigen AD-Login ohnehin automatisch neu anlegt. Ein "wiederherzu- stellender" Papierkorb-Eintrag wäre hier ohne Nutzen.
- **Zeile 6780** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- Mitgliedschaften bleiben dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen soll denselben Stand wiederherstellen wie vor dem Löschen).
- **Zeile 6792** (srv/tesm/app.py): Einzige Möglichkeit, einem AD/LDAP-Konto den Zugriff zu entziehen, ohne es in AD selbst zu sperren -- funktioniert aber genauso für lokale Konten.
- **Zeile 6827** (srv/tesm/app.py): Vorab-Zuweisung: ein AD-Benutzer wird gesucht (siehe /users/ldap_search) und hier -- noch VOR seinem ersten Login -- mit einer Gruppe angelegt. Ruft _ldap_lookup erneut auf statt den übermittelten Anzeigenamen zu vertrauen, damit Name/E-Mail garantiert frisch aus AD stammen.
### `def users_ldap_search`
- **Zeile 6902** (srv/tesm/app.py): Nur LEBENDE Konten ausblenden -- ein im Papierkorb liegender Name soll weiterhin gefunden werden (führt beim Hinzufügen zu einer klaren Fehlermeldung statt stillschweigend zu fehlen, siehe ldap_add_user).
- **Zeile 6914** (srv/tesm/app.py): Gruppen (Rechtesystem) — nur Admins dürfen Gruppen und deren Berechtigungen/Mitgliedschaften verwalten.
### `def groups`
- **Zeile 6936** (srv/tesm/app.py): Rechte direkt beim Anlegen setzbar (dieselbe Tabelle wie beim Bearbeiten, hier ohne eigenes "_submitted"-Flag, da eine neue Gruppe ohnehin noch keine Rechte hat).
- **Zeile 6966** (srv/tesm/app.py): Systemgruppen (aktuell nur die Standardgruppe 'Benutzer') sind standardmäßig in Name und Rechten fix. Ein Admin kann die Rechte über den expliziten "Freischalten"-Button in der UI trotzdem ändern (unlock_system_group=1) -- der Name bleibt IMMER fix, da DEFAULT_GROUP_NAME an anderer Stelle im Code über den Namen nach dieser Gruppe sucht (Umbenennen würde diese Lookups brechen). Mitgliedschaft (members_submitted) war schon immer frei verwaltbar.
- **Zeile 6988** (srv/tesm/app.py): Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind zwei getrennte Formulare, damit das Speichern des einen nicht versehentlich den Stand des anderen zurücksetzt. Die verstecken *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt wurde (leere Checkbox-Listen wären sonst nicht von "nichts ausgewählt" zu unterscheiden).
- **Zeile 7025** (srv/tesm/app.py): Bleibt bewusst admin-exklusiv, unabhängig von groups.edit — wer Admin ist/wird, darf nicht über ein delegierbares Recht gesteuert werden können.
- **Zeile 7040** (srv/tesm/app.py): Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt in Admin- und z.B. Standardgruppe auftauchen.
- **Zeile 7052** (srv/tesm/app.py): "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht).
- **Zeile 7063** (srv/tesm/app.py): Papierkorb statt echtem Löschen -- Rechte/Mitgliedschaften bleiben dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen soll denselben Stand wie vorher liefern).
- **Zeile 7106** (srv/tesm/app.py): Virtuelle "Admin"-Gruppe: rein informativ, damit auf einen Blick sichtbar ist, wer alles darf — Mitgliedschaft/Rechte werden weiterhin ausschließlich über den is_admin-Schalter auf der Users-Seite gesteuert, hier gibt es daher bewusst kein Formular.
- **Zeile 7138** (srv/tesm/app.py): threaded=True ist für das SSH-Web-Terminal (WebSocket) notwendig: die Verbindung hält den Thread offen, ohne Threading würde der einzelne Worker der Dev-Umgebung blockieren und WebSocket-Frames verstümmeln.
---
## create_db.py
### Modulebene
- **Zeile 23** (srv/tesm/create_db.py): Zugangsdaten (wiederverwendbare SSH-Logins, mehrere Switche können sich dieselben teilen; Passwort wird verschlüsselt gespeichert)
- **Zeile 36** (srv/tesm/create_db.py): Switches (Aruba-Switche) — referenzieren ihre Zugangsdaten statt sie direkt zu speichern
- **Zeile 51** (srv/tesm/create_db.py): Devices
- **Zeile 69** (srv/tesm/create_db.py): Benutzer
- **Zeile 86** (srv/tesm/create_db.py): Gruppen (Rechtesystem) — Admins besitzen ohnehin alle Rechte; Gruppen dienen dazu, normalen Benutzern gezielt einzelne Verwaltungsrechte für Devices/Switches zu geben, ohne sie zu Admins zu machen.
- **Zeile 99** (srv/tesm/create_db.py): Welche Berechtigungen eine Gruppe gewährt (siehe app.py: PERMISSIONS)
- **Zeile 109** (srv/tesm/create_db.py): Gruppenmitgliedschaft (ein Benutzer kann in mehreren Gruppen sein; effektive Rechte = Vereinigung aller Gruppen)
- **Zeile 121** (srv/tesm/create_db.py): Settings (z.B. Prüfintervall)
- **Zeile 129** (srv/tesm/create_db.py): DHCP: eigene Options-Definitionen (analog "Vordefinierte Optionen und Werte" bei einem Windows-DHCP-Server) — Code/Name/Typ/Beschreibung. is_standard markiert die vorbefüllten Standard-Optionen (Router/DNS/…) -- die können vom Admin nicht gelöscht werden, nur ihr Wert geleert (die tatsächliche Vorbefüllung übernimmt app.py: _ensure_schema()).
- **Zeile 145** (srv/tesm/create_db.py): DHCP: Werte je Option — device_mac='' bedeutet global (gilt für den ganzen Scope), ein gesetzter device_mac überschreibt den globalen Wert für genau dieses Gerät. Bewusst '' statt NULL für den globalen Fall, da SQLite NULL in UNIQUE-Constraints nicht als gleich behandelt (zwei NULL-Zeilen wären sonst kein Konflikt) — mit '' funktioniert ON CONFLICT zuverlässig für den Upsert des globalen Werts.
- **Zeile 162** (srv/tesm/create_db.py): DHCP: konfigurierte Subnetze (Interface + Range + optionales Gateway/DNS) -- ein Host kann mehrere Subnetze gleichzeitig bedienen, je eines pro Interface/Netz. sort_order steuert die Anzeige-/Verarbeitungsreihenfolge, enabled erlaubt ein Subnetz vorübergehend aus der geschriebenen Konfiguration auszuschließen, ohne es zu löschen.
- **Zeile 180** (srv/tesm/create_db.py): DHCP: manuelle Reservierungen für Geräte OHNE eigenen Client-Datensatz (Geräte mit MAC+IP in der devices-Tabelle bekommen ihre Reservierung automatisch, siehe _dhcp_reservation_candidates in app.py).
- **Zeile 192** (srv/tesm/create_db.py): Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten, ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose" ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt das alte.
- **Zeile 206** (srv/tesm/create_db.py): AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer App-Gruppe zugeordnet werden (additiv, siehe _ldap_resolve_app_groups in app.py) -- passt keine dieser Gruppen, greift die Standardgruppe.
- **Zeile 219** (srv/tesm/create_db.py): Änderungslog (Audit-Log) — wer hat wann was geändert. Bewusst OHNE PoE-Neustarts (die stehen im Live-Log von poe.sh).
- **Zeile 232** (srv/tesm/create_db.py): Standard-Setting: Prüfintervall 5 Minuten (interval = Minuten, check_interval = Sekunden)
- **Zeile 236** (srv/tesm/create_db.py): Standardgruppe 'Benutzer' — Systemgruppe (is_system), Name und Rechte sind über die UI fest und nicht änderbar. Jeder neu angelegte (nicht-admin) Benutzer landet automatisch darin (siehe app.py: users()). Der genaue Rechtesatz muss mit DEFAULT_GROUP_PERMISSIONS in app.py übereinstimmen.
---
## poe.sh
- **Kopfbereich**: PoE Device Check Script -- prueft Erreichbarkeit von Geraeten, startet PoE-Port bei Ausfall neu, loggt Ereignisse.
- **LOG_DIR/LOGFILE**: Eine einzige, durchgehend fortgeschriebene Datei statt vorher bei jedem (auch taeglich per tesm-check-restart.timer automatisch ausgeloesten) Dienst-Neustart ein neues rpi-<timestamp>.log -- Rotation/Aufbewahrung uebernimmt jetzt zentral logrotate (siehe app.py: _write_logrotate_config, einstellbar unter Systemeinstellungen -> Logs), nicht mehr dieses Skript selbst. Pfad muss mit TESM_LOG_DIR/live.log in app.py uebereinstimmen.
- **SLEEP**: Intervall aus DB abrufen.
- **disable_poe()/enable_poe()**: bewusst OHNE expliziten "timeout { exit 1 }"/"eof { exit 1 }"-Abbruch in den einzelnen expect-Schritten -- ein nicht matchender Schritt laesst expect nach Ablauf von "set timeout 5" einfach zur naechsten Zeile weiterlaufen, statt abzubrechen. Das ist absichtlich so belassen: ein Versuch, hier "echte" Fehlererkennung nachzuruesten (Exit-Code 0 nur bei bestaetigtem Erfolg je Schritt), hat sich live als Regression herausgestellt. Grund: das Login-Prompt-Pattern "-re \".*> $\"" matcht nur ein Operator-Prompt ("switchname>"). Auf ArubaOS-Switches, bei denen der SSH-Account (z.B. "admin") mit Manager-Rechten direkt im privilegierten Modus landet ("switchname#" statt "switchname>", bestaetigt an einem 2930M/"ST-2000-Sued"), matcht das Pattern nie -- mit einem harten Timeout-Abbruch waere das faelschlich als Fehlschlag gewertet worden, obwohl Passwort und Verbindung in Ordnung waren. Diese Funktionen sind daher exakt auf den Stand der urspruenglichen, in Produktion bewaehrten Fassung zurueckgesetzt.
- **check_device()**: Ein Geraete-Check (Ping + ggf. PoE-Neustart) als eigene Funktion, damit sie sowohl vom manuellen Neustart (einzeln, synchron) als auch von der Hauptschleife (parallel im Hintergrund) verwendet werden kann. Nur PoE neu starten, wenn dem Geraet auch tatsaechlich ein Switch zugeordnet ist (switch_ip) -- ein reiner Port-Wert ohne Switch (switch_ip leer) darf keinen SSH-Versuch mit leeren Zugangsdaten ausloesen.
- **Hauptschleife**: Jedes Geraet wird als eigener Hintergrund-Job geprueft, statt strikt nacheinander in einer einzigen Schleife -- bei einer groesseren Geraeteliste (z.B. nach einem Import) haette ein einzelnes bereits ausgefallenes Geraet mit langsamem/nicht erreichbarem Switch (jeder expect-Versuch bis zu ~5s Timeout je Verbindungsschritt) sonst die Pruefung ALLER nachfolgenden Geraete verzoegert. "< <(...)" (Process Substitution) statt einer Pipe, damit die Hintergrund-Jobs Kinder dieser Shell bleiben und "wait" sie unten tatsaechlich erfassen kann (bei "cmd | while ...; done" liefe die Schleife in einer eigenen Subshell, deren Hintergrund-Jobs von aussen nicht mehr sichtbar waeren). Auf alle in diesem Durchlauf gestarteten Geraete-Checks warten, bevor das Intervall abgewartet wird -- sonst koennten sich Durchlaeufe ueberlappen (z.B. mehrere parallele expect-Sitzungen zum selben Switch aus zwei verschiedenen, noch laufenden Zyklen gleichzeitig).
---
## _dashboard_tiles.html
### Jinja-/HTML-Kommentare
- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im scripts-Block von index.html).
---
## credentials.html
### Jinja-/HTML-Kommentare
- Modal: Neue Zugangsdaten
- Modal: Zugangsdaten bearbeiten
---
## devices.html
### Jinja-/HTML-Kommentare
- Modal: Neues Gerät
- Modal: Bearbeiten
- Modal: Switch ändern
- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys und Prüfen der Zugangsdaten, bevor das Gerät gespeichert wird)
### JavaScript-Kommentare
- Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach vorherigem Umschalten auf "neu" ohne zu speichern).
- SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum Identisches Muster wie in switches.html.
---
## groups.html
### Jinja-/HTML-Kommentare
- Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients). "Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B. "D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht anklickbare Checkbox dargestellt statt zu fehlen — dadurch bleiben alle Spalten bündig untereinander. readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen (Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox nebeneinander (siehe .permission-groups-row), damit die ganze Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat.
- compact=true (Gruppen-Dropdown auf der Hauptseite) verkleinert Zellen- Padding/Abstände gegenüber dem "Neue Gruppe"-Modal (compact=false, bleibt unverändert) — gleicher Aufbau, nur enger, da im Dropdown mehr Breite zur Verfügung steht als im schmalen Modal und es sonst auseinandergezogen wirkt.
- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer oben (eigenes <tbody data-sort-pinned>).
- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit die Sortierung beide Zeilen gemeinsam verschiebt.
- Modal: Admin-Mitglieder verwalten
- Modals: Mitglieder pro Gruppe verwalten
- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder werden danach über die Gruppentabelle zugeordnet (die Gruppe muss dafür erst existieren).
### JavaScript-Kommentare
- Filtert ganze <tbody>-Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam, analog zur Sortierung) statt einzelner <tr> -- die gepinnte Admin-Gruppe (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff. Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung. Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist — spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP). Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin alle disabled. Zusätzlich zum disabled-Attribut (das der Browser nur dezent abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt "Bereich gesperrt" auf den ersten Blick kaum auf. R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit, Anwahl von W/E/D/X wählt automatisch R mit an.
---
## index.html
### Jinja-/HTML-Kommentare
- Device detail modal
- Schließen bewusst als letztes Element im DOM: modal-footer nutzt justify-content:flex-end, damit landet der zuletzt gerenderte Button ganz rechts — Schließen soll unabhängig davon, welche Aktions-Buttons gerade sichtbar sind, immer rechts stehen.
- Restart confirm modal
### JavaScript-Kommentare
- Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/ Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig greifen können (z.B. "offline" + Namenssuche). Welche Abschnitte (per data-status) manuell eingeklappt wurden — per Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt über Live-Updates hinweg erhalten (siehe applyCollapseState()). Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem Live-Update erneut angewendet, damit aktive Filter erhalten bleiben (die alten DOM-Knoten werden beim Austausch verworfen). Aktive Stat-Kachel synchron halten — läuft hier statt nur im Klick-Handler, damit der Zustand auch nach einem Live-Update (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt. Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das Kachel-Raster darunter, der Titel bleibt klickbar sichtbar. Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das Dashboard auf genau diesen Status; erneuter Klick auf die bereits aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf. Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt ein/aus, unabhängig vom Status-Filter oben. Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird nach jedem Live-Update erneut auf die frisch eingefügten Kacheln angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden. Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen (siehe restart_device()/poe.sh) — der Button wird für diese Geräte komplett ausgeblendet statt nur ausgegraut, damit kein Neustart vorgegaukelt wird, der tatsächlich nichts bewirkt. Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht. + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat. Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt dieses Durchlaufs synchronisieren (initCheckTimer() in app.js liest das Attribut bei jedem Tick neu ein).
---
## maintenance.html
### Jinja-/HTML-Kommentare
- Bewusst KEIN <form> hier: das säße dann innerhalb des äußeren #updateForm (verschachtelte <form>-Elemente sind ungültiges HTML) -- der Browser hätte das äußere Formular an dieser Stelle vorzeitig geschlossen bzw. dessen Inhalt zerrissen. "Update starten" hat dadurch live IMMER zusätzlich das erste/oberste Gerät mitgeschickt, auch ganz ohne angehakte Checkbox. Das eigentliche <form> für den Neustart steht daher unten AUSSERHALB von #updateForm; der Button hier referenziert es nur über "form=".
- Ein eigenständiges, NICHT verschachteltes <form> je Gerät für den einzelnen "Neustart"-Button (siehe Kommentar oben in der Tabelle) -- jedes referenziert nur genau eine MAC, unabhängig vom Checkbox-Zustand im großen #updateForm.
### JavaScript-Kommentare
- Checkboxen beim Laden IMMER zurücksetzen: Browser stellen den Checked-Zustand von Formularen nach einem Reload/Redirect (z.B. genau der Redirect nach "Update starten") gerne aus dem eigenen Verlauf wieder her, unabhängig davon, dass das frisch gerenderte HTML gar kein "checked" enthält -- ein einmal angehaktes Gerät (z.B. beim ersten Ausprobieren) blieb dadurch bei jedem weiteren Aufruf der Seite unsichtbar mitausgewählt und lief bei "Update starten" immer zusätzlich mit, ganz gleich was man gerade neu ankreuzt (live genau so reproduziert). Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert, sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom dazugehörigen Geräte-Namen ein-/ausblenden. "Klebt" am unteren Rand, solange man nicht selbst nach oben gescrollt hat -- reine Update-Ausgabe kann bei vielen Paketen sehr lang werden, ohne das würde man sie sonst manuell nachziehen müssen, um live mitzulesen. Ein kleiner Schwellwert (20px) toleriert, dass "ganz unten" durch Rundungsfehler beim Scrollen selten exakt 0 ist.
---
## settings_dhcp.html
### Jinja-/HTML-Kommentare
- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist ungültiges HTML und wird vom Browser aus der Tabelle heraus "foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen Formular und Feldern zerrissen werden.
- Neu ausgewählte Felder landen hier direkt unter dem Dropdown (siehe poeShowOptionField in app.js), statt irgendwo in der u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte Overrides stehen von Anfang an hier drin.
- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben bei den Reservierungen — dieselbe HTML-Validität-Begründung).
---
## switches.html
### Jinja-/HTML-Kommentare
- Bearbeiten-Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist ungültiges HTML — Browser "foster-parenten" es dann aus der Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung zwischen Formular und Buttons (this.closest('form') lieferte dadurch null, "Verbindung testen" öffnete kein Terminal mehr).
- Modal: Neuer Switch
- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird)
### JavaScript-Kommentare
- Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein
- Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach vorherigem Umschalten auf "neu" ohne zu speichern).
- SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
- Bewusst kein Toast bei "error": manche Browser melden beim Schließen einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt. Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen
---
## users.html
### Jinja-/HTML-Kommentare
- Modal: Neuer Benutzer
- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle)
- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten Login des AD-Benutzers)
- Modal: Gruppe zuweisen (inkl. Admin als Auswahl)
---
## logs.html
### JavaScript-Kommentare
- Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart bei Ausfall) einheitlich orange markieren. Dateiname mit anzeigen — ändert sich nach einem Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload.
---
## logs_kea.html
### JavaScript-Kommentare
- Kea-Zeilenformat: "<ts> <SEVERITY> [logger/pid.tid] MESSAGE_ID text". Grobe Einfärbung nach Schweregrad, ohne die Logik des Live-Logs (online/offline) hier künstlich nachzubilden.
---
## settings.html
### JavaScript-Kommentare
- Lädt beim Umschalten des Interfaces (Netzwerkeinstellungen) dessen tatsächlichen Ist-Zustand nach -- ohne das würden die Felder weiter die Werte des zuvor angezeigten Interfaces zeigen, obwohl gerade ein anderes ausgewählt wurde (bei mehreren Interfaces leicht zu Verwechslungen geführt, siehe /settings/network_state).
+114 -1176
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.0.0"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
"tesm-core>=2.0.0",
"tesm-licensing>=2.0.0",
"paramiko>=3.5",
"PyYAML>=6.0",
"ldap3>=2.9",
"Flask-Sock>=0.7",
]
[project.optional-dependencies]
server = ["gunicorn>=23.0"]
[project.scripts]
tesm-admin = "tesm.cli:main"
tesm-monitor = "tesm.worker:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
tesm = ["templates/**/*.html", "static/**/*"]
+296
View File
@@ -0,0 +1,296 @@
"""TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen.
Diese Datei ist die Anwendungsfabrik. Sie tut drei Dinge und nicht mehr:
Konfiguration laden, das gemeinsame Geruest aus ``tesm_core`` aufbauen, die
eigenen Blueprints anmelden. Der Vorgaenger hatte an dieser Stelle eine
``app.py`` mit mehreren tausend Zeilen -- Routen, SQL, SSH, DHCP-Erzeugung und
Templates in einer Datei.
"""
from __future__ import annotations
import logging
from typing import Any
from flask import Flask, current_app
from tesm_core.app_factory import create_base_app
from tesm_core.config import load_core_config
from tesm_core.extension import core
from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS
from .schema import TESM_MIGRATIONS
from .settings_spec import TESM_SETTINGS
__version__ = "2.0.0"
APP_KEY = "tesm"
APP_NAME = "TESM"
APP_SHORT = "TE"
APP_EDITION = "Geraeteverwaltung"
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
#: was die Anwendung tut, und das gehoert auf die erste Seite.
AUTH_TAGLINE = (
"UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · "
"BETRIEBSSYSTEM GEBEN (PXE)"
)
ENV_PREFIX = "TESM_"
logger = logging.getLogger(__name__)
def create_app(**overrides: Any) -> Flask:
config = load_core_config(
app_key=APP_KEY, app_name=APP_NAME, env_prefix=ENV_PREFIX
)
app = create_base_app(
config=config,
permissions=PERMISSIONS,
nav=NAVIGATION,
role_presets=ROLE_PRESETS,
app_migrations={"tesm": TESM_MIGRATIONS},
settings_specs=TESM_SETTINGS,
import_name="tesm",
app_short=APP_SHORT,
app_edition=APP_EDITION,
auth_tagline=AUTH_TAGLINE,
app_version=__version__,
enable_license=True,
)
_register_blueprints(app)
_register_registry(app)
_register_trash_providers(app)
_startup_tasks(app)
if overrides:
app.config.update(overrides)
return app
def _register_blueprints(app: Flask) -> None:
from .blueprints import (
backup,
credentials,
dashboard,
devices,
dhcp,
fileshare,
license as license_bp,
logs,
maintenance,
settings,
switches,
hostkeys,
terminal,
)
app.register_blueprint(dashboard.bp)
app.register_blueprint(devices.bp)
app.register_blueprint(switches.bp)
app.register_blueprint(credentials.bp)
app.register_blueprint(hostkeys.bp)
app.register_blueprint(maintenance.bp)
app.register_blueprint(dhcp.bp)
app.register_blueprint(fileshare.bp)
app.register_blueprint(logs.bp) # tesmlogs: Kea + Neustarts
app.register_blueprint(settings.bp)
app.register_blueprint(license_bp.bp)
app.register_blueprint(backup.bp)
terminal.init_app(app)
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
app.add_url_rule("/", "index", dashboard.index_view)
def _register_registry(app: Flask) -> None:
"""Haengt app-spezifische Bausteine in den Kern ein."""
from .services import inventory, monitor
extension = core(app)
extension.registry["reencrypt_secrets"] = inventory.reencrypt_secrets
# Angaben fuer die nginx-Vorlage im Kern.
extension.registry["upstream"] = extension.config.bind
extension.registry["static_root"] = str(extension.config.base_dir / "static")
extension.registry["websocket_path"] = "/ws/"
extension.registry["public_dashboard_url"] = "/"
extension.registry["live_log"] = monitor.LiveLog(extension.config.log_dir / "live.log")
extension.registry["status_pill_builder"] = _status_pills
extension.login_hooks.append(_mount_shares_after_login)
extension.logout_hooks.append(_release_shares_on_logout)
def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tuple) -> None:
"""Bindet nach der Anmeldung die Freigaben dieses Benutzers ein.
Ohne diesen Haken blieb der ganze Freigabe-Teil Theorie: Zuordnungen liessen
sich anlegen, es entstand aber nie eine Einbindung, und weil der Menuepunkt
an vorhandenen Einbindungen haengt, war die Seite nie zu sehen.
"""
from flask import session
from .services import fileshare
extension = core()
if extension.license is not None and not extension.license.module_active("fileshare"):
return
with extension.database.transaction(conn):
messages = fileshare.mount_for_login(
conn,
user_id=user.id,
session_id=getattr(session, "sid", ""),
username=user.username,
password=secret,
group_dns=group_dns,
)
for message in messages:
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message)
def _release_shares_on_logout(conn: Any, user: Any) -> None:
"""Haengt die Freigaben dieses Benutzers beim Abmelden wieder aus.
Eingebunden wird mit dem Konto des Benutzers. Bliebe die Einbindung nach
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
die Zeitgrenze greift. Das ist zu lange.
"""
from .services import fileshare
extension = core()
with extension.database.transaction(conn):
released = fileshare.release_all_for_user(conn, user.id)
if released:
current_app.logger.info(
"Dateifreigaben fuer %s geloest: %d", user.username, released
)
def _status_pills(user: Any) -> list[dict[str, str]]:
"""Zusaetzliche Anzeigen in der Topbar."""
if not user.is_authenticated:
return []
from tesm_core.extension import db
from .services import kea, monitor
extension = core()
pills: list[dict[str, str]] = []
counts = monitor.dashboard_counts(db())
if counts["offline"]:
pills.append(
{
"label": f"{counts['offline']} offline",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.",
}
)
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None:
if extension.license.module_active("dhcp") and extension.sysops is not None:
state = extension.sysops.service_state(kea.SERVICE_UNIT)
if state["installed"] == "ja":
active = state["active"] == "active"
pills.append(
{
"label": "DHCP aktiv" if active else "DHCP gestoppt",
"css_class": "" if active else "status-pill--warning",
"dot": "1",
"dot_class": "",
"title": f"kea-dhcp4-server: {state['active']}, {state['enabled']}",
}
)
return pills
def _register_trash_providers(app: Flask) -> None:
from .services import inventory
with app.app_context():
trash_views.register(
trash_views.TrashProvider(
key="devices",
label="Clients",
icon="cpu",
list_fn=lambda conn: inventory.trash_entries(
conn, "devices", label_column="name", detail_columns=("mac", "ip")
),
restore_fn=lambda conn, ident: inventory.restore_row(
conn, "devices", int(ident), unique_column="mac"
),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.",
)
)
trash_views.register(
trash_views.TrashProvider(
key="switches",
label="Switche",
icon="switch",
list_fn=lambda conn: inventory.trash_entries(
conn, "switches", label_column="hostname", detail_columns=("ip", "model")
),
restore_fn=lambda conn, ident: inventory.restore_row(
conn, "switches", int(ident), unique_column="hostname"
),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "switches", int(ident)),
)
)
trash_views.register(
trash_views.TrashProvider(
key="credentials",
label="Zugangsdaten",
icon="key",
list_fn=lambda conn: inventory.trash_entries(
conn, "credentials", label_column="name", detail_columns=("username", "category")
),
restore_fn=lambda conn, ident: inventory.restore_row(
conn, "credentials", int(ident), unique_column="name"
),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.",
)
)
def _startup_tasks(app: Flask) -> None:
"""Einmalige Aufgaben beim Start des echten Webprozesses.
Der Vorgaenger fuehrte solche Aufgaben bei **jedem** Modul-Import aus -- ein
Hilfsskript, das ``app.py`` importierte, raeumte dadurch die
Datei-Einbindungen fremder, aktiver Sitzungen ab. Darum laufen sie hier nur,
wenn die systemd-Unit den Prozess ausdruecklich als Webprozess markiert hat.
"""
extension = core(app)
if not extension.config.web_process:
logger.info(
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert."
)
return
from .services import maintenance
with extension.database.session() as conn, extension.database.transaction(conn):
orphaned = maintenance.mark_orphaned_jobs(conn)
if orphaned:
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned)
if extension.license is not None:
import socket
extension.license.start_heartbeat(hostname=socket.gethostname())
# Fuer ``gunicorn tesm:app``
def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt
return create_app()
app = None # wird von wsgi.py gesetzt
@@ -0,0 +1 @@
"""Blueprints von TESM."""
+236
View File
@@ -0,0 +1,236 @@
"""Sicherung und Uebertragung: verschluesselter Export, zweistufiger Import."""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, Response, flash, redirect, render_template, request, session, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError
from ..services import transfer
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token"
def _audit(action: str, detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _allowed_categories() -> list[transfer.Category]:
allowed: list[transfer.Category] = []
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
def overview():
extension = core()
conn = db()
preview = None
token = session.get(PREVIEW_SESSION_KEY, "")
if token:
try:
payload = transfer.load_preview(conn, token, actor=current_user.username)
preview = {"token": token, "summary": transfer.summarize(payload)}
except transfer.TransferError:
session.pop(PREVIEW_SESSION_KEY, None)
license_hint = ""
if extension.license is not None:
try:
extension.license.require("export")
except LicenseError as exc:
license_hint = str(exc)
return render_template(
"tesm/backup.html",
categories=_allowed_categories(),
all_categories=transfer.CATEGORIES,
preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
license_hint=license_hint,
can_secrets=current_user.has("backup.secrets"),
)
@bp.post("/export")
@permission_required("backup.export")
@reauth_required
def export():
extension = core()
conn = db()
if extension.license is not None:
try:
extension.license.require("export")
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
return redirect(url_for("backup.overview"))
include_secrets = (
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets")
)
passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or ""
if passphrase != repeat:
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
return redirect(url_for("backup.overview"))
try:
payload = transfer.build_export(
conn, extension.keystore, categories=chosen, include_secrets=include_secrets
)
envelope = transfer.seal(payload, passphrase)
except transfer.TransferError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
_audit(
"backup.exported",
f"Kategorien: {', '.join(chosen)}" + (" (mit Geheimnissen)" if include_secrets else ""),
severity="warning" if include_secrets else "notice",
)
stamp = datetime.now().strftime("%Y%m%d-%H%M")
return Response(
envelope.encode("utf-8"),
mimetype="application/json",
headers={
"Content-Disposition": f'attachment; filename="tesm-sicherung-{stamp}.json"',
"Cache-Control": "no-store",
},
)
@bp.post("/import/vorschau")
@permission_required("backup.execute")
@reauth_required
def import_preview():
conn = db()
upload = request.files.get("backup_file")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger")
return redirect(url_for("backup.overview"))
try:
payload, header = transfer.unseal(raw, request.form.get("passphrase") or "")
except transfer.TransferError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
with transaction():
token = transfer.store_preview(conn, payload, actor=current_user.username)
session[PREVIEW_SESSION_KEY] = token
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.",
"success",
)
return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen")
@permission_required("backup.execute")
@reauth_required
def import_apply():
extension = core()
conn = db()
token = session.get(PREVIEW_SESSION_KEY, "")
if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
return redirect(url_for("backup.overview"))
try:
payload = transfer.load_preview(conn, token, actor=current_user.username)
except transfer.TransferError as exc:
session.pop(PREVIEW_SESSION_KEY, None)
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
# Wichtig: alles in **einer** Transaktion auf **einer** Verbindung. Der
# Vorgaenger oeffnete hier ueber set_setting() eine zweite Verbindung und lief
# dadurch in "database is locked".
with transaction():
report = transfer.apply_import(
conn,
extension.keystore,
payload,
categories=chosen,
set_setting=lambda key, value: extension.settings.set(
conn, key, value, actor=current_user.username
),
)
transfer.drop_preview(conn, token)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="backup.imported",
detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen"
for key, counts in report.items()
),
ip=client_ip(),
severity="warning",
)
session.pop(PREVIEW_SESSION_KEY, None)
lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY
]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen")
@permission_required("backup.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
with transaction() as conn:
transfer.drop_preview(conn, token)
flash("Zwischenstand verworfen.", "info")
return redirect(url_for("backup.overview"))
@@ -0,0 +1,143 @@
"""Zugangsdaten: pflegen und -- mit eigenem Recht -- im Klartext ansehen."""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from ..services import inventory
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
@bp.get("/")
@permission_required("credentials.view")
def overview():
conn = db()
return render_template(
"tesm/credentials.html",
credentials=inventory.list_credentials(conn),
categories=inventory.CREDENTIAL_CATEGORIES,
)
@bp.post("/neu")
@permission_required("credentials.create")
def create():
try:
with transaction() as conn:
inventory.create_credential(
conn,
core().keystore,
name=request.form.get("name", ""),
username=request.form.get("username", ""),
secret=request.form.get("secret", ""),
category=request.form.get("category", "switch"),
description=request.form.get("description", ""),
actor=current_user.username,
)
_audit(conn, "credential.created", request.form.get("name", ""), severity="notice")
flash("Zugangsdaten angelegt.", "success")
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>")
@permission_required("credentials.edit")
def update(credential_id: int):
try:
with transaction() as conn:
changes = inventory.update_credential(
conn,
core().keystore,
credential_id,
name=request.form.get("name", ""),
username=request.form.get("username", ""),
secret=request.form.get("secret") or None,
category=request.form.get("category", "switch"),
description=request.form.get("description", ""),
actor=current_user.username,
)
if changes:
_audit(
conn,
"credential.updated",
request.form.get("name", str(credential_id)),
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/loeschen")
@permission_required("credentials.delete")
def delete(credential_id: int):
try:
with transaction() as conn:
entry = next(
(c for c in inventory.list_credentials(conn) if c.id == credential_id), None
)
if entry is None:
abort(404)
inventory.delete_credential(conn, credential_id)
_audit(conn, "credential.deleted", entry.name, severity="warning")
flash("Zugangsdaten in den Papierkorb verschoben.", "success")
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/anzeigen")
@permission_required("credentials.secrets")
@reauth_required
def reveal(credential_id: int):
"""Zeigt ein gespeichertes Passwort im Klartext.
Drei Huerden, bewusst: eigenes Recht (``credentials.secrets``), frische
Passwortbestaetigung und ein Eintrag im Aenderungsprotokoll mit Zeitpunkt,
Benutzer und IP. Der Vorgaenger kannte keine dieser Huerden -- wer
Zugangsdaten bearbeiten durfte, konnte sie exportieren und damit lesen.
"""
conn = db()
entry = next((c for c in inventory.list_credentials(conn) if c.id == credential_id), None)
if entry is None:
abort(404)
try:
username, secret = inventory.credential_secret(conn, core().keystore, credential_id)
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("credentials.overview"))
with transaction():
_audit(
conn,
"credential.secret_revealed",
entry.name,
"Klartextanzeige",
severity="warning",
)
return render_template(
"tesm/credential_reveal.html", entry=entry, username=username, secret=secret
)
+148
View File
@@ -0,0 +1,148 @@
"""Statusuebersicht -- die Startseite.
Bewusst auch ohne Anmeldung erreichbar (abschaltbar ueber die Einstellung
``dashboard_public``): sie ist ein Informationsboard fuer jeden im Netz, damit
niemand sich anmelden muss, um zu sehen, ob ein Geraet laeuft.
Ohne Anmeldung zeigt sie **nur** Name und Erreichbarkeit aktiver Geraete. Keine
IP-Adressen, keine MAC, keine Switch-/Portzuordnung, keine Zaehler zu
deaktivierten Geraeten -- der Vorgaenger gab hier deutlich mehr heraus, als ein
oeffentliches Board hergeben sollte.
"""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, render_template, request
from tesm_core.extension import core, db
from ..services import monitor
bp = Blueprint("dashboard", __name__)
def _maintenance_active() -> bool:
from tesm_core.extension import core
manager = core().license
return manager is None or manager.module_active("maintenance")
def _public_allowed() -> bool:
return bool(core().settings.get(db(), "dashboard_public"))
def _tiles(*, authenticated: bool) -> dict[str, Any]:
from tesm_core.auth.models import current_user
from ..services import inventory, restart
conn = db()
counts = monitor.dashboard_counts(conn)
rows = inventory.list_devices(conn, only_active=not authenticated)
detailed = authenticated and current_user.has("devices.view")
tiles: list[dict[str, Any]] = []
for row in rows:
entry: dict[str, Any] = {
"id": int(row["id"]),
"name": str(row["name"]),
"state": "disabled" if not row["is_active"] else str(row["state"]),
"last_change_at": row["last_change_at"],
"category": str(row["category"] or ""),
"location": str(row["location"] or ""),
}
if detailed:
entry.update(
{
"ip": str(row["ip"]),
"mac": str(row["mac"]),
"switch": str(row["switch_hostname"] or ""),
"port": str(row["port"] or ""),
"latency_ms": row["latency_ms"],
"last_check_at": row["last_check_at"],
"restart_count": row["restart_count"] or 0,
"credential_category": str(row["credential_category"] or ""),
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
"restart_methods": restart.available(
row, maintenance_active=_maintenance_active()
),
}
)
tiles.append(entry)
sections = [
{
# Vor "nicht erreichbar": ein Geraet, das gerade neu startet, ist
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
"key": "restarting",
"label": "Neustart laeuft",
"kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
},
{
"key": "offline",
"label": "Nicht erreichbar",
"kind": "danger",
"tiles": [tile for tile in tiles if tile["state"] == "offline"],
},
{
"key": "online",
"label": "Erreichbar",
"kind": "ok",
"tiles": [tile for tile in tiles if tile["state"] == "online"],
},
{
"key": "unknown",
"label": "Noch nicht geprueft",
"kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
},
]
if authenticated:
sections.append(
{
"key": "disabled",
"label": "Deaktiviert",
"kind": "muted",
"tiles": [tile for tile in tiles if tile["state"] == "disabled"],
}
)
return {
"counts": counts,
"sections": sections,
"detailed": detailed,
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
}
@bp.get("/uebersicht")
def index():
from tesm_core.auth.models import current_user
if not current_user.is_authenticated and not _public_allowed():
from flask import redirect, url_for
return redirect(url_for("auth.login"))
data = _tiles(authenticated=bool(current_user.is_authenticated))
return render_template("tesm/dashboard.html", **data)
def index_view():
"""Wird als Endpunkt ``index`` registriert (Startseite)."""
return index()
@bp.get("/uebersicht/kacheln")
def tiles():
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
from tesm_core.auth.models import current_user
if not current_user.is_authenticated and not _public_allowed():
abort(404)
data = _tiles(authenticated=bool(current_user.is_authenticated))
return render_template("tesm/_dashboard_tiles.html", **data)
+428
View File
@@ -0,0 +1,428 @@
"""Clients: Bestand pflegen, Neustart ausloesen, SSH-Host-Schluessel freigeben.
Die Host-Schluessel-Freigabe gab es zuerst nur fuer Switche. Fuer Clients
fehlte sie -- mit der Folge, dass die Wartung sich weigerte ("Schluessel
unbekannt") und es keine Stelle gab, an der man ihn haette ansehen und
freigeben koennen. Eine Sackgasse, aus der man nur mit einem Shell-Zugang
auf den Server herauskam.
"""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import inventory, monitor, restart, ssh
bp = Blueprint("devices", __name__, url_prefix="/clients")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _maintenance_active() -> bool:
manager = core().license
return manager is None or manager.module_active("maintenance")
def _form_values() -> dict[str, Any]:
return {
"name": request.form.get("name", ""),
"mac": request.form.get("mac", ""),
"ip": request.form.get("ip", ""),
"switch_id": request.form.get("switch_id") or None,
"port": request.form.get("port", ""),
"credential_id": request.form.get("credential_id") or None,
"ssh_port": request.form.get("ssh_port") or 22,
"category": request.form.get("category", ""),
"location": request.form.get("location", ""),
"notes": request.form.get("notes", ""),
"is_active": request.form.get("is_active") == "1",
"auto_restart": request.form.get("auto_restart") == "1",
"actor": current_user.username,
}
@bp.get("/")
@permission_required("devices.view")
def overview():
conn = db()
devices = inventory.list_devices(conn)
maintenance_active = _maintenance_active()
return render_template(
"tesm/devices.html",
devices=devices,
restart_methods={
int(device["id"]): restart.available(
device, maintenance_active=maintenance_active
)
for device in devices
},
switches=inventory.list_switches(conn),
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
credential_categories=inventory.CREDENTIAL_CATEGORIES,
category_effects=inventory.CATEGORY_EFFECTS,
credentials=inventory.list_credentials(conn),
categories=inventory.DEVICE_CATEGORIES,
counts=monitor.dashboard_counts(conn),
)
def _credential_from_form(conn: Any) -> int | None:
"""Legt bei Bedarf neue Zugangsdaten an und gibt deren Kennung zurueck.
``None`` heisst: nichts anzulegen, das Formular hat eine vorhandene Auswahl
(oder gar keine). Fehler werden als ``ValidationError`` durchgereicht,
damit das Geraet dann ebenfalls nicht entsteht -- ein Geraet ohne die
beabsichtigten Zugangsdaten waere eine stille Halbausfuehrung.
"""
if request.form.get("credential_id") != "__neu__":
return None
if not current_user.has("credentials.create"):
raise inventory.ValidationError(
"Zum Anlegen neuer Zugangsdaten fehlt die Berechtigung."
)
name = (request.form.get("new_credential_name") or "").strip()
if not name:
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.")
return inventory.create_credential(
conn,
core().keystore,
name=name,
username=(request.form.get("new_credential_username") or "").strip(),
secret=request.form.get("new_credential_secret") or "",
category=request.form.get("new_credential_category", "linux"),
description="Beim Anlegen eines Geraets erfasst.",
actor=current_user.username,
)
@bp.post("/neu")
@permission_required("devices.create")
def create():
try:
with transaction() as conn:
values = _form_values()
new_credential = _credential_from_form(conn)
if new_credential is not None:
values["credential_id"] = new_credential
device_id = inventory.create_device(conn, **values)
_audit(
conn,
"device.created",
request.form.get("name", ""),
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
severity="notice",
)
flash("Geraet angelegt.", "success")
return redirect(url_for("devices.overview", highlight=device_id))
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>")
@permission_required("devices.edit")
def update(device_id: int):
try:
with transaction() as conn:
values = _form_values()
new_credential = _credential_from_form(conn)
if new_credential is not None:
values["credential_id"] = new_credential
changes = inventory.update_device(conn, device_id, **values)
if changes:
_audit(
conn,
"device.updated",
request.form.get("name", str(device_id)),
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/aktiv")
@permission_required("devices.edit")
def toggle_active(device_id: int):
active = request.form.get("active") == "1"
with transaction() as conn:
device = inventory.get_device(conn, device_id)
if device is None:
abort(404)
inventory.set_device_active(conn, device_id, active)
_audit(
conn,
"device.activated" if active else "device.deactivated",
str(device["name"]),
severity="notice",
)
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success")
return redirect(request.referrer or url_for("devices.overview"))
@bp.post("/<int:device_id>/loeschen")
@permission_required("devices.delete")
def delete(device_id: int):
with transaction() as conn:
device = inventory.get_device(conn, device_id)
if device is None:
abort(404)
inventory.delete_device(conn, device_id)
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
flash("Geraet in den Papierkorb verschoben.", "success")
return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/neustart")
@permission_required("devices.execute")
def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
Der PoE-Neustart ist bewusst **nicht** lizenzpflichtig: ein Kunde ohne
gueltige Lizenz soll ein haengendes Geraet weiter von Hand neu starten
koennen. Nur der *automatische* Neustart bei Ausfall und die Wege ueber
SSH bzw. RPC gehoeren zum Modul Wartung.
"""
extension = core()
conn = db()
device = inventory.get_device(conn, device_id)
if device is None or device["deleted_at"]:
abort(404)
maintenance_active = _maintenance_active()
method = restart.resolve(
dict(device),
(request.form.get("method") or "").strip(),
maintenance_active=maintenance_active,
)
if method is None:
options = restart.available(dict(device), maintenance_active=maintenance_active)
if options:
flash(
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. "
"Moeglich waere: " + ", ".join(entry.label for entry in options),
"warning",
)
else:
flash(
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port "
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.",
"warning",
)
return redirect(request.referrer or url_for("devices.overview"))
if method.is_poe:
return _restart_over_poe(extension, conn, device)
return _restart_as_job(extension, conn, device, method)
def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[no-untyped-def]
"""Sofort und synchron -- der Portzyklus dauert nur Sekunden."""
live = extension.registry.get("live_log")
try:
outcome = monitor.restart_device_poe(
conn,
dict(device),
keystore=extension.keystore,
trigger="manuell",
actor=current_user.username,
live=live,
database=extension.database,
)
with transaction():
_audit(
conn,
"device.poe_restart",
str(device["name"]),
outcome["message"],
severity="notice" if outcome["ok"] else "warning",
)
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(request.referrer or url_for("devices.overview"))
flash(
f"PoE-Neustart fuer {device['name']} durchgefuehrt."
if outcome["ok"]
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
"success" if outcome["ok"] else "danger",
)
return redirect(request.referrer or url_for("devices.overview"))
def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # type: ignore[no-untyped-def]
"""Ueber SSH bzw. RPC -- als Auftrag, denn das Warten dauert Minuten."""
from ..services import maintenance as maintenance_service
try:
username, password = inventory.credential_secret(
conn, extension.keystore, int(device["credential_id"])
)
except inventory.ValidationError as exc:
flash(f"{device['name']}: {exc}", "danger")
return redirect(request.referrer or url_for("devices.overview"))
with transaction():
handle = maintenance_service.create_job(
conn,
kind=maintenance_service.JOB_KIND_REBOOT,
subject=str(device["name"]),
actor=current_user.username,
message="Wartet auf Start",
)
_audit(
conn,
"device.restart",
str(device["name"]),
f"{method.label}, Auftrag {handle.id}",
severity="notice",
)
maintenance_service.start_background(
maintenance_service.run_reboot,
database=extension.database,
job_id=handle.id,
device=dict(device),
username=username,
password=password,
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
actor=current_user.username,
)
flash(
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
"success",
)
return redirect(request.referrer or url_for("devices.overview"))
@bp.get("/<int:device_id>")
@permission_required("devices.view")
def detail(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
if device is None:
abort(404)
events = Database.all(
conn,
"SELECT * FROM restart_events WHERE device_id=? ORDER BY ts DESC LIMIT 50",
(device_id,),
)
host_keys = ssh.all_known_keys(conn, str(device["ip"]), int(device["ssh_port"] or 22))
extension = core()
return render_template(
"tesm/device_detail.html",
device=device,
restart_methods=restart.available(
device, maintenance_active=_maintenance_active()
),
events=events,
switches=inventory.list_switches(conn),
credentials=inventory.list_credentials(conn),
categories=inventory.DEVICE_CATEGORIES,
host_keys=host_keys,
method_labels=monitor.METHOD_LABELS,
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")),
)
# --------------------------------------------------------------------------- #
# SSH-Host-Schluessel
# --------------------------------------------------------------------------- #
@bp.post("/<int:device_id>/hostkey/pruefen")
@permission_required("devices.edit")
def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
Ausdruecklich getrennt vom Freigeben: erst ansehen und vergleichen, dann
-- in einem zweiten, bestaetigten Schritt -- freigeben.
"""
conn = db()
device = inventory.get_device(conn, device_id)
if device is None:
abort(404)
try:
record = ssh.probe_host_key(str(device["ip"]), int(device["ssh_port"] or 22))
except ssh.SshError as exc:
flash(str(exc), "danger")
return redirect(url_for("devices.detail", device_id=device_id))
level, message = ssh.describe_probe(conn, record)
flash(message, level)
return redirect(
url_for(
"devices.detail",
device_id=device_id,
probed_type=record.key_type,
probed_fingerprint=record.fingerprint,
)
)
@bp.post("/<int:device_id>/hostkey/freigeben")
@permission_required("devices.edit")
@reauth_required
def trust_host_key(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
if device is None:
abort(404)
key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
return redirect(url_for("devices.detail", device_id=device_id))
with transaction():
ssh.trust_key(
conn,
host=str(device["ip"]),
port=int(device["ssh_port"] or 22),
key_type=key_type,
fingerprint=fingerprint,
actor=current_user.username,
)
_audit(
conn,
"device.host_key_trusted",
str(device["name"]),
f"{key_type} {fingerprint}",
severity="notice",
)
flash("Host-Schluessel freigegeben.", "success")
return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/<int:device_id>/hostkey/verwerfen")
@permission_required("devices.edit")
def forget_host_key(device_id: int):
conn = db()
device = inventory.get_device(conn, device_id)
if device is None:
abort(404)
with transaction():
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info")
return redirect(url_for("devices.detail", device_id=device_id))
+519
View File
@@ -0,0 +1,519 @@
"""DHCP-Server (Kea): Subnetze, Optionen, Reservierungen, Dienstzustand.
Lizenzpflichtiges Vollmodul -- ohne Lizenz verschwindet der Punkt aus der
Navigation **und** jede Route hier antwortet mit 404. Ein direkter URL-Aufruf
kommt damit genauso wenig durch wie ein fehlendes Menue.
Dienstaktionen (installieren, starten, stoppen) sind bewusst **nicht** an
"Speichern" gekoppelt: wer eine Konfiguration ablegt, will nicht zwangsweise
einen Dienst starten.
"""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import kea
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
@bp.before_request
def _require_module() -> None:
manager = core().license
if manager is not None and not manager.module_active("dhcp"):
abort(404)
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _settings_values() -> dict[str, Any]:
return core().settings.get_many(
db(), ["dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path"]
)
@bp.get("/")
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
def overview():
extension = core()
conn = db()
sysops = extension.sysops
networks = kea.detect_networks(sysops) if sysops else []
values = _settings_values()
config, warnings = kea.render_config(conn, networks=networks, settings=values)
# Nur Adressen in eingerichteten Bereichen: alles andere waere eine
# Reservierung, die Kea nie ausliefert.
skipped: list[dict[str, str]] = []
reservations = kea.collect_reservations(
conn, only_networks=kea.configured_networks(conn, networks), skipped=skipped
)
leases = kea.read_leases(sysops) if sysops else []
# Zustand aus den laufenden Leases -- wie im Windows-DHCP.
reservations = kea.annotate_reservations(reservations, leases)
reserved_ips = {str(entry["ip"]) for entry in reservations}
return render_template(
"tesm/dhcp.html",
subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"),
options=Database.all(
conn,
"SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o "
"LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL "
"ORDER BY o.code",
),
option_types=kea.OPTION_TYPES,
device_option_values=_device_option_values(conn),
reservations=reservations,
skipped_reservations=skipped,
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
leases=leases,
reserved_ips=reserved_ips,
networks=networks,
default_gateway=kea.default_gateway(sysops) if sysops else "",
service=kea.service_state(sysops) if sysops else {},
values=values,
preview=kea.config_to_text(config),
warnings=warnings,
lease_file=kea.LEASE_FILE,
sysops_available=bool(sysops and sysops.available),
)
# --------------------------------------------------------------------------- #
# Einstellungen und Subnetze
# --------------------------------------------------------------------------- #
@bp.post("/einstellungen")
@permission_required("dhcp.edit")
def save_settings():
extension = core()
conn = db()
values = {
key: request.form.get(key)
for key in ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path")
if key in request.form
}
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info")
return _write_and_redirect()
@bp.post("/subnetz")
@permission_required("dhcp.edit")
def add_subnet():
extension = core()
conn = db()
sysops = extension.sysops
networks = kea.detect_networks(sysops) if sysops else []
interface = (request.form.get("interface") or "").strip()
start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end)
if problem:
flash(problem, "danger")
return redirect(url_for("dhcp.overview"))
with transaction():
conn.execute(
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, comment, "
"enabled, sort_order, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,1,?,datetime('now'),datetime('now'))",
(
interface,
start,
end,
(request.form.get("gateway") or "").strip(),
(request.form.get("dns") or "").strip(),
(request.form.get("comment") or "").strip(),
int(request.form.get("sort_order") or 0),
),
)
_audit("dhcp.subnet_added", f"{interface} {start}-{end}", severity="notice")
flash("Subnetz angelegt.", "success")
return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>")
@permission_required("dhcp.edit")
def update_subnet(subnet_id: int):
extension = core()
conn = db()
sysops = extension.sysops
networks = kea.detect_networks(sysops) if sysops else []
interface = (request.form.get("interface") or "").strip()
start = (request.form.get("range_start") or "").strip()
end = (request.form.get("range_end") or "").strip()
problem = kea.validate_subnet(networks, interface, start, end)
if problem:
flash(problem, "danger")
return redirect(url_for("dhcp.overview"))
with transaction():
conn.execute(
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
"comment=?, enabled=?, sort_order=?, updated_at=datetime('now') WHERE id=?",
(
interface,
start,
end,
(request.form.get("gateway") or "").strip(),
(request.form.get("dns") or "").strip(),
(request.form.get("comment") or "").strip(),
1 if request.form.get("enabled") == "1" else 0,
int(request.form.get("sort_order") or 0),
subnet_id,
),
)
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
flash("Subnetz gespeichert.", "success")
return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>/loeschen")
@permission_required("dhcp.edit")
def delete_subnet(subnet_id: int):
with transaction() as conn:
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
_audit("dhcp.subnet_deleted", str(subnet_id), severity="warning")
flash("Subnetz entfernt.", "success")
return _write_and_redirect()
# --------------------------------------------------------------------------- #
# Optionen
# --------------------------------------------------------------------------- #
@bp.post("/option/wert")
@permission_required("dhcp.edit")
def set_option_value():
conn = db()
option_id = int(request.form.get("option_id") or 0)
value = (request.form.get("value") or "").strip()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
if option is None:
abort(404)
with transaction():
if value:
conn.execute(
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,NULL,?) "
"ON CONFLICT(option_id) WHERE device_id IS NULL DO UPDATE SET value=excluded.value",
(option_id, value),
)
else:
conn.execute(
"DELETE FROM dhcp_option_values WHERE option_id=? AND device_id IS NULL",
(option_id,),
)
_audit("dhcp.option_set", str(option["name"]), value or "(geleert)")
flash(f"Option {option['name']} gespeichert.", "success")
return _write_and_redirect()
def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ignore[no-untyped-def]
"""Je-Geraet gesetzte Optionen, nach Geraet gruppiert -- fuer die Anzeige."""
gruppen: dict[int, list[dict[str, Any]]] = {}
for row in Database.all(
conn,
"SELECT v.id, v.device_id, v.value, o.code, o.name, o.id AS option_id "
"FROM dhcp_option_values v JOIN dhcp_options o ON o.id = v.option_id "
"WHERE v.device_id IS NOT NULL ORDER BY o.code",
):
gruppen.setdefault(int(row["device_id"]), []).append(dict(row))
return gruppen
@bp.post("/option/geraet")
@permission_required("dhcp.edit")
def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet.
Der Wert landet in der Reservierung dieses Geraets, nicht global. Genau
dafuer war ``dhcp_option_values.device_id`` von Anfang an gedacht -- der
Erzeuger hat es immer schon ausgewertet, nur liess sich der Wert nirgends
eingeben.
"""
conn = db()
option_id = int(request.form.get("option_id") or 0)
device_id = int(request.form.get("device_id") or 0)
value = (request.form.get("value") or "").strip()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
device = Database.one(
conn, "SELECT id, name FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
)
if option is None or device is None:
abort(404)
with transaction():
if value:
conn.execute(
"INSERT INTO dhcp_option_values (option_id, device_id, value) VALUES (?,?,?) "
"ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL "
"DO UPDATE SET value=excluded.value",
(option_id, device_id, value),
)
else:
conn.execute(
"DELETE FROM dhcp_option_values WHERE option_id=? AND device_id=?",
(option_id, device_id),
)
_audit(
"dhcp.device_option_set",
f"{device['name']} / {option['name']}",
value or "(geleert)",
)
flash(
f"Option {option['name']} fuer {device['name']} "
f"{'gespeichert' if value else 'entfernt'}.",
"success",
)
return _write_and_redirect()
@bp.post("/option/neu")
@permission_required("dhcp.edit")
def add_custom_option():
conn = db()
try:
code = int(request.form.get("code") or 0)
except ValueError:
flash("Der Optionscode muss eine Zahl sein.", "danger")
return redirect(url_for("dhcp.overview"))
name = (request.form.get("name") or "").strip()
option_type = (request.form.get("type") or "").strip()
if not 1 <= code <= 254:
flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger")
return redirect(url_for("dhcp.overview"))
if not name or option_type not in kea.OPTION_TYPES:
flash("Name und Typ sind erforderlich.", "danger")
return redirect(url_for("dhcp.overview"))
if Database.one(conn, "SELECT 1 FROM dhcp_options WHERE code=? OR name=?", (code, name)):
flash("Code oder Name existiert bereits.", "danger")
return redirect(url_for("dhcp.overview"))
with transaction():
conn.execute(
"INSERT INTO dhcp_options (code, name, type, description, is_standard) "
"VALUES (?,?,?,?,0)",
(code, name, option_type, (request.form.get("description") or "").strip()),
)
_audit("dhcp.option_created", name, severity="notice")
flash("Eigene Option angelegt.", "success")
return redirect(url_for("dhcp.overview"))
@bp.post("/option/<int:option_id>/loeschen")
@permission_required("dhcp.edit")
def delete_custom_option(option_id: int):
conn = db()
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
if option is None:
abort(404)
if int(option["is_standard"] or 0):
flash(
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.",
"danger",
)
return redirect(url_for("dhcp.overview"))
with transaction():
conn.execute("DELETE FROM dhcp_options WHERE id=?", (option_id,))
_audit("dhcp.option_deleted", str(option["name"]), severity="warning")
flash("Option entfernt.", "success")
return _write_and_redirect()
# --------------------------------------------------------------------------- #
# Reservierungen
# --------------------------------------------------------------------------- #
@bp.post("/reservierung")
@permission_required("dhcp.edit")
def add_reservation():
conn = db()
mac = kea.normalize_mac(request.form.get("mac", ""))
ip = (request.form.get("ip") or "").strip()
if not mac:
flash("Die MAC-Adresse ist ungueltig.", "danger")
return redirect(url_for("dhcp.overview"))
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
# aus. Sie anzunehmen und stillschweigend zu verwerfen waere die
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
flash(
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- "
"ohne eines wird keine Reservierung ausgeliefert.",
"danger",
)
return redirect(url_for("dhcp.overview"))
sysops = core().sysops
if sysops is None or not sysops.available:
# Ohne Systemhelfer ist das Netz des Hosts nicht feststellbar. Dann hier
# nicht blockieren: sonst waere in einer eingeschraenkten Umgebung gar
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
# bleibt.
flash(
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz "
"dieses Servers nicht feststellbar.",
"warning",
)
else:
netze = kea.configured_networks(conn, kea.detect_networks(sysops))
if netze and not kea.in_configured_network(ip, netze):
bereiche = ", ".join(str(netz) for netz in netze)
flash(
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
f"({bereiche}). Die Reservierung waere ohne Wirkung.",
"danger",
)
return redirect(url_for("dhcp.overview"))
with transaction():
conn.execute(
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
"VALUES (?,?,?,?,datetime('now')) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, "
"hostname=excluded.hostname, comment=excluded.comment",
(
mac,
ip,
(request.form.get("hostname") or "").strip(),
(request.form.get("comment") or "").strip(),
),
)
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
flash("Reservierung gespeichert.", "success")
return _write_and_redirect()
@bp.post("/reservierung/<int:reservation_id>/loeschen")
@permission_required("dhcp.edit")
def delete_reservation(reservation_id: int):
with transaction() as conn:
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
_audit("dhcp.reservation_deleted", str(reservation_id), severity="notice")
flash("Reservierung entfernt.", "success")
return _write_and_redirect()
# --------------------------------------------------------------------------- #
# Datei schreiben und Dienst steuern
# --------------------------------------------------------------------------- #
def _write_config() -> dict[str, Any]:
"""Schreibt die Konfiguration und startet den Dienst neu, falls er laeuft.
Ein gestoppter Dienst wird **nicht** automatisch gestartet -- das bleibt eine
ausdrueckliche Entscheidung.
"""
extension = core()
sysops = extension.sysops
conn = db()
if sysops is None or not sysops.available:
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."}
networks = kea.detect_networks(sysops)
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
if not config["Dhcp4"]["subnet4"]:
return {
"ok": False,
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.",
"warnings": warnings,
}
result = sysops.write_kea_config(kea.config_to_text(config))
if not result.ok:
return {"ok": False, "message": result.message, "warnings": warnings}
state = sysops.service_state(kea.SERVICE_UNIT)
restarted = False
if state["active"] == "active":
restart = sysops.service("restart", kea.SERVICE_UNIT)
restarted = restart.ok
if not restart.ok:
return {
"ok": False,
"message": f"Datei geschrieben, Neustart fehlgeschlagen: {restart.message}",
"warnings": warnings,
}
return {
"ok": True,
"message": "Konfiguration geschrieben." + (" Dienst neu gestartet." if restarted else ""),
"warnings": warnings,
}
def _write_and_redirect():
result = _write_config()
for warning in result.get("warnings", []):
flash(warning, "warning")
if not result["ok"] and result.get("message"):
flash(result["message"], "warning")
elif result["ok"]:
flash(result["message"], "info")
return redirect(url_for("dhcp.overview"))
@bp.post("/schreiben")
@permission_required("dhcp.edit")
def write_config():
return _write_and_redirect()
@bp.post("/dienst/<action>")
@permission_required("dhcp.execute")
def service_action(action: str):
allowed = {"install", "start", "stop"}
if action not in allowed:
abort(404)
extension = core()
sysops = extension.sysops
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("dhcp.overview"))
if action == "install":
result = sysops.apt_install(kea.PACKAGE_NAME)
message = "Kea installiert." if result.ok else f"Installation fehlgeschlagen: {result.message}"
elif action == "start":
enable = sysops.service("enable", kea.SERVICE_UNIT)
result = sysops.service("start", kea.SERVICE_UNIT)
message = (
"Dienst aktiviert und gestartet."
if result.ok and enable.ok
else f"Start fehlgeschlagen: {result.message or enable.message}"
)
else:
sysops.service("disable", kea.SERVICE_UNIT)
result = sysops.service("stop", kea.SERVICE_UNIT)
message = "Dienst gestoppt und deaktiviert." if result.ok else f"Stoppen fehlgeschlagen: {result.message}"
_audit(f"dhcp.service_{action}", detail=message, severity="notice" if result.ok else "warning")
flash(message, "success" if result.ok else "danger")
return redirect(url_for("dhcp.overview"))
+265
View File
@@ -0,0 +1,265 @@
"""Dateifreigaben: Blaettern, Herunterladen, Hochladen, Verwalten."""
from __future__ import annotations
import io
from flask import (
Blueprint,
Response,
abort,
flash,
redirect,
render_template,
request,
send_file,
url_for,
)
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from ..services import fileshare as service
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben")
@bp.before_request
def _require_module() -> None:
manager = core().license
if manager is not None and not manager.module_active("fileshare"):
abort(404)
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _mount_or_404(token: str) -> service.Mount:
mounts = service.active_mounts(db(), current_user.id)
for mount in mounts:
if mount.token == token:
return mount
abort(404)
@bp.get("/")
@permission_required("fileshare.view")
def browse():
conn = db()
mounts = service.active_mounts(conn, current_user.id)
token = request.args.get("freigabe") or (mounts[0].token if mounts else "")
relative = request.args.get("pfad") or ""
listing = None
error = ""
if token:
try:
mount = _mount_or_404(token)
listing = service.list_directory(mount, relative)
except service.FileshareError as exc:
error = str(exc)
return render_template(
"tesm/fileshare.html",
mounts=mounts,
token=token,
listing=listing,
error=error,
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512),
)
@bp.get("/<token>/herunterladen")
@permission_required("fileshare.view")
def download(token: str):
mount = _mount_or_404(token)
relative = request.args.get("pfad") or ""
try:
path = service.resolve_path(mount, relative)
except service.FileshareError as exc:
abort(400, description=str(exc))
if not path.is_file():
abort(404)
_audit("fileshare.downloaded", f"{mount.label}:{relative}")
return send_file(
path,
as_attachment=True,
download_name=path.name,
mimetype=service.download_mime(path.name),
)
@bp.get("/<token>/vorschau")
@permission_required("fileshare.view")
def preview(token: str):
mount = _mount_or_404(token)
relative = request.args.get("pfad") or ""
try:
path = service.resolve_path(mount, relative)
mimetype = service.preview_mime(path.name)
except service.FileshareError as exc:
abort(415, description=str(exc))
if not path.is_file():
abort(404)
# ``Content-Disposition: inline`` mit engem MIME-Typ; die CSP der Anwendung
# verhindert zusaetzlich, dass eingebettetes Skript ausgefuehrt wuerde.
return Response(
path.read_bytes(),
mimetype=mimetype,
headers={
"Content-Disposition": f'inline; filename="{path.name}"',
"X-Content-Type-Options": "nosniff",
},
)
@bp.post("/<token>/hochladen")
@permission_required("fileshare.create")
def upload(token: str):
mount = _mount_or_404(token)
relative = request.form.get("pfad") or ""
files = request.files.getlist("files")
if not files:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024
stored = 0
try:
target_dir = service.resolve_path(mount, relative)
for upload_file in files:
if not upload_file.filename:
continue
name = service.safe_filename(upload_file.filename)
data = upload_file.stream.read(limit + 1)
if len(data) > limit:
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger")
continue
(target_dir / name).write_bytes(data)
stored += 1
except service.FileshareError as exc:
flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
if stored:
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
flash(f"{stored} Datei(en) hochgeladen.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
@bp.post("/<token>/ordner")
@permission_required("fileshare.create")
def make_directory(token: str):
mount = _mount_or_404(token)
relative = request.form.get("pfad") or ""
try:
service.make_directory(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
flash("Ordner angelegt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
@bp.post("/<token>/umbenennen")
@permission_required("fileshare.edit")
def rename(token: str):
mount = _mount_or_404(token)
relative = request.form.get("pfad") or ""
parent = request.form.get("parent") or ""
try:
service.rename_entry(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
_audit("fileshare.renamed", f"{mount.label}:{relative}")
flash("Umbenannt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
@bp.post("/<token>/loeschen")
@permission_required("fileshare.delete")
def delete(token: str):
mount = _mount_or_404(token)
parent = request.form.get("parent") or ""
removed = 0
for relative in request.form.getlist("pfade"):
try:
kind = service.delete_entry(mount, relative)
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
removed += 1
except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger")
if removed:
flash(f"{removed} Eintrag/Eintraege geloescht.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
@bp.post("/<token>/freigeben")
@permission_required("fileshare.view")
def release(token: str):
mount = _mount_or_404(token)
with transaction() as conn:
service.release_mount(conn, mount.id)
_audit("fileshare.released", mount.label)
flash("Einbindung geloest.", "success")
return redirect(url_for("fileshare.browse"))
# --------------------------------------------------------------------------- #
# Verwaltung der Zuordnungen (Einstellungen)
# --------------------------------------------------------------------------- #
@bp.get("/zuordnungen")
@permission_required("fileshare.edit")
def mappings():
from tesm_core.auth import directory
conn = db()
return render_template(
"tesm/fileshare_mappings.html",
mappings=service.list_mappings(conn),
directory_enabled=directory.is_enabled(conn),
mount_root=str(core().settings.get(conn, "fileshare_mount_root")),
)
@bp.post("/zuordnungen")
@permission_required("fileshare.edit")
def mapping_add():
try:
with transaction() as conn:
service.add_mapping(
conn,
ad_group_dn=request.form.get("ad_group_dn", ""),
ad_group_name=request.form.get("ad_group_name", ""),
label=request.form.get("label", ""),
unc=request.form.get("unc", ""),
)
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice")
flash("Zuordnung gespeichert.", "success")
except service.FileshareError as exc:
flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
@permission_required("fileshare.edit")
def mapping_delete(mapping_id: int):
with transaction() as conn:
service.delete_mapping(conn, mapping_id)
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice")
flash("Zuordnung entfernt.", "success")
return redirect(url_for("fileshare.mappings"))
+249
View File
@@ -0,0 +1,249 @@
"""SSH-Host-Schluessel -- alle Ziele auf einer Seite.
Die Freigabe einzelner Schluessel gibt es auch auf den Detailseiten von
Clients und Switchen. Diese Seite beantwortet die andere Frage, die im Betrieb
haeufiger ist: *wo fehlt noch eine Freigabe?* Sie vorher zu beantworten hiess,
jede Detailseite einzeln aufzurufen.
Gezeigt werden beide Richtungen:
* Jedes Ziel (Client oder Switch) mit einer SSH-Adresse -- mit oder ohne
Freigabe.
* Freigegebene Schluessel, zu denen es kein Ziel mehr gibt. Die sind kein
Fehler, aber sie sollten auffallen: ein Geraet wurde geloescht oder hat eine
neue IP, und die alte Freigabe steht noch.
Die Rechte bleiben an den Objekten: wer Clients bearbeiten darf, gibt
Client-Schluessel frei; fuer Switche gilt ``switches.edit``. Diese Seite
buendelt die Ansicht, nicht die Berechtigung.
"""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel")
KIND_DEVICE = "client"
KIND_SWITCH = "switch"
#: Welches Recht welche Art von Ziel freigibt.
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _targets(conn: Any) -> list[dict[str, Any]]:
"""Alle Ziele mit SSH-Adresse, jeweils mit ihren freigegebenen Schluesseln."""
rows: list[dict[str, Any]] = []
for switch in inventory.list_switches(conn):
rows.append(
{
"kind": KIND_SWITCH,
"id": int(switch["id"]),
"label": str(switch["hostname"]),
"host": str(switch["ip"]),
"port": int(switch["ssh_port"] or 22),
"detail_endpoint": "switches.detail",
"credential": switch["credential_name"] if "credential_name" in switch.keys()
else "",
}
)
for device in inventory.list_devices(conn):
# Windows-Geraete werden nicht ueber SSH bedient -- sie haetten hier
# ewig den Zustand "Freigabe offen", ohne dass es je etwas freizugeben
# gaebe.
if not inventory.is_ssh_managed(device["credential_category"]):
continue
rows.append(
{
"kind": KIND_DEVICE,
"id": int(device["id"]),
"label": str(device["name"]),
"host": str(device["ip"]),
"port": int(device["ssh_port"] or 22),
"detail_endpoint": "devices.detail",
"credential": device["credential_name"] or "",
}
)
for row in rows:
# Nicht "keys" nennen: Jinja loest ``row.keys`` auf die dict-Methode
# auf, nicht auf den Eintrag -- die Vorlage bekaeme dann eine Funktion.
found = ssh.all_known_keys(conn, row["host"], row["port"])
row["host_keys"] = found
row["trusted"] = bool(found)
rows.sort(key=lambda item: (item["trusted"], item["label"].lower()))
return rows
def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Freigaben, zu denen kein Ziel mehr passt."""
used = {(row["host"], row["port"]) for row in targets}
return [
dict(row)
for row in Database.all(
conn,
"SELECT host, port, key_type, fingerprint, added_at, added_by "
"FROM switch_host_keys ORDER BY host, port, key_type",
)
if (str(row["host"]), int(row["port"])) not in used
]
@bp.get("/")
@permission_required("devices.view", "switches.view", mode="any")
def overview():
conn = db()
targets = _targets(conn)
return render_template(
"tesm/hostkeys.html",
targets=targets,
orphans=_orphans(conn, targets),
open_count=sum(1 for row in targets if not row["trusted"]),
edit_right=EDIT_RIGHT,
probed_host=request.args.get("probed_host", ""),
probed_port=request.args.get("probed_port", ""),
probed_type=request.args.get("probed_type", ""),
probed_fingerprint=request.args.get("probed_fingerprint", ""),
)
def _load(conn: Any, kind: str, target_id: int) -> dict[str, Any] | None:
for row in _targets(conn):
if row["kind"] == kind and row["id"] == target_id:
return row
return None
def _require(kind: str) -> None:
right = EDIT_RIGHT.get(kind)
if right is None or not current_user.has(right):
abort(403)
@bp.post("/<kind>/<int:target_id>/pruefen")
@permission_required("devices.view", "switches.view", mode="any")
def probe(kind: str, target_id: int):
conn = db()
_require(kind)
target = _load(conn, kind, target_id)
if target is None:
abort(404)
try:
record = ssh.probe_host_key(target["host"], target["port"])
except ssh.SshError as exc:
flash(str(exc), "danger")
return redirect(url_for("hostkeys.overview"))
level, message = ssh.describe_probe(conn, record)
flash(f"{target['label']}: {message}", level)
return redirect(
url_for(
"hostkeys.overview",
probed_host=record.host,
probed_port=record.port,
probed_type=record.key_type,
probed_fingerprint=record.fingerprint,
)
)
@bp.post("/<kind>/<int:target_id>/freigeben")
@permission_required("devices.view", "switches.view", mode="any")
@reauth_required
def trust(kind: str, target_id: int):
conn = db()
_require(kind)
target = _load(conn, kind, target_id)
if target is None:
abort(404)
key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
return redirect(url_for("hostkeys.overview"))
with transaction():
ssh.trust_key(
conn,
host=target["host"],
port=target["port"],
key_type=key_type,
fingerprint=fingerprint,
actor=current_user.username,
)
_audit(
f"{'switch' if kind == KIND_SWITCH else 'device'}.host_key_trusted",
target["label"],
f"{key_type} {fingerprint}",
severity="notice",
)
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success")
return redirect(url_for("hostkeys.overview"))
@bp.post("/<kind>/<int:target_id>/verwerfen")
@permission_required("devices.view", "switches.view", mode="any")
def forget(kind: str, target_id: int):
conn = db()
_require(kind)
target = _load(conn, kind, target_id)
if target is None:
abort(404)
with transaction():
ssh.forget_key(conn, target["host"], target["port"])
_audit(
f"{'switch' if kind == KIND_SWITCH else 'device'}.host_key_forgotten",
target["label"],
severity="warning",
)
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info")
return redirect(url_for("hostkeys.overview"))
@bp.post("/verwaist/verwerfen")
@permission_required("devices.edit", "switches.edit", mode="any")
def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
host = (request.form.get("host") or "").strip()
try:
port = int(request.form.get("port") or 0)
except ValueError:
port = 0
if not host or not 1 <= port <= 65535:
flash("Ungueltiges Ziel.", "danger")
return redirect(url_for("hostkeys.overview"))
conn = db()
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning")
return redirect(url_for("hostkeys.overview"))
with transaction():
ssh.forget_key(conn, host, port)
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info")
return redirect(url_for("hostkeys.overview"))
+250
View File
@@ -0,0 +1,250 @@
"""Lizenzverwaltung auf Kundenseite.
Diese Seite bleibt **immer** erreichbar -- auch ohne gueltige Lizenz. Sonst
liesse sich eine neue Lizenz nie einspielen (Henne-Ei).
"""
from __future__ import annotations
import socket
from flask import Blueprint, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import (
admin_required,
current_user,
permission_required,
reauth_required,
)
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic
bp = Blueprint("license", __name__, url_prefix="/lizenz")
MAX_UPLOAD_BYTES = 256 * 1024
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
@bp.get("/")
@permission_required("settings_license.view")
def overview():
extension = core()
conn = db()
manager = extension.license
state = manager.reload(conn) if manager else None
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
return render_template(
"tesm/license.html",
state=state,
pending=pending,
server_certificate=extension.settings.get(conn, "license_server_ca") or "",
modules=lic.ALL_MODULES,
module_labels=lic.MODULE_LABELS,
type_labels=lic.LICENSE_TYPE_LABELS,
role_labels=lic.ROLE_LABELS,
features=LICENSED_FEATURES,
trusted_keys=Database.all(
conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC"
),
fingerprint=lic.system_fingerprint(),
hostname=socket.gethostname(),
grace_days=lic.GRACE_PERIOD_DAYS,
)
@bp.post("/einspielen")
@permission_required("settings_license.edit")
def upload():
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("license.overview"))
upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
return redirect(url_for("license.overview"))
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
try:
with transaction() as conn:
state = manager.import_bundle(
conn, raw, actor=current_user.username, allow_new_issuer=allow_new_issuer
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("license.overview"))
flash(
"Lizenz eingespielt. Bitte jetzt aktivieren, damit die Funktionen freigeschaltet werden.",
"success",
)
if state.status and state.status.role != "customer":
flash(
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
"In einer TESM-Kundeninstanz ist das ungewoehnlich.",
"warning",
)
return redirect(url_for("license.overview"))
@bp.post("/aktivieren")
@permission_required("settings_license.edit")
def activate():
return _handshake("activate")
@bp.post("/deaktivieren")
@permission_required("settings_license.edit")
@reauth_required
def deactivate():
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
Verlangt eine frische Passwortbestaetigung: eine versehentliche
Deaktivierung schaltet lizenzpflichtige Funktionen ab.
"""
return _handshake("deactivate")
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
def heartbeat():
return _handshake("heartbeat")
def _handshake(action: str):
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("license.overview"))
try:
with transaction() as conn:
result = manager.handshake(
conn, action, actor=current_user.username, hostname=socket.gethostname()
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("license.overview"))
if result["ok"]:
flash(result["message"], "success")
elif result["transport"] == "offline":
flash(result["message"], "warning")
else:
flash(result["message"], "danger")
return redirect(url_for("license.overview"))
@bp.post("/offline-antwort")
@permission_required("settings_license.edit")
def offline_response():
extension = core()
manager = extension.license
if manager is None:
return redirect(url_for("license.overview"))
try:
with transaction() as conn:
result = manager.submit_offline_response(
conn, request.form.get("code", ""), actor=current_user.username
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("license.overview"))
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("license.overview"))
@bp.post("/serverzertifikat")
@permission_required("settings_license.edit")
def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers.
Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
Zertifikat. Ohne diesen Anker scheitert jeder Online-Handshake an der
Pruefung und faellt auf den Offline-Weg zurueck. Die Pruefung deshalb
abzuschalten waere der falsche Weg -- hier wird sie stattdessen auf genau
dieses Zertifikat gerichtet.
"""
extension = core()
conn = db()
pem = (request.form.get("certificate") or "").strip()
if pem and "BEGIN CERTIFICATE" not in pem:
flash(
"Das sieht nicht nach einem Zertifikat aus. Erwartet wird PEM, beginnend mit "
"'-----BEGIN CERTIFICATE-----'.",
"danger",
)
return redirect(url_for("license.overview"))
if pem:
# Lieber jetzt scheitern als beim naechsten Handshake.
try:
extension.license._tls_context(pem) # noqa: SLF001 - eigenes Paket
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("license.overview"))
with transaction():
extension.settings.set(conn, "license_server_ca", pem, actor=current_user.username)
_audit(
"license.server_certificate_set" if pem else "license.server_certificate_cleared",
detail="PEM hinterlegt" if pem else "entfernt",
severity="notice",
)
flash(
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg."
if pem
else "Zertifikat entfernt -- es gelten wieder die Systemanker.",
"success",
)
return redirect(url_for("license.overview"))
@bp.post("/aussteller/<key_id>/entfernen")
@admin_required
@reauth_required
def remove_trusted_key(key_id: str):
"""Entfernt einen gepinnten Aussteller-Schluessel.
Nur echte Administratoren, nur mit frischer Bestaetigung: das ist die
Vertrauensgrundlage der Lizenzpruefung.
"""
with transaction() as conn:
conn.execute("DELETE FROM trusted_issuer_keys WHERE key_id=?", (key_id,))
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="license.issuer_unpinned",
target=key_id,
ip=client_ip(),
severity="warning",
)
manager = core().license
if manager is not None:
with core().database.session() as conn:
manager.reload(conn)
flash("Aussteller-Schluessel entfernt.", "info")
return redirect(url_for("license.overview"))
+69
View File
@@ -0,0 +1,69 @@
"""TESM-eigene Protokollansichten: Kea-DHCP und Neustarts.
Die generischen Ansichten (laufend, Verlauf, Aenderungen) liegen im Kern --
sie sind in beiden Anwendungen identisch.
"""
from __future__ import annotations
from flask import Blueprint, abort, render_template, request
from tesm_core.auth.models import permission_required
from tesm_core.db import Database
from tesm_core.extension import core, db
from ..services import kea, monitor
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle")
MAX_TAIL_LINES = 2000
@bp.get("/kea")
@permission_required("logs_dhcp.view")
def dhcp():
extension = core()
if extension.license is not None and not extension.license.module_active("dhcp"):
abort(404)
sysops = extension.sysops
lines: list[str] = []
error = ""
if sysops is not None and sysops.available:
result = sysops.read_privileged_file(kea.LOG_PATH)
if result.ok:
lines = result.stdout.splitlines()[-MAX_TAIL_LINES:]
else:
error = result.message
else:
error = "Systemhelfer nicht verfuegbar."
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
@bp.get("/neustarts")
@permission_required("devices.view")
def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
Frueher hiess diese Seite "PoE-Vorgaenge" und zeigte nur die eine Haelfte.
Ein Geraet, das die Wartung per ``reboot`` neu gestartet hatte, fehlte in
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
der wissen will, warum ein Geraet weg war.
"""
method = (request.args.get("methode") or "").strip()
if method and method not in monitor.METHOD_LABELS:
method = ""
sql = (
"SELECT e.*, d.name AS device_name FROM restart_events e "
"LEFT JOIN devices d ON d.id = e.device_id "
)
params: tuple[object, ...] = ()
if method:
sql += "WHERE e.method = ? "
params = (method,)
sql += "ORDER BY e.ts DESC LIMIT 300"
return render_template(
"tesm/logs_restarts.html",
rows=Database.all(db(), sql, params),
method=method,
methods=tuple(monitor.METHOD_LABELS.items()),
method_labels=monitor.METHOD_LABELS,
)
@@ -0,0 +1,276 @@
"""Wartung: Paketaktualisierung und Neustart ueber SSH.
Lizenzpflichtiges Vollmodul. Es erscheinen Geraete, deren Zugangsdaten die
Kategorie ``linux`` oder ``windows`` tragen:
* **Linux** -- Paketaktualisierung (apt) und Neustart.
* **Windows** -- nur Neustart, ueber ``shutdown.exe``. Das Geraet braucht dafuer
den OpenSSH-Server. Paketaktualisierung bleibt bewusst aus: dafuer braeuchte
es PSWindowsUpdate oder WSUS, und beides darf man nicht voraussetzen.
Die Kategorie setzt der Administrator ausdruecklich, sie wird bewusst nicht per
Ping/TTL erraten (das war im Vorgaenger schon so und ist richtig: eine falsch
geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
"""
from __future__ import annotations
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import inventory, maintenance as service, ssh, windowsops
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
@bp.before_request
def _require_module() -> None:
manager = core().license
if manager is not None and not manager.module_active("maintenance"):
abort(404)
def _eligible_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
"""Geraete, auf denen die Wartung ueberhaupt arbeiten kann.
Zusaetzlich zur Auswahl steht am Ergebnis, ob der SSH-Host-Schluessel schon
freigegeben ist. Ohne diese Angabe lief man in eine Sackgasse: die Wartung
brach mit "Schluessel unbekannt" ab, und auf der Seite war nicht zu sehen,
woran es lag oder wo man es beheben kann.
"""
rows = _query_devices(conn)
for row in rows:
row["ssh_managed"] = inventory.is_ssh_managed(row["category"])
# Windows braucht keinen Host-Schluessel -- dort waere "offen" eine
# Warnung, die sich nie abstellen laesst.
row["host_key_ready"] = (
ssh.is_trusted(conn, str(row["ip"]), int(row["ssh_port"] or 22))
if row["ssh_managed"]
else True
)
return rows
def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
return Database.all(
conn,
"""
SELECT d.id, d.name, d.ip, d.ssh_port, c.name AS credential_name, c.id AS credential_id,
c.category AS category,
COALESCE(st.state,'unknown') AS state
FROM devices d
JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
AND c.category IN ({placeholders})
LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL AND d.is_active = 1
ORDER BY d.name COLLATE NOCASE
""".format(placeholders=",".join("?" * len(inventory.MAINTENANCE_CATEGORIES))),
inventory.MAINTENANCE_CATEGORIES,
)
@bp.get("/")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def overview():
conn = db()
return render_template(
"tesm/maintenance.html",
devices=_eligible_devices(conn),
jobs=service.list_jobs(conn),
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
category_effects=inventory.CATEGORY_EFFECTS,
update_category=inventory.MAINTENANCE_CATEGORY,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE,
)
def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
"""Fehlt ``net``, obwohl Windows-Geraete gepflegt werden?
Nur dann ist der Hinweis angebracht. Auf einem Server ohne Windows-Geraete
waere er Laerm, und das Paket waere unnoetig installiert.
"""
if windowsops.available():
return False
return any(
str(device.get("category") or "") == inventory.NON_SSH_CATEGORY
for device in _eligible_devices(conn)
)
@bp.post("/paket-nachinstallieren")
@permission_required("maintenance.execute")
def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
Ohne diesen Weg endete der Neustart eines Windows-Geraetes in einer
Sackgasse: die Meldung nannte das fehlende Paket, aus der Oberflaeche liess
es sich aber nicht beschaffen.
"""
sysops = core().sysops
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("maintenance.overview"))
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
if result.ok:
flash(f"{windowsops.REQUIRED_PACKAGE} installiert.", "success")
else:
flash(f"Installation fehlgeschlagen: {result.message}", "danger")
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="maintenance.package_installed",
target=windowsops.REQUIRED_PACKAGE,
detail="" if result.ok else result.message,
ip=client_ip(),
severity="notice" if result.ok else "warning",
)
return redirect(url_for("maintenance.overview"))
@bp.get("/status")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def status():
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
conn = db()
jobs = service.list_jobs(conn, limit=30)
running = any(job["state"] in {"pending", "running"} for job in jobs)
return jsonify(
{
"running": running,
"poll_ms": 2000 if running else 10000,
"jobs": [
{
"id": job["id"],
"kind": job["kind"],
"subject": job["subject"],
"state": job["state"],
"progress": job["progress"],
"message": job["message"],
"updated_at": job["updated_at"],
}
for job in jobs
],
}
)
@bp.get("/auftrag/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def job_detail(job_id: str):
conn = db()
job = service.get_job(conn, job_id)
if job is None:
abort(404)
return render_template("tesm/maintenance_job.html", job=job)
@bp.get("/auftrag/<job_id>/ausgabe")
@permission_required("maintenance.view", "maintenance.execute", mode="any")
def job_output(job_id: str):
conn = db()
job = service.get_job(conn, job_id)
if job is None:
abort(404)
return render_template("tesm/_maintenance_output.html", job=job)
def _start(kind: str) -> None:
extension = core()
conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
if not device_ids:
flash("Es wurde kein Geraet ausgewaehlt.", "warning")
return
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
started = 0
for device_id in device_ids:
device = eligible.get(device_id)
if device is None:
continue
# Vor dem Anlegen pruefen, nicht danach: sonst bliebe ein Auftrag
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
flash(
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. "
"Windows-Geraete lassen sich hier nur neu starten.",
"warning",
)
continue
try:
username, password = inventory.credential_secret(
conn, extension.keystore, int(device["credential_id"])
)
except inventory.ValidationError as exc:
flash(f"{device['name']}: {exc}", "danger")
continue
with transaction():
handle = service.create_job(
conn,
kind=kind,
subject=str(device["name"]),
actor=current_user.username,
message="Wartet auf Start",
)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=kind,
target=str(device["name"]),
detail=f"Auftrag {handle.id}",
ip=client_ip(),
severity="notice",
)
if kind == service.JOB_KIND_UPDATE:
service.start_background(
service.run_update,
database=extension.database,
job_id=handle.id,
device=dict(device),
username=username,
password=password,
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
else:
service.start_background(
service.run_reboot,
database=extension.database,
job_id=handle.id,
device=dict(device),
username=username,
password=password,
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
actor=current_user.username,
)
started += 1
if started:
flash(f"{started} Auftrag/Auftraege gestartet.", "success")
@bp.post("/aktualisieren")
@permission_required("maintenance.execute")
def start_update():
_start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview"))
@bp.post("/neustarten")
@permission_required("maintenance.execute")
def start_reboot():
_start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview"))
+235
View File
@@ -0,0 +1,235 @@
"""Systemeinstellungen von TESM.
Netzwerk, Webserver/TLS und Verzeichnisdienst liegen im Kern
(``tesm_core.views.hostsettings``) -- sie sind in beiden Anwendungen identisch.
Hier steht nur, was TESM eigen ist: Ueberwachung, Konsole, Protokolle, Host.
"""
from __future__ import annotations
import threading
import time
from typing import Any
from flask import Blueprint, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _license_hint(feature: str) -> str:
manager = core().license
if manager is None:
return ""
try:
manager.require(feature)
except LicenseError as exc:
return str(exc)
return ""
def _current_timezone() -> str:
sysops = core().sysops
if sysops is None:
return ""
return sysops.read_command(
["timedatectl", "show", "--property=Timezone", "--value"]
).stdout.strip()
def _timezone_choices() -> list[str]:
try:
from zoneinfo import available_timezones
return sorted(available_timezones())
except Exception: # noqa: BLE001
return ["Europe/Berlin", "UTC"]
@bp.get("/system")
@permission_required("settings_system.view")
def system():
extension = core()
conn = db()
sysops = extension.sysops
return render_template(
"tesm/settings_system.html",
values=extension.settings.get_many(
conn,
[
"monitor_interval_seconds",
"monitor_failures_before_restart",
"monitor_auto_restart",
"dashboard_public",
"terminal_enabled",
"terminal_idle_timeout",
"trash_retention_days",
"log_rotate_interval",
"log_rotate_keep",
],
),
specs={spec.key: spec for spec in extension.settings.specs()},
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=_current_timezone(),
timezones=_timezone_choices(),
nav_items=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available),
)
@bp.post("/system")
@permission_required("settings_system.edit")
def system_save():
extension = core()
conn = db()
form = request.form
values: dict[str, Any] = {}
for key in (
"monitor_failures_before_restart",
"monitor_auto_restart",
"dashboard_public",
"terminal_enabled",
"terminal_idle_timeout",
"trash_retention_days",
"log_rotate_interval",
"log_rotate_keep",
):
spec = extension.settings.spec(key)
if spec.kind == "bool":
# Nur beruecksichtigen, wenn das Formular diesen Bereich ueberhaupt
# enthaelt -- sonst wuerde ein Teilformular fremde Schalter loeschen.
if f"{key}__present" in form or key in form:
values[key] = form.get(key) == "1"
elif key in form:
values[key] = form.get(key)
interval_changed = False
if "monitor_interval_seconds" in form:
problem = _license_hint("monitor_interval")
current = str(extension.settings.get(conn, "monitor_interval_seconds") or 300)
wanted = str(form.get("monitor_interval_seconds"))
if current != wanted:
if problem:
flash(problem, "danger")
else:
values["monitor_interval_seconds"] = wanted
interval_changed = True
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if changed:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.system_saved",
detail=", ".join(changed),
ip=client_ip(),
severity="notice",
)
if changed:
message = "Einstellungen gespeichert."
if interval_changed:
message += " Das Pruefintervall greift beim naechsten Durchlauf."
flash(message, "success")
else:
flash("Keine Aenderungen.", "info")
return redirect(url_for("settings.system"))
@bp.post("/system/hostname")
@permission_required("settings_system.execute")
def set_hostname():
extension = core()
sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname)
if result.ok:
with transaction() as conn:
extension.settings.set(conn, "hostname", hostname, actor=current_user.username)
_audit("settings.hostname_changed", hostname, severity="notice")
flash(f"Hostname auf {hostname} gesetzt.", "success")
else:
flash(f"Hostname konnte nicht gesetzt werden: {result.message}", "danger")
return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone")
@permission_required("settings_system.execute")
def set_timezone():
extension = core()
sysops = extension.sysops
timezone = (request.form.get("timezone") or "").strip()
if timezone not in _timezone_choices():
flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone)
if result.ok:
# Sofort auf den laufenden Prozess wirken lassen -- sonst zeigt die
# Oberflaeche bis zum naechsten Neustart die alte Zeit.
try:
time.tzset() # type: ignore[attr-defined]
except AttributeError: # pragma: no cover - Windows
pass
with transaction() as conn:
extension.settings.set(conn, "timezone", timezone, actor=current_user.username)
_audit("settings.timezone_changed", timezone, severity="notice")
flash(f"Zeitzone auf {timezone} gesetzt.", "success")
else:
flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger")
return redirect(url_for("settings.system"))
@bp.post("/system/pruefung-jetzt")
@permission_required("settings_system.execute")
def run_check_now():
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
extension = core()
def worker() -> None:
from ..services import inventory, monitor
allowed = extension.license is None or extension.license.feature_allowed(
"auto_poe_restart"
)
monitor.run_sweep(
database=extension.database,
live=extension.registry["live_log"],
keystore=extension.keystore,
license_allows_auto_restart=allowed,
)
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
_audit("monitor.manual_sweep")
flash("Durchlauf gestartet. Das Ergebnis erscheint im laufenden Protokoll.", "success")
return redirect(request.referrer or url_for("settings.system"))
+292
View File
@@ -0,0 +1,292 @@
"""Switche: Bestand pflegen, Host-Schluessel freigeben, Zustand abfragen."""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import (
current_user,
permission_required,
reauth_required,
)
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh, switchcli
bp = Blueprint("switches", __name__, url_prefix="/switche")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _form_values() -> dict[str, Any]:
return {
"hostname": request.form.get("hostname", ""),
"ip": request.form.get("ip", ""),
"ssh_port": request.form.get("ssh_port") or 22,
"credential_id": request.form.get("credential_id") or None,
"model": request.form.get("model", ""),
"location": request.form.get("location", ""),
"notes": request.form.get("notes", ""),
"actor": current_user.username,
}
@bp.get("/")
@permission_required("switches.view")
def overview():
conn = db()
return render_template(
"tesm/switches.html",
switches=inventory.list_switches(conn),
credentials=inventory.list_credentials(conn),
host_keys=Database.all(conn, "SELECT * FROM switch_host_keys ORDER BY host"),
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
)
@bp.post("/neu")
@permission_required("switches.create")
def create():
try:
with transaction() as conn:
switch_id = inventory.create_switch(conn, **_form_values())
_audit(
conn,
"switch.created",
request.form.get("hostname", ""),
f"IP {request.form.get('ip')}",
severity="notice",
)
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id))
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("switches.overview"))
@bp.post("/<int:switch_id>")
@permission_required("switches.edit")
def update(switch_id: int):
try:
with transaction() as conn:
changes = inventory.update_switch(conn, switch_id, **_form_values())
if changes:
_audit(
conn,
"switch.updated",
request.form.get("hostname", str(switch_id)),
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(request.referrer or url_for("switches.overview"))
@bp.post("/<int:switch_id>/loeschen")
@permission_required("switches.delete")
def delete(switch_id: int):
try:
with transaction() as conn:
switch = inventory.get_switch(conn, switch_id)
if switch is None:
abort(404)
inventory.delete_switch(conn, switch_id)
_audit(conn, "switch.deleted", str(switch["hostname"]), severity="warning")
flash("Switch in den Papierkorb verschoben.", "success")
except inventory.ValidationError as exc:
flash(str(exc), "danger")
return redirect(url_for("switches.overview"))
@bp.get("/<int:switch_id>")
@permission_required("switches.view")
def detail(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
if switch is None:
abort(404)
host_key = ssh.known_key(conn, str(switch["ip"]), int(switch["ssh_port"]), "ssh-ed25519")
all_keys = Database.all(
conn,
"SELECT * FROM switch_host_keys WHERE host=? AND port=? ORDER BY key_type",
(str(switch["ip"]), int(switch["ssh_port"])),
)
devices = Database.all(
conn,
"SELECT d.id, d.name, d.port, d.ip, COALESCE(st.state,'unknown') AS state "
"FROM devices d LEFT JOIN device_status st ON st.device_id = d.id "
"WHERE d.switch_id=? AND d.deleted_at IS NULL ORDER BY d.port, d.name",
(switch_id,),
)
return render_template(
"tesm/switch_detail.html",
switch=switch,
host_key=host_key,
host_keys=all_keys,
devices=devices,
credentials=inventory.list_credentials(conn),
safe_commands=switchcli.SAFE_COMMANDS,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
)
# --------------------------------------------------------------------------- #
# Host-Schluessel
# --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/hostkey/pruefen")
@permission_required("switches.edit")
def probe_host_key(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
if switch is None:
abort(404)
try:
record = ssh.probe_host_key(str(switch["ip"]), int(switch["ssh_port"]))
except ssh.SshError as exc:
flash(str(exc), "danger")
return redirect(url_for("switches.detail", switch_id=switch_id))
level, message = ssh.describe_probe(conn, record)
flash(message, level)
return redirect(
url_for(
"switches.detail",
switch_id=switch_id,
probed_type=record.key_type,
probed_fingerprint=record.fingerprint,
)
)
@bp.post("/<int:switch_id>/hostkey/freigeben")
@permission_required("switches.edit")
@reauth_required
def trust_host_key(switch_id: int):
"""Gibt einen Host-Schluessel ausdruecklich frei.
Nur ein Mensch mit frischer Passwortbestaetigung kann das -- kein
Hintergrundprozess und kein Automatismus.
"""
conn = db()
switch = inventory.get_switch(conn, switch_id)
if switch is None:
abort(404)
key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id))
with transaction():
ssh.trust_key(
conn,
host=str(switch["ip"]),
port=int(switch["ssh_port"]),
key_type=key_type,
fingerprint=fingerprint,
actor=current_user.username,
)
_audit(
conn,
"switch.host_key_trusted",
str(switch["hostname"]),
f"{key_type} {fingerprint}",
severity="notice",
)
flash("Host-Schluessel freigegeben.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id))
@bp.post("/<int:switch_id>/hostkey/verwerfen")
@permission_required("switches.edit")
def forget_host_key(switch_id: int):
conn = db()
switch = inventory.get_switch(conn, switch_id)
if switch is None:
abort(404)
with transaction():
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info")
return redirect(url_for("switches.detail", switch_id=switch_id))
# --------------------------------------------------------------------------- #
# Kommandos
# --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/kommando")
@permission_required("switches.execute")
@reauth_required
def run_command(switch_id: int):
"""Fuehrt ein Kommando aus der Positivliste aus.
Der schnelle, sichere Weg fuer den Alltag: kein interaktives Terminal, nur
lesende Kommandos aus einer festen Liste. Wer wirklich konfigurieren muss,
nutzt das Terminal -- das ebenfalls Recht und Bestaetigung verlangt und
zusaetzlich vollstaendig mitgeschrieben wird.
"""
extension = core()
conn = db()
switch = inventory.get_switch(conn, switch_id)
if switch is None:
abort(404)
command = (request.form.get("command") or "").strip()
allowed = {entry[0] for entry in switchcli.SAFE_COMMANDS}
if command not in allowed:
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id))
if not switch["credential_id"]:
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id))
try:
username, password = inventory.credential_secret(
conn, extension.keystore, int(switch["credential_id"])
)
with switchcli.SwitchCli(
conn,
host=str(switch["ip"]),
port=int(switch["ssh_port"]),
username=username,
password=password,
) as cli:
output = cli.send(command, tolerate_error=True)
except (ssh.SshError, inventory.ValidationError) as exc:
flash(str(exc), "danger")
return redirect(url_for("switches.detail", switch_id=switch_id))
with transaction():
_audit(
conn,
"switch.command",
str(switch["hostname"]),
command,
severity="notice",
)
conn.execute(
"UPDATE switches SET last_seen_at=datetime('now') WHERE id=?", (switch_id,)
)
return render_template(
"tesm/switch_command.html", switch=switch, command=command, output=output
)
+397
View File
@@ -0,0 +1,397 @@
"""Konsole im Browser -- fuer Switche und fuer Clients, zeilenorientiert.
Bewusste Entscheidung gegen ein vollstaendiges Terminal-Emulat: der Vorgaenger
lieferte dafuer xterm.js als Fremdcode im Repo mit (mehrere hundert Kilobyte,
eigene Aktualisierungspflicht) und brauchte einen dauerhaft blockierten
Worker-Thread pro Sitzung. Eine Zeile hinein, eine Antwort heraus deckt beide
Faelle ab -- die ArubaOS-Kommandozeile ist ohnehin zeilenorientiert, und auf
einem Linux-Client ist eine eigene ``exec``-Sitzung je Kommando sogar
robuster als eine geteilte Shell.
Zwei Ziele, ein Ablauf:
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
:class:`~tesm.services.switchcli.SwitchCli`.
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
:class:`~tesm.services.shellcli.ShellSession`.
Die Rechte sind absichtlich verschieden: ``devices.execute`` erlaubt heute
einen PoE-Neustart. Wer ein Geraet neu starten darf, soll damit nicht
automatisch eine Shell darauf bekommen -- das waere genau die stille
Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
Gemeinsame Sicherheitsmerkmale:
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
* Abschaltbar ueber die Einstellung ``terminal_enabled``.
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
* Jede Sitzung wird vollstaendig mitgeschrieben (Datei unter
``<log>/terminal/``) und im Aenderungsprotokoll vermerkt.
* Leerlauf-Zeitlimit trennt die Verbindung.
"""
from __future__ import annotations
import json
import logging
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from flask import Blueprint, abort, render_template
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip, reauth_is_fresh
from tesm_core.extension import core, db
from ..services import inventory, shellcli, ssh, switchcli
logger = logging.getLogger(__name__)
bp = Blueprint("terminal", __name__)
MAX_LINE_LENGTH = 400
KIND_SWITCH = "switch"
KIND_DEVICE = "device"
#: Vorschlaege fuer die Auswahlliste auf einem Linux-Client. Reine
#: Lesekommandos -- die Liste ist eine Bequemlichkeit, keine Schranke: das
#: Eingabefeld nimmt jedes Kommando an. Eine Positivliste waere hier
#: Sicherheitstheater, denn wer eine Shell hat, hat eine Shell.
CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("uptime", "Laufzeit und Last"),
("df -h", "Belegung der Dateisysteme"),
("free -h", "Arbeitsspeicher"),
("ip -brief address", "Netzwerkadressen"),
("systemctl --failed", "Fehlgeschlagene Dienste"),
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"),
)
@dataclass(frozen=True, slots=True)
class Target:
"""Alles, was die Konsole ueber ihr Gegenueber wissen muss."""
kind: str
id: int
label: str
host: str
port: int
credential_id: int
back_endpoint: str
back_label: str
@property
def is_switch(self) -> bool:
return self.kind == KIND_SWITCH
@property
def audit_prefix(self) -> str:
return "switch" if self.is_switch else "device"
def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
if kind == KIND_SWITCH:
row = inventory.get_switch(conn, target_id)
if row is None:
return None
return Target(
kind=KIND_SWITCH,
id=int(row["id"]),
label=str(row["hostname"]),
host=str(row["ip"]),
port=int(row["ssh_port"] or 22),
credential_id=int(row["credential_id"] or 0),
back_endpoint="switches.detail",
back_label="Switche",
)
row = inventory.get_device(conn, target_id)
if row is None or row["deleted_at"]:
return None
return Target(
kind=KIND_DEVICE,
id=int(row["id"]),
label=str(row["name"]),
host=str(row["ip"]),
port=int(row["ssh_port"] or 22),
credential_id=int(row["credential_id"] or 0),
back_endpoint="devices.detail",
back_label="Clients",
)
def _client_module_active() -> bool:
manager = core().license
return manager is None or manager.module_active("maintenance")
def _render_console(target: Target): # type: ignore[no-untyped-def]
extension = core()
conn = db()
if not extension.settings.get(conn, "terminal_enabled"):
abort(404)
if not target.credential_id:
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")
return render_template(
"tesm/console.html",
target=target,
host_key_ready=ssh.is_trusted(conn, target.host, target.port),
presets=switchcli.SAFE_COMMANDS if target.is_switch else CLIENT_COMMANDS,
idle_timeout=int(extension.settings.get(conn, "terminal_idle_timeout") or 300),
websocket_path=f"/ws/{target.kind}/{target.id}",
)
@bp.get("/switche/<int:switch_id>/konsole")
@permission_required("switches.execute")
@reauth_required
def switch_console(switch_id: int):
target = _load_target(db(), KIND_SWITCH, switch_id)
if target is None:
abort(404)
return _render_console(target)
@bp.get("/clients/<int:device_id>/konsole")
@permission_required("maintenance.execute")
@reauth_required
def device_console(device_id: int):
if not _client_module_active():
abort(404)
target = _load_target(db(), KIND_DEVICE, device_id)
if target is None:
abort(404)
return _render_console(target)
def init_app(app) -> None: # type: ignore[no-untyped-def]
"""Meldet die HTTP-Routen und -- falls verfuegbar -- die WebSockets an."""
app.register_blueprint(bp)
try:
from flask_sock import Sock
except ImportError: # pragma: no cover - optionale Abhaengigkeit
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.")
return
sock = Sock(app)
app.config.setdefault("SOCK_SERVER_OPTIONS", {"ping_interval": 25})
@sock.route("/ws/switch/<int:target_id>")
def switch_socket(ws, target_id: int): # type: ignore[no-untyped-def]
_handle_socket(ws, KIND_SWITCH, target_id)
@sock.route("/ws/device/<int:target_id>")
def device_socket(ws, target_id: int): # type: ignore[no-untyped-def]
_handle_socket(ws, KIND_DEVICE, target_id)
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
ws.send(json.dumps({"type": kind, "data": text}))
def _transcript_name(label: str) -> str:
"""Dateiname aus dem Ziel -- ohne alles, was einen Pfad ergeben koennte."""
return re.sub(r"[^A-Za-z0-9._-]", "_", label)[:60] or "unbenannt"
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
extension = core()
conn = db()
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.")
return
if not reauth_is_fresh():
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
return
if not extension.settings.get(conn, "terminal_enabled"):
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
return
if kind == KIND_DEVICE and not _client_module_active():
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.")
return
target = _load_target(conn, kind, target_id)
if target is None or not target.credential_id:
_send(ws, "error", "Ziel oder Zugangsdaten nicht gefunden.")
return
idle_timeout = int(extension.settings.get(conn, "terminal_idle_timeout") or 300)
transcript_dir = extension.config.log_dir / "terminal"
transcript_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
transcript_path = (
transcript_dir
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
)
try:
username, password = inventory.credential_secret(
conn, extension.keystore, target.credential_id
)
except inventory.ValidationError as exc:
_send(ws, "error", str(exc))
return
with extension.database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=f"{target.audit_prefix}.console_opened",
target=target.label,
detail=f"Mitschrift: {transcript_path.name}",
ip=client_ip(),
severity="warning",
)
commands = 0
try:
with transcript_path.open("a", encoding="utf-8", newline="\n") as transcript:
transcript.write(
f"# Sitzung {stamp} von {current_user.username} auf {target.label} "
f"({target.host}:{target.port})\n"
)
if target.is_switch:
commands = _run_switch_session(
ws, conn, target, username, password, transcript, idle_timeout
)
else:
commands = _run_client_session(
ws, conn, target, username, password, transcript, idle_timeout
)
except ssh.UnknownHostKey as exc:
page = "Switch-Seite" if target.is_switch else "Client-Seite"
_send(
ws,
"error",
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der "
f"{page} ausdruecklich freigeben.",
)
except ssh.SshError as exc:
_send(ws, "error", str(exc))
except Exception as exc: # noqa: BLE001
logger.exception("Konsolensitzung abgebrochen")
_send(ws, "error", f"Unerwarteter Fehler: {exc}")
finally:
with extension.database.session() as fresh, extension.database.transaction(fresh):
audit.log(
fresh,
actor=current_user.username,
actor_id=current_user.id,
action=f"{target.audit_prefix}.console_closed",
target=target.label,
detail=f"{commands} Kommandos, Mitschrift {transcript_path.name}",
ip=client_ip(),
severity="notice",
)
def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyped-def]
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
raw = ws.receive(timeout=idle_timeout)
if raw is None:
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.")
return None
try:
message = json.loads(raw)
except ValueError:
return ""
command = str(message.get("command") or "").strip()
if not command:
return ""
if len(command) > MAX_LINE_LENGTH:
_send(ws, "error", "Die Zeile ist zu lang.")
return ""
if command.lower() in {"exit", "quit", "logout"}:
_send(ws, "info", "Sitzung beendet.")
return None
return command
def _run_switch_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int:
commands = 0
with switchcli.SwitchCli(
conn, host=target.host, port=target.port, username=username, password=password
) as cli:
_send(ws, "ready", f"Verbunden mit {target.label} ({target.host}).")
_send(ws, "output", cli.full_transcript)
while True:
command = _read_command(ws, idle_timeout)
if command is None:
break
if not command:
continue
transcript.write(f"$ {command}\n")
started = time.monotonic()
try:
output = cli.send(command, tolerate_error=True)
except ssh.SshError as exc:
_send(ws, "error", str(exc))
transcript.write(f"! {exc}\n")
break
duration = int((time.monotonic() - started) * 1000)
transcript.write(output + "\n")
transcript.flush()
commands += 1
_send(ws, "output", output)
_send(ws, "done", f"{duration} ms")
return commands
def _run_client_session( # type: ignore[no-untyped-def]
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int:
commands = 0
with shellcli.ShellSession(
conn, host=target.host, port=target.port, username=username, password=password
) as session:
_send(ws, "ready", f"Verbunden mit {target.label} ({target.host}) als {username}.")
banner = session.banner()
if banner:
_send(ws, "output", banner)
transcript.write(banner + "\n")
while True:
command = _read_command(ws, idle_timeout)
if command is None:
break
if not command:
continue
transcript.write(f"$ {command}\n")
try:
result = session.run(command)
except ssh.SshError as exc:
_send(ws, "error", str(exc))
transcript.write(f"! {exc}\n")
break
transcript.write(result["output"] + "\n")
transcript.flush()
commands += 1
if result["output"]:
_send(ws, "output", result["output"])
# Der Rueckgabewert ist auf einem Client die eigentliche Antwort:
# ein Kommando ohne Ausgabe kann trotzdem fehlgeschlagen sein.
if result["status"] == 0:
_send(ws, "done", f"{result['duration_ms']} ms")
else:
_send(
ws,
"error",
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.",
)
return commands
+303
View File
@@ -0,0 +1,303 @@
"""Kommandozeilenwerkzeuge: Administrator anlegen, Zustand pruefen, Wartung.
Ersetzt ``create_db.py``/``create_admin.py`` des Vorgaengers. Die Datenbank wird
nicht mehr von einem eigenen Skript erzeugt -- das Schema entsteht beim ersten
Start durch die Migrationen.
"""
from __future__ import annotations
import argparse
import getpass
import json
import sys
from typing import Any
from tesm_core import audit
from tesm_core.cli_web import add_web_setup, run_web_setup
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.rbac import service as rbac
from tesm_core.security import passwords
from . import create_app
def _app_and_core() -> tuple[Any, Any]:
app = create_app()
return app, core(app)
def cmd_create_admin(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
username = args.username or input("Benutzername: ").strip()
if not username:
print("Kein Benutzername angegeben.", file=sys.stderr)
return 2
password = args.password
if not password:
password = getpass.getpass("Passwort: ")
if password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
return 2
problems = extension.password_policy.check(password, username=username)
if problems and not args.force:
for problem in problems:
print(f"- {problem}", file=sys.stderr)
print("Mit --force trotzdem setzen.", file=sys.stderr)
return 2
with app.app_context(), extension.database.session() as conn:
existing = rbac.find_user_by_username(conn, username, include_deleted=True)
with extension.database.transaction(conn):
if existing is not None:
conn.execute(
"UPDATE users SET password_hash=?, is_admin=1, is_locked=0, deleted_at=NULL, "
"must_change_password=0, updated_at=datetime('now') WHERE id=?",
(passwords.hash_password(password), existing["id"]),
)
action = "aktualisiert"
user_id = int(existing["id"])
else:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, "
"created_at, updated_at, password_changed_at) "
"VALUES (?,?,1,'local',datetime('now'),datetime('now'),datetime('now'))",
(username, passwords.hash_password(password)),
)
user_id = int(cursor.lastrowid or 0)
action = "angelegt"
audit.log(
conn,
actor="cli",
actor_id=user_id,
action="user.admin_created",
target=username,
detail=f"ueber die Kommandozeile {action}",
severity="warning",
)
print(f"Administrator {username!r} {action}.")
return 0
def cmd_status(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
with app.app_context(), extension.database.session() as conn:
chain = audit.verify_chain(conn, limit=5000)
counts = {
"benutzer": Database.value(
conn, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL", default=0
),
"administratoren": rbac.count_active_admins(conn),
"gruppen": Database.value(
conn, "SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL", default=0
),
"geraete": Database.value(
conn, "SELECT COUNT(*) FROM devices WHERE deleted_at IS NULL", default=0
),
"switche": Database.value(
conn, "SELECT COUNT(*) FROM switches WHERE deleted_at IS NULL", default=0
),
"protokollzeilen": audit.count(conn),
}
license_state = None
if extension.license is not None:
state = extension.license.reload(conn)
license_state = {
"vorhanden": state.present,
"gueltig": state.valid,
"aktiviert": state.activated,
"rolle": state.role,
"module": sorted(state.modules),
"fehler": state.error,
}
payload = {
"datenbank": str(extension.config.db_path),
"protokolle": str(extension.config.log_dir),
"migrationen": extension.migration_status(),
"audit_kette": chain,
"bestand": counts,
"lizenz": license_state,
"systemhelfer": bool(extension.sysops and extension.sysops.available),
}
if args.json:
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
return 0
print(f"Datenbank : {payload['datenbank']}")
print(f"Protokolle : {payload['protokolle']}")
print(f"Systemhelfer : {'verfuegbar' if payload['systemhelfer'] else 'nicht verfuegbar'}")
print(
"Audit-Kette : "
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
+ f" ({chain['checked']} geprueft)"
)
for key, value in counts.items():
print(f"{key.capitalize():<15}: {value}")
if license_state:
print(
"Lizenz : "
+ ("aktiv" if license_state["aktiviert"] and license_state["gueltig"] else "nicht aktiv")
+ f", Rolle {license_state['rolle']}, Module {', '.join(license_state['module']) or '-'}"
)
if license_state["fehler"]:
print(f"Lizenzfehler : {license_state['fehler']}")
if not counts["administratoren"]:
print("\nWARNUNG: es existiert kein entsperrtes Administratorkonto.", file=sys.stderr)
return 1
return 0
def cmd_housekeeping(_args: argparse.Namespace) -> int:
app, extension = _app_and_core()
from tesm_core.security import ratelimit
from tesm_core.security import sessions as session_store
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
sessions = session_store.prune(conn)
events = ratelimit.prune(conn)
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
print(
f"{sessions} Sitzungen entfernt, {events} Ratenereignisse entfernt, "
f"{len(archived)} Protokolltage archiviert."
)
return 0
def cmd_check_now(_args: argparse.Namespace) -> int:
app, extension = _app_and_core()
from .services import inventory, monitor
with app.app_context():
allowed = extension.license is None or extension.license.feature_allowed(
"auto_poe_restart"
)
report = monitor.run_sweep(
database=extension.database,
live=extension.registry["live_log"],
keystore=extension.keystore,
license_allows_auto_restart=allowed,
)
print(
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, "
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
f"{report.duration_ms} ms"
)
return 0
def cmd_web_setup(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
return run_web_setup(app, extension, args)
def cmd_convert_legacy(args: argparse.Namespace) -> int:
"""Uebersetzt einen Export des alten TESM in das neue Format.
Bewusst ohne Datenbankzugriff: heraus kommt eine Datei, die ueber den
vorhandenen Import eingelesen wird. So sieht man vorher, was ankommt, und
es gibt nur einen Schreibpfad in die Datenbank.
"""
from getpass import getpass
from pathlib import Path
from .services import legacy_transfer as legacy
from .services import transfer
quelle = Path(args.datei)
if not quelle.is_file():
print(f"Datei nicht gefunden: {quelle}")
return 2
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
return 2
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
try:
rohdaten = legacy.open_legacy(quelle.read_bytes(), alt_pass)
nutzlast, bericht = legacy.convert(rohdaten)
except legacy.LegacyTransferError as exc:
print(f"Fehlgeschlagen: {exc}")
return 1
print(bericht.as_text())
if args.dry_run:
print()
print("--dry-run: keine Datei geschrieben.")
return 0
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ")
try:
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
print(f"Schreiben fehlgeschlagen: {exc}")
return 1
print()
print(f"Geschrieben: {ziel}")
print("Einlesen unter Verwaltung -> Sicherung mit derselben Passphrase.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
)
sub = parser.add_subparsers(dest="command", required=True)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
admin.add_argument("--username")
admin.add_argument("--password", help="Nur fuer automatisierte Installationen")
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
admin.set_defaults(func=cmd_create_admin)
status = sub.add_parser("status", help="Zustand der Installation ausgeben")
status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen")
house.set_defaults(func=cmd_housekeeping)
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren")
check.set_defaults(func=cmd_check_now)
legacy = sub.add_parser(
"convert-legacy",
help="Export des alten TESM in das neue Format uebersetzen",
description=(
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im "
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, "
"Zugangsdaten und die DHCP-Konfiguration."
),
)
legacy.add_argument("datei", help="Exportdatei des alten TESM")
legacy.add_argument("--ausgabe", help="Zieldatei (Vorgabe: <name>-neu.json)")
legacy.add_argument(
"--passphrase", help="Passphrase der alten Datei (sonst wird gefragt)"
)
legacy.add_argument(
"--neue-passphrase", dest="neue_passphrase", help="Passphrase der neuen Datei"
)
legacy.add_argument(
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
)
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
legacy.set_defaults(func=cmd_convert_legacy)
add_web_setup(sub, cmd_web_setup)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+245
View File
@@ -0,0 +1,245 @@
"""Navigationsbaum von TESM.
Sichtbarkeit steht am Eintrag selbst -- nicht in einer zentralen if/elif-Kette
wie im Vorgaenger. Lizenzpflichtige Vollmodule verschwinden vollstaendig aus der
Navigation; die zugehoerigen Routen pruefen dasselbe zusaetzlich serverseitig
(ein direkter Aufruf per URL wird ebenso abgewiesen).
"""
from __future__ import annotations
from typing import Any
from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree
def _module(name: str):
"""Bedingung: Vollmodul lizenziert?"""
def check(_user: Any) -> bool:
manager = core().license
return manager is None or manager.module_active(name)
return check
def _has_fileshare_mounts(user: Any) -> bool:
"""Dateifreigaben erscheinen, wenn es etwas zu sehen oder zu tun gibt.
Zu sehen: eingebundene Freigaben dieses Benutzers. Zu tun: die Verwaltung
der Zuordnungen. Der zweite Fall fehlte -- damit war die einzige Seite, auf
der Zuordnungen entstehen, nur ueber die Adresszeile erreichbar, und wer sie
nicht kannte, hielt die Funktion fuer nicht vorhanden.
"""
if not user.is_authenticated:
return False
if not _module("fileshare")(user):
return False
if user.has("fileshare.edit"):
return True
from .services import fileshare
return fileshare.has_mounts(user.id)
NAVIGATION = NavTree(
[
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"),
NavItem(
key="monitoring",
label="Ueberwachung",
icon="cpu",
children=(
NavItem(
key="devices",
label="Clients",
icon="cpu",
endpoint="devices.overview",
permissions=("devices.view",),
),
NavItem(
key="switches",
label="Switche",
icon="switch",
endpoint="switches.overview",
permissions=("switches.view",),
),
NavItem(
key="credentials",
label="Zugangsdaten",
icon="key",
endpoint="credentials.overview",
permissions=("credentials.view",),
),
NavItem(
key="hostkeys",
label="Host-Schluessel",
icon="shield",
endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"),
),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
condition=_module("maintenance"),
),
),
),
NavItem(
key="dhcp",
label="DHCP-Server",
icon="network",
endpoint="dhcp.overview",
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
condition=_module("dhcp"),
),
NavItem(
key="fileshare",
label="Dateifreigaben",
icon="folder",
condition=_has_fileshare_mounts,
children=(
NavItem(
key="fileshare_browse",
label="Meine Freigaben",
icon="folder",
endpoint="fileshare.browse",
permissions=("fileshare.view",),
),
NavItem(
key="fileshare_mappings",
label="Zuordnungen",
icon="link",
endpoint="fileshare.mappings",
permissions=("fileshare.edit",),
),
),
),
NavItem(
key="observability",
label="Protokolle",
icon="terminal",
children=(
NavItem(
key="logs_live",
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Aenderungen",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
),
NavItem(
key="logs_dhcp",
label="Kea-DHCP",
icon="network",
endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",),
condition=_module("dhcp"),
),
NavItem(
key="restarts",
label="Neustarts",
icon="power",
endpoint="tesmlogs.restarts",
permissions=("devices.view",),
),
),
),
NavItem(
key="administration",
label="Verwaltung",
icon="sliders",
children=(
NavItem(
key="users",
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
),
NavItem(
key="settings_license",
label="Lizenz",
icon="key",
endpoint="license.overview",
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
# sonst liesse sich eine neue Lizenz nie einspielen.
permissions=("settings_license.view",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
),
),
),
]
)
+222
View File
@@ -0,0 +1,222 @@
"""Rechtematrix von TESM.
Bewusste Aenderungen gegenueber dem Vorgaenger (dort: R/W/E/D/X mit
bereichsabhaengiger Bedeutung):
* **Loeschen ist ein eigenes Recht.** Vorher deckte "Aendern" das Loeschen mit
ab -- wer Geraete pflegen sollte, durfte sie zwangslaeufig auch entfernen.
* **Jede Spalte bedeutet ueberall dasselbe.** "Ausfuehren" ist immer "eine
Aktion ausloesen", nicht mal Neustart und mal Export.
* **Geheimnisse sind ein eigenes Recht.** Wer Zugangsdaten *pflegen* darf, muss
sie nicht im Klartext *sehen* duerfen. Zusaetzlich verlangt jede Anzeige eine
frische Passwortbestaetigung.
* **Jede Einstellungsseite hat ihr eigenes Rechtepaar** -- im Vorgaenger lief
die Lizenzseite ueber ``settings_system`` mit, was sich nicht getrennt
vergeben liess.
"""
from __future__ import annotations
from tesm_core.rbac.common import (
LOGS_HISTORY,
LOGS_LIVE,
administration_area,
observability_area,
)
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
A = Action
DEVICES = Resource(
key="devices",
label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Ueberwachte PoE-Endgeraete",
action_hints={
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
A.DELETE: "Geraet in den Papierkorb legen.",
},
)
SWITCHES = Resource(
key="switches",
label="Switche",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Aruba-Switche und ihre Erreichbarkeit",
action_hints={
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. "
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird "
"vollstaendig protokolliert.",
},
)
CREDENTIALS = Resource(
key="credentials",
label="Zugangsdaten",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins",
action_hints={
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestaetigung und wird protokolliert.",
},
)
MAINTENANCE = Resource(
key="maintenance",
label="Wartung",
actions=(A.VIEW, A.EXECUTE),
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
)
DHCP = Resource(
key="dhcp",
label="DHCP-Server",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
action_hints={
A.EDIT: "Konfiguration aendern und schreiben.",
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
},
)
FILESHARE = Resource(
key="fileshare",
label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.",
},
)
LOGS_DHCP = Resource(
key="logs_dhcp",
label="Kea-DHCP-Protokoll",
actions=(A.VIEW,),
)
MONITORING_AREA = Area(
key="monitoring",
label="Ueberwachung",
icon="cpu",
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
)
NETWORK_AREA = Area(
key="network",
label="Netzwerkdienste",
icon="network",
resources=(DHCP,),
)
FILES_AREA = Area(
key="files",
label="Dateifreigaben",
icon="folder",
resources=(FILESHARE,),
)
PERMISSIONS = PermissionTree(
[
MONITORING_AREA,
NETWORK_AREA,
FILES_AREA,
observability_area(LOGS_DHCP),
administration_area(),
]
)
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
is_default=True,
),
RolePreset(
key="operations",
name="Betrieb",
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.",
grants=(
("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
("credentials", (A.VIEW,)),
("maintenance", "*"),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW,)),
("trash", (A.VIEW, A.EDIT)),
),
),
RolePreset(
key="support",
name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.",
grants=(
("devices", (A.VIEW, A.EXECUTE)),
("switches", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
),
RolePreset(
key="network",
name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.",
grants=(
("dhcp", "*"),
("settings_network", "*"),
("logs_dhcp", (A.VIEW,)),
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
grants=(
("devices", (A.VIEW,)),
("switches", (A.VIEW,)),
("credentials", (A.VIEW,)),
("dhcp", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
("trash", (A.VIEW,)),
),
),
RolePreset(
key="administration",
name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
grants=(
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
),
),
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
+257
View File
@@ -0,0 +1,257 @@
"""Migrationen der Komponente ``tesm``.
Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
``ALTER TABLE``-Schritte des Vorgaengers. Inhaltliche Aenderungen am Datenmodell:
* **Kuenstliche Schluessel.** Switche hatten den Hostnamen als Primaerschluessel,
Geraete die MAC. Ein Umbenennen bzw. ein Tausch der Netzwerkkarte zog damit
jede Referenz nach sich. Jetzt: ``id`` als Schluessel, Hostname/MAC bleiben
eindeutig -- aber nur unter den nicht geloeschten Zeilen (Teilindex), damit ein
Eintrag im Papierkorb den Namen nicht auf Dauer blockiert.
* **Status getrennt von Stammdaten.** Erreichbarkeit, Fehlerzaehler und
Neustart-Historie liegen in ``device_status``/``restart_events``, nicht in der
Geraetetabelle. Der Ueberwachungsprozess schreibt damit ausschliesslich in
Tabellen, die niemand von Hand pflegt.
* **Nachvollziehbare Neustarts.** Der Vorgaenger protokollierte sie
ausdruecklich *nicht* im Aenderungslog, nur in einer Textdatei. Jetzt gibt es
``restart_events`` -- mit Ergebnis, Dauer, Ausloeser und **Methode**: ein
Neustart ueber den PoE-Port des Switches und einer per ``reboot`` ueber SSH
sind fuer den Betrieb dasselbe Ereignis und gehoeren in dieselbe Liste.
* **Host-Schluessel des Switches** werden gespeichert und geprueft
(``switch_host_keys``), statt sie im Terminal blind zu akzeptieren.
"""
from __future__ import annotations
from tesm_core.migrations import Migration
V1 = """
-- Wiederverwendbare Zugangsdaten. Das Passwort liegt AES-GCM-verschluesselt vor,
-- gebunden an "credentials.secret:<id>" als AAD.
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
username TEXT NOT NULL DEFAULT '',
secret_encrypted TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'switch',
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_credentials_name_live
ON credentials(name) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS switches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hostname TEXT NOT NULL COLLATE NOCASE,
ip TEXT NOT NULL,
ssh_port INTEGER NOT NULL DEFAULT 22,
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
model TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
last_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_switches_hostname_live
ON switches(hostname) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT NOT NULL COLLATE NOCASE,
ip TEXT NOT NULL,
name TEXT NOT NULL,
switch_id INTEGER REFERENCES switches(id) ON DELETE SET NULL,
port TEXT NOT NULL DEFAULT '',
is_active INTEGER NOT NULL DEFAULT 1,
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
ssh_port INTEGER NOT NULL DEFAULT 22,
category TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
auto_restart INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT '',
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_live
ON devices(mac) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_devices_switch ON devices(switch_id);
CREATE INDEX IF NOT EXISTS idx_devices_active ON devices(is_active, deleted_at);
-- Laufender Zustand der Ueberwachung. Getrennt von den Stammdaten, damit der
-- Ueberwachungsprozess nie in vom Menschen gepflegte Spalten schreibt.
CREATE TABLE IF NOT EXISTS device_status (
device_id INTEGER PRIMARY KEY REFERENCES devices(id) ON DELETE CASCADE,
state TEXT NOT NULL DEFAULT 'unknown',
latency_ms REAL,
fail_count INTEGER NOT NULL DEFAULT 0,
last_check_at TEXT,
last_ok_at TEXT,
last_change_at TEXT,
last_restart_at TEXT,
restart_count INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT ''
);
-- Jeder PoE-Vorgang, automatisch oder manuell, mit Ergebnis.
CREATE TABLE IF NOT EXISTS poe_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
ts TEXT NOT NULL,
trigger TEXT NOT NULL DEFAULT 'auto',
actor TEXT NOT NULL DEFAULT 'system',
action TEXT NOT NULL,
result TEXT NOT NULL,
duration_ms INTEGER,
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_poe_events_device ON poe_events(device_id, ts);
CREATE INDEX IF NOT EXISTS idx_poe_events_ts ON poe_events(ts);
-- Gespeicherte Host-Schluessel der Switche. Ein Wechsel wird gemeldet, statt
-- stillschweigend akzeptiert zu werden.
CREATE TABLE IF NOT EXISTS switch_host_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
added_at TEXT NOT NULL,
added_by TEXT NOT NULL DEFAULT '',
UNIQUE(host, port, key_type)
);
-- DHCP (Kea)
CREATE TABLE IF NOT EXISTS dhcp_subnets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
interface TEXT NOT NULL,
range_start TEXT NOT NULL,
range_end TEXT NOT NULL,
gateway TEXT NOT NULL DEFAULT '',
dns TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS dhcp_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_standard INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS dhcp_option_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
option_id INTEGER NOT NULL REFERENCES dhcp_options(id) ON DELETE CASCADE,
device_id INTEGER REFERENCES devices(id) ON DELETE CASCADE,
value TEXT NOT NULL
);
-- Ein globaler Wert je Option (device_id IS NULL) und hoechstens ein Wert je
-- Option und Geraet. Zwei Teilindizes, weil NULL in SQLite in einem UNIQUE
-- nicht mit sich selbst kollidiert.
CREATE UNIQUE INDEX IF NOT EXISTS idx_dhcp_value_global
ON dhcp_option_values(option_id) WHERE device_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_dhcp_value_device
ON dhcp_option_values(option_id, device_id) WHERE device_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS dhcp_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT NOT NULL UNIQUE COLLATE NOCASE,
ip TEXT NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
-- Dateifreigaben: Zuordnung Verzeichnisgruppe -> UNC-Pfad.
CREATE TABLE IF NOT EXISTS fileshare_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL,
ad_group_name TEXT NOT NULL,
label TEXT NOT NULL,
unc TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(ad_group_dn, unc)
);
-- Aktive Mounts je Sitzung. Der Sweep-Prozess raeumt hierueber auf, statt das
-- Dateisystem zu durchsuchen.
CREATE TABLE IF NOT EXISTS fileshare_mounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
session_id TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL,
unc TEXT NOT NULL,
mount_path TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
released_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_fileshare_mounts_user ON fileshare_mounts(user_id, released_at);
"""
def seed_dhcp_options(conn) -> None: # type: ignore[no-untyped-def]
"""Legt den kuratierten Katalog der DHCP-Standardoptionen an.
56 Eintraege, gegen eine echte Kea-Instanz verifiziert. Bewusst *nicht*
enthalten:
* Optionen mit eigenen Feldern in der Oberflaeche: Router (3), DNS (6),
Domain-Name (15), Lease-Zeiten (51/58/59).
* ``host-name`` (12): Kea beantwortet die Anfrage bereits ueber das
``hostname``-Feld der Reservierung. Ein zusaetzlicher Override ist laut
Kea-Dokumentation eine Widerspruchsquelle -- der Vorgaenger hatte den
Eintrag zeitweise drin und musste ihn per Migration wieder entfernen.
* Client- und protokollinterne Optionen.
"""
from .services.kea import STANDARD_OPTIONS
conn.executemany(
"INSERT INTO dhcp_options (code, name, type, description, is_standard) "
"VALUES (?,?,?,?,1) ON CONFLICT(code) DO NOTHING",
STANDARD_OPTIONS,
)
# Version 3 -----------------------------------------------------------------
#
# ``poe_events`` hiess so, weil es anfangs nur PoE-Neustarts gab. Inzwischen
# startet auch die Wartung Geraete neu -- per ``reboot`` ueber SSH. Beides ist
# aus Betriebssicht dasselbe Ereignis ("dieses Geraet wurde neu gestartet"),
# und beides gehoert in dieselbe Liste. Ein Name, der nur die halbe Wahrheit
# sagt, faellt spaetestens dem naechsten auf die Fuesse, der die Datenbank
# liest.
#
# Die Spalte ``method`` haelt fest, *wie* neu gestartet wurde. Bestehende
# Zeilen sind ausnahmslos PoE-Vorgaenge -- daher der Vorgabewert.
V3 = """
ALTER TABLE poe_events RENAME TO restart_events;
ALTER TABLE restart_events ADD COLUMN method TEXT NOT NULL DEFAULT 'poe';
DROP INDEX IF EXISTS idx_poe_events_device;
DROP INDEX IF EXISTS idx_poe_events_ts;
CREATE INDEX IF NOT EXISTS idx_restart_events_device ON restart_events(device_id, ts);
CREATE INDEX IF NOT EXISTS idx_restart_events_ts ON restart_events(ts);
CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, ts);
"""
TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3),
)
+1
View File
@@ -0,0 +1 @@
"""Fachdienste von TESM -- Netzwerk, SSH, DHCP, Dateifreigaben, Wartung, Sicherung."""
+443
View File
@@ -0,0 +1,443 @@
"""Dateifreigaben: SMB-Einbindungen und sicheres Blaettern im Dateibaum.
Einbindungen entstehen ausschliesslich nach einer erfolgreichen Anmeldung gegen
den Verzeichnisdienst -- lokale Konten haben hier grundsaetzlich nichts zu
sehen. *Welche* Freigaben sichtbar sind, entscheidet allein die Gruppen-
mitgliedschaft im Verzeichnis; das Recht ``fileshare.view`` entscheidet, ob
ueberhaupt eingebunden wird.
Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
* **Das Passwort geht nie ueber die Umgebung.** Der Vorgaenger uebergab es per
``PASSWD``-Variable an ``mount.cifs`` -- sichtbar in ``/proc/<pid>/environ``
fuer denselben Benutzer und fuer root. Hier schreibt der privilegierte Helfer
es in eine Datei mit Modus 0600, die unmittelbar danach geloescht wird.
* **Pfadpruefung mit ``Path.resolve()``.** Jeder Zugriff wird gegen das
Wurzelverzeichnis der Sitzung geprueft; Symlinks koennen nicht herausfuehren.
* **Einbindungen stehen in der Datenbank.** Der Aufraeumprozess liest sie dort,
statt das Dateisystem zu durchsuchen -- und ein Neustart des Dienstes verliert
nicht mehr die Information, was noch eingebunden ist.
"""
from __future__ import annotations
import logging
import mimetypes
import os
import re
import secrets
import shutil
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Iterable
from tesm_core.db import Database
from tesm_core.extension import core
logger = logging.getLogger(__name__)
_UNC_HINT = (
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche "
"gehen auch: //server/freigabe."
)
UNC_RE = re.compile(r"^\\\\[A-Za-z0-9._-]+\\[^\\/:*?\"<>|]+(\\[^\\/:*?\"<>|]+)*$")
#: Endungen, die inline angezeigt werden duerfen -- alles andere nur als Download.
#: ``.doc`` fehlt bewusst: alte Word-Dateien koennen Makros tragen und werden von
#: Browsern uneinheitlich behandelt.
PREVIEW_MIME: dict[str, str] = {
".pdf": "application/pdf",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".txt": "text/plain; charset=utf-8",
".csv": "text/plain; charset=utf-8",
".log": "text/plain; charset=utf-8",
".md": "text/plain; charset=utf-8",
".json": "text/plain; charset=utf-8",
".xml": "text/plain; charset=utf-8",
}
#: SVG ist bewusst *nicht* in der Vorschau: es kann Skript enthalten und wuerde
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
class FileshareError(Exception):
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def validate_unc(value: str) -> str:
"""Prueft und vereinheitlicht einen Freigabepfad.
Beide Schreibweisen sind erlaubt und bedeuten dasselbe::
\\\\dc2022\\daten
//dc2022/daten
Gespeichert wird immer die Backslash-Form; der Helfer dreht sie fuer
``mount`` wieder um. Ueberzaehlige Trenner und ein Trenner am Ende werden
entfernt, statt den Pfad abzulehnen: genau so sieht ein Pfad aus, den
jemand aus dem Explorer oder aus einem Browser kopiert hat, und eine
Ablehnung dafuer waere Schikane ohne Gewinn.
"""
text = (value or "").strip().replace("/", "\\")
# Der doppelte Trenner am Anfang macht den Pfad zur Freigabe. Ein einfacher
# waere ein absoluter Pfad auf diesem Rechner -- etwas voellig anderes.
if not text.startswith("\\\\"):
raise FileshareError(_UNC_HINT)
teile = [teil for teil in text.split("\\") if teil]
if len(teile) < 2:
raise FileshareError(_UNC_HINT)
text = "\\\\" + "\\".join(teile)
if not UNC_RE.match(text):
raise FileshareError(_UNC_HINT)
return text
# --------------------------------------------------------------------------- #
# Zuordnungen
# --------------------------------------------------------------------------- #
def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return Database.all(
conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE"
)
def add_mapping(
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
) -> int:
clean_unc = validate_unc(unc)
clean_label = (label or "").strip()
if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not (ad_group_dn or "").strip():
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
cursor = conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
(ad_group_dn.strip(), (ad_group_name or "").strip(), clean_label, clean_unc, _iso(_now())),
)
if not cursor.rowcount:
raise FileshareError("Diese Zuordnung existiert bereits.")
return int(cursor.lastrowid or 0)
def delete_mapping(conn: sqlite3.Connection, mapping_id: int) -> None:
conn.execute("DELETE FROM fileshare_mappings WHERE id=?", (mapping_id,))
def shares_for_groups(
conn: sqlite3.Connection, group_dns: Iterable[str]
) -> list[dict[str, Any]]:
"""Freigaben, auf die diese Verzeichnisgruppen Anspruch geben.
Es gibt bewusst keinen zweiten Weg: eine Freigabe wird mit dem Konto des
angemeldeten Verzeichnisbenutzers eingebunden. Ein lokales TESM-Konto hat
kein Passwort, das ein Fileserver akzeptieren wuerde -- eine Zuordnung an
eine lokale Gruppe waere deshalb eine Zuordnung, die nie zu einer
Einbindung fuehrt.
"""
lowered = {dn.strip().lower() for dn in group_dns if dn and dn.strip()}
if not lowered:
return []
return [
row
for row in list_mappings(conn)
if str(row["ad_group_dn"] or "").lower() in lowered
]
# --------------------------------------------------------------------------- #
# Einbindungen
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class Mount:
id: int
token: str
label: str
unc: str
path: Path
expires_at: str
def mount_root() -> Path:
from tesm_core.extension import db
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]:
rows = Database.all(
conn,
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL "
"AND expires_at > ? ORDER BY label COLLATE NOCASE",
(user_id, _iso(_now())),
)
return [
Mount(
id=int(row["id"]),
token=str(row["token"]),
label=str(row["label"]),
unc=str(row["unc"]),
path=Path(str(row["mount_path"])),
expires_at=str(row["expires_at"]),
)
for row in rows
]
def has_mounts(user_id: int) -> bool:
from tesm_core.extension import db
return bool(active_mounts(db(), user_id))
def mount_for_login(
conn: sqlite3.Connection,
*,
user_id: int,
session_id: str,
username: str,
password: str,
group_dns: Iterable[str] = (),
domain: str = "",
) -> list[str]:
"""Bindet alle Freigaben ein, auf die die Verzeichnisgruppen Anspruch geben.
Rueckgabe: Liste der Meldungen fuer das Protokoll. Fehler einzelner
Freigaben brechen die Anmeldung **nicht** ab -- der Benutzer soll sich
anmelden koennen, auch wenn ein Fileserver gerade nicht erreichbar ist.
"""
extension = core()
sysops = extension.sysops
if sysops is None or not sysops.available:
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."]
dns = [dn for dn in group_dns if dn and dn.strip()]
shares = shares_for_groups(conn, dns)
if not shares:
# Stillschweigen war hier der eigentliche Fehler: ob keine Gruppe ankam
# oder keine passte, liess sich von aussen nicht unterscheiden -- und
# ein Merkmal, das lautlos nichts tut, ist nicht zu diagnostizieren.
bekannt = len(list_mappings(conn))
if bekannt:
logger.info(
"Keine Freigabe passend: %d Verzeichnisgruppe(n) der Anmeldung gegen "
"%d Zuordnung(en). Gruppen: %s",
len(dns),
bekannt,
", ".join(dns) or "keine",
)
return []
# Ohne Passwort kaeme eine Gast-Einbindung zustande, die still danebengreift:
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
if not password:
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
messages: list[str] = []
for share in shares:
token = secrets.token_urlsafe(16)
target = root / token
result = sysops.mount_cifs(
str(share["unc"]), str(target), username, password, domain=domain
)
if not result.ok:
messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})")
continue
conn.execute(
"INSERT INTO fileshare_mounts (token, user_id, session_id, label, unc, mount_path, "
"created_at, expires_at) VALUES (?,?,?,?,?,?,?,?)",
(
token,
user_id,
session_id,
str(share["label"]),
str(share["unc"]),
str(target),
_iso(_now()),
_iso(_now() + timedelta(hours=hours)),
),
)
messages.append(f"{share['label']}: eingebunden")
return messages
def release_mount(conn: sqlite3.Connection, mount_id: int) -> None:
row = Database.one(conn, "SELECT * FROM fileshare_mounts WHERE id=?", (mount_id,))
if row is None:
return
sysops = core().sysops
if sysops is not None and sysops.available:
sysops.umount(str(row["mount_path"]))
conn.execute(
"UPDATE fileshare_mounts SET released_at=? WHERE id=?", (_iso(_now()), mount_id)
)
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
count = 0
for mount in active_mounts(conn, user_id):
release_mount(conn, mount.id)
count += 1
return count
def sweep_expired(database: Any) -> int:
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
Bewusst datenbankgetrieben: der Vorgaenger durchsuchte beim Start das
Dateisystem und raeumte dabei auch Einbindungen *anderer, aktiver* Sitzungen
ab, sobald ein Hilfsskript das Modul importierte.
"""
released = 0
with database.session() as conn:
rows = Database.all(
conn,
"SELECT id FROM fileshare_mounts WHERE released_at IS NULL AND expires_at <= ?",
(_iso(_now()),),
)
if not rows:
return 0
with database.transaction(conn):
for row in rows:
release_mount(conn, int(row["id"]))
released += 1
return released
# --------------------------------------------------------------------------- #
# Dateibaum
# --------------------------------------------------------------------------- #
def resolve_path(mount: Mount, relative: str) -> Path:
"""Loest einen relativen Pfad innerhalb einer Einbindung auf.
Wirft, sobald das Ergebnis das Wurzelverzeichnis verlaesst -- auch ueber
Symlinks, weil ``resolve()`` diesen folgt.
"""
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
if any(part == ".." for part in candidate.parts):
raise FileshareError("Ungueltiger Pfad.")
root = mount.path.resolve()
target = (root / str(candidate)).resolve()
if target != root and root not in target.parents:
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.")
return target
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
path = resolve_path(mount, relative)
if not path.is_dir():
raise FileshareError("Dieses Verzeichnis existiert nicht.")
directories: list[dict[str, Any]] = []
files: list[dict[str, Any]] = []
try:
for entry in sorted(path.iterdir(), key=lambda item: item.name.lower()):
try:
stat = entry.stat()
except OSError:
continue
record = {
"name": entry.name,
"relative": str(PurePosixPath(relative or "") / entry.name).lstrip("/"),
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
timespec="seconds"
),
}
if entry.is_dir():
directories.append(record)
else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
files.append(record)
except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
parts = [part for part in PurePosixPath(relative or "").parts]
breadcrumb = []
walked = PurePosixPath("")
for part in parts:
walked = walked / part
breadcrumb.append({"name": part, "relative": str(walked)})
return {
"directories": directories,
"files": files,
"breadcrumb": breadcrumb,
"relative": relative or "",
}
def preview_mime(name: str) -> str:
suffix = Path(name).suffix.lower()
if suffix in PREVIEW_MIME:
return PREVIEW_MIME[suffix]
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.")
def download_mime(name: str) -> str:
guessed, _ = mimetypes.guess_type(name)
return guessed or "application/octet-stream"
def safe_filename(name: str) -> str:
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
raise FileshareError("Ungueltiger Dateiname.")
if len(cleaned) > 200:
raise FileshareError("Der Dateiname ist zu lang.")
return cleaned
def delete_entry(mount: Mount, relative: str) -> str:
path = resolve_path(mount, relative)
if path == mount.path.resolve():
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.")
if path.is_dir():
shutil.rmtree(path)
return "Verzeichnis"
path.unlink()
return "Datei"
def make_directory(mount: Mount, relative: str, name: str) -> Path:
parent = resolve_path(mount, relative)
target = parent / safe_filename(name)
if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
target.mkdir()
return target
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
path = resolve_path(mount, relative)
target = path.parent / safe_filename(new_name)
if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target)
return target
+764
View File
@@ -0,0 +1,764 @@
"""Bestandsdaten: Zugangsdaten, Switche, Geraete.
Zugangsdaten sind -- wie im Vorgaenger -- eine eigene, wiederverwendbare
Entitaet und stehen nicht als Benutzer/Passwort-Paar an jedem Switch. Neu ist,
wie das Geheimnis liegt: AES-256-GCM mit rotierbarem Schluessel und dem
Verwendungszweck als zugeordnete Daten (``credentials.secret:<id>``). Damit ist
ein Chiffrat nicht an anderer Stelle einspielbar.
"""
from __future__ import annotations
import ipaddress
import re
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
from tesm_core.db import Database
from tesm_core.keystore import DecryptionError, Keystore
MAC_RE = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){5}$")
PORT_RE = re.compile(r"^[A-Za-z0-9/.:-]{1,24}$")
HOSTNAME_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]{0,62})$")
CREDENTIAL_CATEGORIES: tuple[tuple[str, str], ...] = (
("switch", "Switch"),
("linux", "Linux-Client"),
("windows", "Windows-Client"),
("router", "Router/Sonstiges"),
)
#: Diese Kategorien erscheinen auf der Wartungsseite. Der Weg dorthin ist
#: verschieden: Linux ueber SSH, Windows ueber RPC.
MAINTENANCE_CATEGORIES: tuple[str, ...] = ("linux", "windows")
#: Rueckwaertskompatibler Name -- die Vorgabe, wenn nur eine gemeint ist.
MAINTENANCE_CATEGORY = "linux"
#: Diese Kategorie wird **nicht** ueber SSH bedient. Windows-Geraete haben in
#: aller Regel keinen SSH-Dienst und damit auch keinen Host-Schluessel, den
#: jemand freigeben koennte. Alles, was mit Host-Schluesseln zu tun hat, laesst
#: sie deshalb aus -- sonst stuenden sie fuer immer auf "Freigabe offen".
NON_SSH_CATEGORY = "windows"
def is_ssh_managed(category: str | None) -> bool:
"""Wird ein Geraet dieser Kategorie ueber SSH bedient?"""
return str(category or "") != NON_SSH_CATEGORY
#: Was eine Kategorie tatsaechlich bewirkt. Steht in der Oberflaeche, damit
#: niemand eine Auswahl trifft, die stillschweigend folgenlos bleibt: genau das
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
CATEGORY_EFFECTS: dict[str, str] = {
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
"windows": (
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das "
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
),
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.",
}
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
("", "nicht angegeben"),
("kamera", "Kamera"),
("accesspoint", "Access Point"),
("telefon", "Telefon"),
("sensor", "Sensor"),
("rechner", "Rechner"),
("sonstiges", "Sonstiges"),
)
class ValidationError(Exception):
"""Eingabefehler mit Klartextmeldung."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def aad_for_credential(credential_id: int) -> str:
return f"credentials.secret:{credential_id}"
# --------------------------------------------------------------------------- #
# Validierung
# --------------------------------------------------------------------------- #
def normalize_mac(value: str) -> str:
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
if len(cleaned) != 12:
raise ValidationError(
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten "
"(z. B. aa:bb:cc:dd:ee:ff)."
)
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
text = (value or "").strip()
try:
ipaddress.ip_address(text)
except ValueError as exc:
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc
return text
def validate_port_name(value: str) -> str:
text = (value or "").strip()
if not text:
return ""
if not PORT_RE.match(text):
raise ValidationError(
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, "
"Ziffern, / . : -)."
)
return text
def validate_hostname(value: str) -> str:
text = (value or "").strip()
if not HOSTNAME_RE.match(text):
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.")
return text
def validate_ssh_port(value: Any) -> int:
try:
port = int(value or 22)
except (TypeError, ValueError) as exc:
raise ValidationError("Der SSH-Port muss eine Zahl sein.") from exc
if not 1 <= port <= 65535:
raise ValidationError("Der SSH-Port muss zwischen 1 und 65535 liegen.")
return port
# --------------------------------------------------------------------------- #
# Zugangsdaten
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class Credential:
id: int
name: str
username: str
category: str
description: str
has_secret: bool
in_use: int
def list_credentials(
conn: sqlite3.Connection, *, include_deleted: bool = False
) -> list[Credential]:
where = "" if include_deleted else "WHERE c.deleted_at IS NULL"
rows = Database.all(
conn,
f"""
SELECT c.*,
(SELECT COUNT(*) FROM switches s
WHERE s.credential_id = c.id AND s.deleted_at IS NULL) +
(SELECT COUNT(*) FROM devices d
WHERE d.credential_id = c.id AND d.deleted_at IS NULL) AS in_use
FROM credentials c {where}
ORDER BY c.name COLLATE NOCASE
""",
)
return [
Credential(
id=int(row["id"]),
name=str(row["name"]),
username=str(row["username"] or ""),
category=str(row["category"] or "switch"),
description=str(row["description"] or ""),
has_secret=bool(row["secret_encrypted"]),
in_use=int(row["in_use"] or 0),
)
for row in rows
]
def create_credential(
conn: sqlite3.Connection,
keystore: Keystore,
*,
name: str,
username: str,
secret: str,
category: str,
description: str = "",
actor: str = "",
) -> int:
clean_name = (name or "").strip()
if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.")
if category not in dict(CREDENTIAL_CATEGORIES):
raise ValidationError("Unbekannte Kategorie.")
existing = Database.one(
conn, "SELECT id, deleted_at FROM credentials WHERE name=? COLLATE NOCASE", (clean_name,)
)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
"wieder her oder loeschen Sie sie endgueltig."
)
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
cursor = conn.execute(
"INSERT INTO credentials (name, username, secret_encrypted, category, description, "
"created_at, updated_at, updated_by) VALUES (?,?,'',?,?,?,?,?)",
(clean_name, (username or "").strip(), category, (description or "").strip(), _now(), _now(), actor),
)
credential_id = int(cursor.lastrowid or 0)
if secret:
conn.execute(
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
)
return credential_id
def update_credential(
conn: sqlite3.Connection,
keystore: Keystore,
credential_id: int,
*,
name: str,
username: str,
secret: str | None,
category: str,
description: str = "",
actor: str = "",
) -> list[str]:
row = Database.one(
conn, "SELECT * FROM credentials WHERE id=? AND deleted_at IS NULL", (credential_id,)
)
if row is None:
raise ValidationError("Diese Zugangsdaten existieren nicht (mehr).")
clean_name = (name or "").strip()
if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.")
clash = Database.one(
conn,
"SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(clean_name, credential_id),
)
if clash is not None:
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
changes: list[str] = []
if clean_name != str(row["name"]):
changes.append(f"Name: {row['name']} -> {clean_name}")
if (username or "").strip() != str(row["username"] or ""):
changes.append("Benutzer geaendert")
if category != str(row["category"]):
changes.append(f"Kategorie: {row['category']} -> {category}")
if (description or "").strip() != str(row["description"] or ""):
changes.append("Beschreibung geaendert")
conn.execute(
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
"updated_by=? WHERE id=?",
(clean_name, (username or "").strip(), category, (description or "").strip(), _now(), actor, credential_id),
)
if secret:
conn.execute(
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
)
changes.append("Passwort geaendert")
return changes
def credential_secret(
conn: sqlite3.Connection, keystore: Keystore, credential_id: int
) -> tuple[str, str]:
"""``(benutzer, passwort)`` -- entschluesselt. Nur fuer den Verbindungsaufbau."""
row = Database.one(
conn,
"SELECT username, secret_encrypted FROM credentials WHERE id=? AND deleted_at IS NULL",
(credential_id,),
)
if row is None:
raise ValidationError("Die hinterlegten Zugangsdaten existieren nicht mehr.")
encrypted = str(row["secret_encrypted"] or "")
if not encrypted:
return str(row["username"] or ""), ""
try:
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
except DecryptionError as exc:
raise ValidationError(
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der "
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
) from exc
return str(row["username"] or ""), secret
def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
row = Database.one(
conn,
"""
SELECT
(SELECT COUNT(*) FROM switches WHERE credential_id=? AND deleted_at IS NULL) AS switches,
(SELECT COUNT(*) FROM devices WHERE credential_id=? AND deleted_at IS NULL) AS devices
""",
(credential_id, credential_id),
) or {}
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
raise ValidationError(
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
f"{row.get('devices')} Geraet(en) verwendet."
)
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
def reencrypt_secrets(conn: sqlite3.Connection, keystore: Keystore) -> int:
"""Verschluesselt alle Geheimnisse unter dem aktuellen Schluessel neu.
Wird von der Schluesselrotation auf der Diagnoseseite aufgerufen.
"""
migrated = 0
for row in Database.all(
conn, "SELECT id, secret_encrypted FROM credentials WHERE secret_encrypted <> ''"
):
token = str(row["secret_encrypted"])
if not keystore.needs_reencrypt(token):
continue
aad = aad_for_credential(int(row["id"]))
try:
plain = keystore.decrypt(token, aad=aad)
except DecryptionError:
continue
conn.execute(
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
(keystore.encrypt(plain, aad=aad), row["id"]),
)
migrated += 1
# Bind-Passwort des Verzeichnisdienstes mitnehmen.
from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE
row = Database.one(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
if row and str(row["password_encrypted"] or ""):
token = str(row["password_encrypted"])
if keystore.needs_reencrypt(token):
try:
plain = keystore.decrypt(token, aad=AAD_LDAP_BIND)
except DecryptionError:
plain = ""
if plain:
conn.execute(
"UPDATE service_accounts SET password_encrypted=? WHERE purpose=?",
(keystore.encrypt(plain, aad=AAD_LDAP_BIND), SERVICE_PURPOSE),
)
migrated += 1
return migrated
# --------------------------------------------------------------------------- #
# Switche
# --------------------------------------------------------------------------- #
def list_switches(conn: sqlite3.Connection, *, include_deleted: bool = False) -> list[dict[str, Any]]:
where = "" if include_deleted else "WHERE s.deleted_at IS NULL"
return Database.all(
conn,
f"""
SELECT s.*, c.name AS credential_name, c.username AS credential_username,
(SELECT COUNT(*) FROM devices d
WHERE d.switch_id = s.id AND d.deleted_at IS NULL) AS device_count,
(SELECT COUNT(*) FROM switch_host_keys k
WHERE k.host = s.ip AND k.port = s.ssh_port) AS host_key_known
FROM switches s
LEFT JOIN credentials c ON c.id = s.credential_id AND c.deleted_at IS NULL
{where}
ORDER BY s.hostname COLLATE NOCASE
""",
)
def get_switch(conn: sqlite3.Connection, switch_id: int) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT s.*, c.name AS credential_name FROM switches s "
"LEFT JOIN credentials c ON c.id = s.credential_id "
"WHERE s.id=? AND s.deleted_at IS NULL",
(switch_id,),
)
def create_switch(
conn: sqlite3.Connection,
*,
hostname: str,
ip: str,
ssh_port: Any,
credential_id: Any,
model: str = "",
location: str = "",
notes: str = "",
actor: str = "",
) -> int:
clean_host = validate_hostname(hostname)
clean_ip = validate_ip(ip)
port = validate_ssh_port(ssh_port)
existing = Database.one(
conn, "SELECT id, deleted_at FROM switches WHERE hostname=? COLLATE NOCASE", (clean_host,)
)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(
f"Ein Switch namens {clean_host!r} liegt im Papierkorb."
)
raise ValidationError(f"Ein Switch namens {clean_host!r} existiert bereits.")
cursor = conn.execute(
"INSERT INTO switches (hostname, ip, ssh_port, credential_id, model, location, notes, "
"created_at, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)",
(
clean_host,
clean_ip,
port,
int(credential_id) if credential_id else None,
(model or "").strip(),
(location or "").strip(),
(notes or "").strip(),
_now(),
_now(),
actor,
),
)
return int(cursor.lastrowid or 0)
def update_switch(
conn: sqlite3.Connection,
switch_id: int,
*,
hostname: str,
ip: str,
ssh_port: Any,
credential_id: Any,
model: str = "",
location: str = "",
notes: str = "",
actor: str = "",
) -> list[str]:
row = get_switch(conn, switch_id)
if row is None:
raise ValidationError("Dieser Switch existiert nicht (mehr).")
clean_host = validate_hostname(hostname)
clean_ip = validate_ip(ip)
port = validate_ssh_port(ssh_port)
clash = Database.one(
conn,
"SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(clean_host, switch_id),
)
if clash is not None:
raise ValidationError(f"Ein Switch namens {clean_host!r} existiert bereits.")
changes: list[str] = []
for label, before, after in (
("Hostname", str(row["hostname"]), clean_host),
("IP", str(row["ip"]), clean_ip),
("SSH-Port", str(row["ssh_port"]), str(port)),
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
("Modell", str(row["model"] or ""), (model or "").strip()),
("Standort", str(row["location"] or ""), (location or "").strip()),
):
if before != after:
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
conn.execute(
"UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=?, model=?, location=?, "
"notes=?, updated_at=?, updated_by=? WHERE id=?",
(
clean_host,
clean_ip,
port,
int(credential_id) if credential_id else None,
(model or "").strip(),
(location or "").strip(),
(notes or "").strip(),
_now(),
actor,
switch_id,
),
)
return changes
def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
count = Database.value(
conn,
"SELECT COUNT(*) FROM devices WHERE switch_id=? AND deleted_at IS NULL",
(switch_id,),
default=0,
)
if int(count or 0):
raise ValidationError(
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen."
)
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
# --------------------------------------------------------------------------- #
# Geraete
# --------------------------------------------------------------------------- #
def list_devices(
conn: sqlite3.Connection,
*,
include_deleted: bool = False,
only_active: bool = False,
) -> list[dict[str, Any]]:
clauses = [] if include_deleted else ["d.deleted_at IS NULL"]
if only_active:
clauses.append("d.is_active = 1")
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
return Database.all(
conn,
f"""
SELECT d.*, s.hostname AS switch_hostname, s.ip AS switch_ip,
s.ssh_port AS switch_ssh_port, s.credential_id AS switch_credential_id,
c.name AS credential_name, c.category AS credential_category,
-- Wie bei den Switchen: der Zustand der Host-Schluessel-Freigabe
-- gehoert in die Liste. Ob eine Wartung ueberhaupt laufen kann,
-- soll man sehen, ohne jede Detailseite zu oeffnen.
(SELECT COUNT(*) FROM switch_host_keys k
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
st.fail_count
FROM devices d
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
LEFT JOIN device_status st ON st.device_id = d.id
{where}
ORDER BY d.name COLLATE NOCASE
""",
)
def get_device(conn: sqlite3.Connection, device_id: int) -> dict[str, Any] | None:
rows = [
row
for row in list_devices(conn, include_deleted=True)
if int(row["id"]) == int(device_id)
]
return rows[0] if rows else None
def create_device(
conn: sqlite3.Connection,
*,
name: str,
mac: str,
ip: str,
switch_id: Any,
port: str,
credential_id: Any,
ssh_port: Any,
category: str = "",
location: str = "",
notes: str = "",
is_active: bool = True,
auto_restart: bool = True,
actor: str = "",
) -> int:
clean_name = (name or "").strip()
if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.")
clean_mac = normalize_mac(mac)
clean_ip = validate_ip(ip)
clean_port = validate_port_name(port)
existing = Database.one(
conn, "SELECT id, deleted_at FROM devices WHERE mac=? COLLATE NOCASE", (clean_mac,)
)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.")
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port:
raise ValidationError(
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein "
"PoE-Neustart moeglich."
)
cursor = conn.execute(
"INSERT INTO devices (mac, ip, name, switch_id, port, is_active, credential_id, ssh_port, "
"category, location, notes, auto_restart, created_at, updated_at, updated_by) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
clean_mac,
clean_ip,
clean_name,
int(switch_id) if switch_id else None,
clean_port,
int(bool(is_active)),
int(credential_id) if credential_id else None,
validate_ssh_port(ssh_port),
(category or "").strip(),
(location or "").strip(),
(notes or "").strip(),
int(bool(auto_restart)),
_now(),
_now(),
actor,
),
)
device_id = int(cursor.lastrowid or 0)
conn.execute(
"INSERT INTO device_status (device_id, state, last_change_at) VALUES (?,'unknown',?)",
(device_id, _now()),
)
return device_id
def update_device(
conn: sqlite3.Connection,
device_id: int,
*,
name: str,
mac: str,
ip: str,
switch_id: Any,
port: str,
credential_id: Any,
ssh_port: Any,
category: str = "",
location: str = "",
notes: str = "",
is_active: bool = True,
auto_restart: bool = True,
actor: str = "",
) -> list[str]:
row = Database.one(
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
)
if row is None:
raise ValidationError("Dieses Geraet existiert nicht (mehr).")
clean_name = (name or "").strip()
if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.")
clean_mac = normalize_mac(mac)
clean_ip = validate_ip(ip)
clean_port = validate_port_name(port)
clash = Database.one(
conn,
"SELECT id FROM devices WHERE mac=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(clean_mac, device_id),
)
if clash is not None:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port:
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.")
changes: list[str] = []
for label, before, after in (
("Name", str(row["name"]), clean_name),
("MAC", str(row["mac"]), clean_mac),
("IP", str(row["ip"]), clean_ip),
("Switch", str(row["switch_id"] or ""), str(switch_id or "")),
("Port", str(row["port"] or ""), clean_port),
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
):
if before != after:
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
conn.execute(
"UPDATE devices SET mac=?, ip=?, name=?, switch_id=?, port=?, is_active=?, "
"credential_id=?, ssh_port=?, category=?, location=?, notes=?, auto_restart=?, "
"updated_at=?, updated_by=? WHERE id=?",
(
clean_mac,
clean_ip,
clean_name,
int(switch_id) if switch_id else None,
clean_port,
int(bool(is_active)),
int(credential_id) if credential_id else None,
validate_ssh_port(ssh_port),
(category or "").strip(),
(location or "").strip(),
(notes or "").strip(),
int(bool(auto_restart)),
_now(),
actor,
device_id,
),
)
return changes
def set_device_active(conn: sqlite3.Connection, device_id: int, active: bool) -> None:
conn.execute(
"UPDATE devices SET is_active=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(int(bool(active)), _now(), device_id),
)
def delete_device(conn: sqlite3.Connection, device_id: int) -> None:
conn.execute("UPDATE devices SET deleted_at=? WHERE id=?", (_now(), device_id))
def restore_row(conn: sqlite3.Connection, table: str, row_id: int, *, unique_column: str) -> None:
"""Wiederherstellung mit Konfliktpruefung auf dem eindeutigen Feld."""
if table not in {"devices", "switches", "credentials"}:
raise ValidationError("Unbekannte Ressource.")
row = Database.one(conn, f"SELECT {unique_column} FROM {table} WHERE id=?", (row_id,))
if row is None:
raise ValidationError("Dieser Eintrag existiert nicht.")
value = row[unique_column]
clash = Database.one(
conn,
f"SELECT id FROM {table} WHERE {unique_column}=? COLLATE NOCASE AND id<>? "
"AND deleted_at IS NULL",
(value, row_id),
)
if clash is not None:
raise ValidationError(
f"Es existiert bereits ein aktiver Eintrag mit {unique_column} = {value!r}."
)
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (row_id,))
def purge_row(conn: sqlite3.Connection, table: str, row_id: int) -> None:
if table not in {"devices", "switches", "credentials"}:
raise ValidationError("Unbekannte Ressource.")
conn.execute(f"DELETE FROM {table} WHERE id=?", (row_id,))
def trash_entries(
conn: sqlite3.Connection, table: str, *, label_column: str, detail_columns: Iterable[str]
) -> list[dict[str, Any]]:
columns = ", ".join([label_column, *detail_columns])
rows = Database.all(
conn,
f"SELECT id, {columns}, deleted_at FROM {table} "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC",
)
return [
{
"id": str(row["id"]),
"label": str(row[label_column]),
"detail": " / ".join(str(row[column] or "-") for column in detail_columns),
"deleted_at": row["deleted_at"],
}
for row in rows
]
+661
View File
@@ -0,0 +1,661 @@
"""Kea-DHCP: Optionskatalog, Konfigurationserzeugung, Lease-Auswertung.
Backend ist ``kea-dhcp4-server`` (isc-dhcp-server ist seit 2022 abgekuendigt).
Die im Vorgaengerprojekt teuer erarbeiteten Kea-Eigenheiten sind hier von
Anfang an beruecksichtigt und im Code vermerkt:
* Der Schluessel der Lease-Datei heisst ``name``, nicht ``lease-file``.
* ``loggers`` gehoert **unter** ``Dhcp4``, nicht daneben -- sonst Syntaxfehler.
* Standardoptionen bekommen **kein** eigenes ``option-def`` (Kea kennt sie
nativ); nur eigene Optionen werden definiert.
* Die Lease-CSV wird mit ``\\n`` geschrieben; Pythons csv-Default ``\\r\\n``
laesst Kea die Datei mit "invalid column" ablehnen.
* Das Kea-Protokoll darf nicht unter dem Protokollverzeichnis der Anwendung
liegen: das AppArmor-Profil von Kea erlaubt ausschliesslich den fest
einprogrammierten Pfad ``/var/log/kea/kea-dhcp4.log``.
"""
from __future__ import annotations
import ipaddress
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Sequence
from tesm_core.db import Database
from tesm_core.sysops import SysOps
logger = logging.getLogger(__name__)
SERVICE_UNIT = "kea-dhcp4-server"
PACKAGE_NAME = "kea-dhcp4-server"
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
LOG_PATH = "/var/log/kea/kea-dhcp4.log"
DEFAULT_CONFIG_PATH = "/etc/kea/kea-dhcp4.conf"
#: ``(code, name, type, description)`` -- kuratierte Standardoptionen.
STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
(7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
(8, "cookie-servers", "ipv4-address", "Cookie-Server"),
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"),
(14, "merit-dump", "string", "Crash-Dump-Datei"),
(16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"),
(18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"),
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"),
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
(27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
(28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
(29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"),
(30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"),
(31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"),
(32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"),
(33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"),
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"),
(42, "ntp-servers", "ipv4-address", "NTP-Server"),
(44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"),
(45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"),
(46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"),
(47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
(48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
(49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
(60, "vendor-class-identifier", "string", "Vendor Class Identifier"),
(64, "nisplus-domain-name", "string", "NIS+-Domain"),
(65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
(66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
(67, "boot-file-name", "string", "Bootdatei-Name"),
(68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"),
(69, "smtp-server", "ipv4-address", "SMTP-Server"),
(70, "pop-server", "ipv4-address", "POP3-Server"),
(71, "nntp-server", "ipv4-address", "NNTP-Server"),
(72, "www-server", "ipv4-address", "WWW-Server"),
(73, "finger-server", "ipv4-address", "Finger-Server"),
(74, "irc-server", "ipv4-address", "IRC-Server"),
(75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
(76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
(118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"),
(119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
)
OPTION_TYPES = (
"ipv4-address",
"string",
"boolean",
"uint8",
"uint16",
"uint32",
"int8",
"int16",
"int32",
)
_HOSTNAME_CLEAN = re.compile(r"[^a-z0-9-]+")
# --------------------------------------------------------------------------- #
# Netzwerkerkennung
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class InterfaceNetwork:
interface: str
cidr: str
@property
def network(self) -> ipaddress.IPv4Network:
return ipaddress.ip_network(self.cidr, strict=False) # type: ignore[return-value]
@property
def address(self) -> str:
return self.cidr.split("/")[0]
def detect_networks(sysops: SysOps) -> list[InterfaceNetwork]:
"""Liest alle IPv4-Adressen inkl. Alias-Adressen aus ``ip -4 addr show``.
Subnetz und Maske werden nie von Hand eingegeben -- sie kommen immer vom
laufenden System. Das war schon im Vorgaenger die richtige Entscheidung.
"""
result = sysops.read_command(["ip", "-4", "-o", "addr", "show", "scope", "global"])
if not result.ok:
return []
networks: list[InterfaceNetwork] = []
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) < 4 or parts[2] != "inet":
continue
networks.append(InterfaceNetwork(interface=parts[1], cidr=parts[3]))
return networks
def default_gateway(sysops: SysOps) -> str:
result = sysops.read_command(["ip", "route", "show", "default"])
if not result.ok:
return ""
for line in result.stdout.splitlines():
parts = line.split()
if "via" in parts:
return parts[parts.index("via") + 1]
return ""
def _now() -> datetime:
return datetime.now(timezone.utc)
def matching_network(
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
) -> InterfaceNetwork | None:
"""Findet das Netz, in das ein Adressbereich tatsaechlich passt."""
try:
start = ipaddress.ip_address(range_start)
end = ipaddress.ip_address(range_end)
except ValueError:
return None
if int(end) < int(start):
return None
for candidate in networks:
if candidate.interface != interface:
continue
network = candidate.network
if start in network and end in network:
return candidate
return None
def validate_subnet(
networks: Iterable[InterfaceNetwork], interface: str, range_start: str, range_end: str
) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
if not interface:
return "Es ist kein Interface ausgewaehlt."
try:
start = ipaddress.ip_address(range_start)
end = ipaddress.ip_address(range_end)
except ValueError:
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein."
if int(end) < int(start):
return "Die Endadresse liegt vor der Startadresse."
if matching_network(networks, interface, range_start, range_end) is None:
return (
f"Der Bereich {range_start}-{range_end} passt in kein Netz von {interface}. "
"Subnetz und Maske werden vom laufenden System gelesen."
)
return ""
# --------------------------------------------------------------------------- #
# Reservierungen
# --------------------------------------------------------------------------- #
def normalize_mac(value: str) -> str:
"""Vereinheitlicht MAC-Adressen auf ``aa:bb:cc:dd:ee:ff``."""
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
if len(cleaned) != 12:
return ""
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
def hostname_from_name(name: str, taken: set[str]) -> str:
"""Leitet einen DNS-taugliche Hostnamen ab und loest Kollisionen mit -2/-3."""
base = _HOSTNAME_CLEAN.sub("-", (name or "").lower()).strip("-") or "client"
base = base[:60]
candidate = base
suffix = 2
while candidate in taken:
candidate = f"{base}-{suffix}"
suffix += 1
taken.add(candidate)
return candidate
def configured_networks(
conn: sqlite3.Connection, networks: Iterable[InterfaceNetwork]
) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
"""Die Netze, fuer die tatsaechlich ein Subnetz eingerichtet ist.
Nur diese landen in der Konfiguration. Eine Reservierung ausserhalb wird von
Kea nie ausgeliefert -- sie in der Oberflaeche trotzdem zu zeigen, hat
Reservierungen versprochen, die es nicht gab.
"""
treffer = []
for subnet in Database.all(
conn, "SELECT * FROM dhcp_subnets WHERE enabled=1 ORDER BY sort_order, id"
):
match = matching_network(
networks,
str(subnet["interface"]),
str(subnet["range_start"]),
str(subnet["range_end"]),
)
if match is not None:
treffer.append(match.network)
return treffer
def in_configured_network(
ip: str, networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network]
) -> bool:
try:
adresse = ipaddress.ip_address(ip)
except ValueError:
return False
return any(adresse in netz for netz in networks)
def collect_reservations(
conn: sqlite3.Connection,
*,
only_networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network] | None = None,
skipped: list[dict[str, str]] | None = None,
) -> list[dict[str, Any]]:
"""Automatische Reservierungen aus dem Geraetebestand plus manuelle Eintraege.
Bei einer MAC-Kollision gewinnt die automatische Reservierung -- ein
ueberwachtes Geraet ist die verlaesslichere Quelle.
``only_networks`` grenzt auf Adressen in eingerichteten Bereichen ein. Ohne
diese Eingrenzung entstanden Eintraege fuer Adressen, fuer die es kein
Subnetz gibt: in der Liste sichtbar, in der Kea-Konfiguration nie
vorhanden. Was dabei wegfaellt, wird in ``skipped`` gesammelt, damit die
Oberflaeche es benennen kann statt es zu verschweigen.
"""
grenzen = list(only_networks) if only_networks is not None else None
taken: set[str] = set()
reservations: list[dict[str, Any]] = []
seen_macs: set[str] = set()
for row in Database.all(
conn,
"SELECT id, mac, ip, name FROM devices "
"WHERE deleted_at IS NULL AND is_active=1 AND ip <> '' ORDER BY name COLLATE NOCASE",
):
mac = normalize_mac(str(row["mac"]))
if not mac or mac in seen_macs:
continue
try:
ipaddress.ip_address(str(row["ip"]))
except ValueError:
continue
if grenzen is not None and not in_configured_network(str(row["ip"]), grenzen):
if skipped is not None:
skipped.append({"name": str(row["name"]), "ip": str(row["ip"]), "source": "auto"})
continue
seen_macs.add(mac)
reservations.append(
{
"device_id": int(row["id"]),
"mac": mac,
"ip": str(row["ip"]),
"hostname": hostname_from_name(str(row["name"]), taken),
"source": "auto",
"name": str(row["name"]),
}
)
for row in Database.all(
conn, "SELECT id, mac, ip, hostname, comment FROM dhcp_reservations ORDER BY ip"
):
mac = normalize_mac(str(row["mac"]))
if not mac or mac in seen_macs:
continue
if grenzen is not None and not in_configured_network(str(row["ip"]), grenzen):
if skipped is not None:
skipped.append(
{
"name": str(row["hostname"] or row["comment"] or mac),
"ip": str(row["ip"]),
"source": "manuell",
}
)
continue
seen_macs.add(mac)
reservations.append(
{
"device_id": None,
"reservation_id": int(row["id"]),
"mac": mac,
"ip": str(row["ip"]),
"hostname": hostname_from_name(
str(row["hostname"] or row["comment"] or mac.replace(":", "")), taken
),
"source": "manuell",
"name": str(row["comment"] or ""),
}
)
return reservations
# --------------------------------------------------------------------------- #
# Konfiguration
# --------------------------------------------------------------------------- #
def render_config(
conn: sqlite3.Connection,
*,
networks: list[InterfaceNetwork],
settings: dict[str, Any],
) -> tuple[dict[str, Any], list[str]]:
"""Erzeugt die Kea-Konfiguration. Rueckgabe: ``(config, warnungen)``."""
warnings: list[str] = []
subnets = Database.all(
conn,
"SELECT * FROM dhcp_subnets WHERE enabled=1 ORDER BY sort_order, id",
)
reservations = collect_reservations(conn)
option_rows = Database.all(conn, "SELECT * FROM dhcp_options ORDER BY code")
options_by_id = {int(row["id"]): row for row in option_rows}
global_values: dict[int, str] = {}
device_values: dict[int, dict[int, str]] = {}
for row in Database.all(conn, "SELECT * FROM dhcp_option_values"):
option_id = int(row["option_id"])
value = str(row["value"] or "").strip()
if not value:
continue
if row["device_id"] is None:
global_values[option_id] = value
else:
device_values.setdefault(int(row["device_id"]), {})[option_id] = value
def option_data(values: dict[int, str]) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
for option_id, value in sorted(values.items()):
option = options_by_id.get(option_id)
if option is None:
continue
entries.append({"name": str(option["name"]), "data": value})
return entries
subnet_entries: list[dict[str, Any]] = []
interfaces: set[str] = set()
for index, subnet in enumerate(subnets, start=1):
interface = str(subnet["interface"])
match = matching_network(
networks, interface, str(subnet["range_start"]), str(subnet["range_end"])
)
if match is None:
# Nicht passende Subnetze werden uebersprungen, nicht die ganze
# Aktion abgebrochen -- eine geaenderte Host-IP soll nicht dazu
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
warnings.append(
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
"passt in kein aktuelles Netz und wurde uebersprungen."
)
continue
interfaces.add(interface)
network = match.network
subnet_options: list[dict[str, Any]] = []
if str(subnet["gateway"] or "").strip():
subnet_options.append({"name": "routers", "data": str(subnet["gateway"]).strip()})
if str(subnet["dns"] or "").strip():
subnet_options.append(
{"name": "domain-name-servers", "data": str(subnet["dns"]).strip()}
)
subnet_reservations = []
for reservation in reservations:
if ipaddress.ip_address(reservation["ip"]) not in network:
continue
entry: dict[str, Any] = {
"hw-address": reservation["mac"],
"ip-address": reservation["ip"],
"hostname": reservation["hostname"],
}
overrides = device_values.get(reservation.get("device_id") or -1, {})
if overrides:
entry["option-data"] = option_data(overrides)
subnet_reservations.append(entry)
subnet_entries.append(
{
"id": index,
"subnet": str(network),
"pools": [{"pool": f"{subnet['range_start']} - {subnet['range_end']}"}],
**({"option-data": subnet_options} if subnet_options else {}),
"reservations": subnet_reservations,
}
)
if not subnet_entries:
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.")
global_option_data = option_data(global_values)
domain = str(settings.get("dhcp_domain") or "").strip()
if domain:
global_option_data.insert(0, {"name": "domain-name", "data": domain})
# Eigene (nicht-Standard-) Optionen brauchen eine Definition, sonst
# verweigert Kea den Start: "definition for the option 'dhcp4.<name>' does
# not exist". Deklariert werden **alle** eigenen Optionen, nicht nur die
# mit globalem Wert -- ein Wert kann auch nur in einer einzelnen
# Reservierung stehen, und genau das hat den Dienst umgebracht: die
# Definition fehlte, weil hier nur die globalen Werte geprueft wurden.
# Eine ungenutzte Definition ist fuer Kea harmlos; eine fehlende nicht.
custom_defs = [
{
"name": str(row["name"]),
"code": int(row["code"]),
"type": str(row["type"]),
"space": "dhcp4",
}
for row in option_rows
if not int(row["is_standard"] or 0)
]
config = {
"Dhcp4": {
"interfaces-config": {"interfaces": sorted(interfaces)},
"lease-database": {
"type": "memfile",
"lfc-interval": 3600,
# Schluessel heisst "name" -- nicht "lease-file".
"name": LEASE_FILE,
},
"valid-lifetime": int(settings.get("dhcp_lease_default") or 600),
"max-valid-lifetime": int(settings.get("dhcp_lease_max") or 7200),
**({"option-def": custom_defs} if custom_defs else {}),
**({"option-data": global_option_data} if global_option_data else {}),
"subnet4": subnet_entries,
# loggers gehoert UNTER Dhcp4 -- daneben ist es ein Syntaxfehler.
"loggers": [
{
"name": "kea-dhcp4",
"output_options": [{"output": LOG_PATH, "pattern": "%D{%Y-%m-%d %H:%M:%S} %-5p %m\n"}],
"severity": "INFO",
"debuglevel": 0,
}
],
}
}
return config, warnings
def config_to_text(config: dict[str, Any]) -> str:
return json.dumps(config, indent=2, ensure_ascii=False) + "\n"
# --------------------------------------------------------------------------- #
# Leases
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class Lease:
address: str
mac: str
hostname: str
expires_at: int
state: str
@property
def is_active(self) -> bool:
return self.state == "0"
@property
def is_expired(self) -> bool:
return self.expires_at > 0 and self.expires_at <= int(_now().timestamp())
@property
def expires_text(self) -> str:
"""Ablauf als Datum und Uhrzeit in der Zeitzone des Servers.
Kea schreibt einen Unix-Zeitstempel in die Lease-Datei. Der stand hier
vorher unveraendert in der Tabelle -- eine zehnstellige Zahl, aus der
niemand ablesen kann, ob eine Lease in zehn Minuten oder erst morgen
ablaeuft.
"""
if self.expires_at <= 0:
return "unbegrenzt"
return datetime.fromtimestamp(self.expires_at).strftime("%d.%m.%Y %H:%M")
@property
def remaining_text(self) -> str:
"""Restlaufzeit in Worten -- die eigentlich interessierende Angabe."""
if self.expires_at <= 0:
return ""
sekunden = self.expires_at - int(_now().timestamp())
if sekunden <= 0:
return "abgelaufen"
if sekunden < 60:
return "unter einer Minute"
if sekunden < 3600:
return f"in {sekunden // 60} min"
if sekunden < 86400:
stunden, rest = divmod(sekunden, 3600)
return f"in {stunden} h {rest // 60} min" if rest >= 60 else f"in {stunden} h"
tage, rest = divmod(sekunden, 86400)
return f"in {tage} Tg {rest // 3600} h" if rest >= 3600 else f"in {tage} Tg"
#: Zustand einer Reservierung -- wie im Windows-DHCP, wo eine Reservierung
#: "aktiv" oder "inaktiv" ist, je nachdem ob der Client sie gerade benutzt.
#: Ohne diese Angabe sieht eine nie abgeholte Reservierung genauso aus wie eine
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
#: ueberhaupt nicht auf.
RESERVATION_STATES: dict[str, tuple[str, str]] = {
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."),
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."),
"andere_adresse": (
"warning",
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die "
"Reservierung erst nach Ablauf oder Neuanforderung.",
),
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."),
}
def annotate_reservations(
reservations: Sequence[dict[str, Any]], leases: Sequence[Lease]
) -> list[dict[str, Any]]:
"""Ergaenzt jede Reservierung um ihren Zustand aus den laufenden Leases.
Reine Funktion ueber zwei Listen -- damit pruefbar, ohne Kea zu starten.
"""
nach_mac: dict[str, Lease] = {}
for lease in leases:
mac = normalize_mac(lease.mac)
if not mac:
continue
vorhanden = nach_mac.get(mac)
# Bei mehreren Leases je MAC gewinnt die, die spaeter ablaeuft.
if vorhanden is None or lease.expires_at > vorhanden.expires_at:
nach_mac[mac] = lease
ergebnis: list[dict[str, Any]] = []
for reservation in reservations:
eintrag = dict(reservation)
lease = nach_mac.get(normalize_mac(str(reservation["mac"])))
if lease is None:
zustand = "unbenutzt"
elif lease.address != str(reservation["ip"]):
zustand = "andere_adresse"
elif lease.is_expired or not lease.is_active:
zustand = "abgelaufen"
else:
zustand = "aktiv"
eintrag["state"] = zustand
eintrag["state_hint"] = RESERVATION_STATES[zustand][1]
eintrag["state_style"] = RESERVATION_STATES[zustand][0]
eintrag["lease"] = lease
ergebnis.append(eintrag)
return ergebnis
def read_leases(sysops: SysOps, *, path: str = LEASE_FILE) -> list[Lease]:
"""Liest die memfile-Lease-Datei.
Die Datei haengt bei jeder Verlaengerung neue Zeilen an; es gewinnt die
letzte Zeile je Adresse. Gelesen wird ueber den privilegierten Helfer, weil
``/var/lib/kea`` dem Kea-Dienstbenutzer gehoert.
"""
result = sysops.read_privileged_file(path)
if not result.ok:
return []
lines = result.stdout.splitlines()
if not lines:
return []
header = [name.strip().strip('"') for name in lines[0].split(",")]
try:
index = {
"address": header.index("address"),
"hwaddr": header.index("hwaddr"),
"expire": header.index("expire"),
"state": header.index("state"),
"hostname": header.index("hostname"),
}
except ValueError:
logger.warning("Kea-Lease-Datei hat unerwartete Spalten: %s", header)
return []
latest: dict[str, Lease] = {}
for line in lines[1:]:
fields = [field.strip().strip('"') for field in line.split(",")]
if len(fields) < len(header):
continue
try:
lease = Lease(
address=fields[index["address"]],
mac=normalize_mac(fields[index["hwaddr"]]),
hostname=fields[index["hostname"]],
expires_at=int(fields[index["expire"]] or 0),
state=fields[index["state"]] or "0",
)
except (ValueError, IndexError):
continue
latest[lease.address] = lease
return sorted(latest.values(), key=lambda entry: tuple(int(p) for p in entry.address.split(".") if p.isdigit()))
def service_state(sysops: SysOps) -> dict[str, str]:
state = sysops.service_state(SERVICE_UNIT)
import shutil
state["binary"] = "vorhanden" if shutil.which("kea-dhcp4") else "fehlt"
return state
@@ -0,0 +1,417 @@
"""Uebersetzt einen Export des alten TESM in das Format dieser Anwendung.
Der Vorgaenger (``gitea:alientim/tesm``) schreibt seine Exportdatei so::
{"app": "tesm", "version": 2, "exported_at": "...",
"salt": "<b64url, 16 Byte>", "payload": "<Fernet-Token>"}
Der Schluessel entsteht aus Passphrase und Salt per PBKDF2-HMAC-SHA256 mit
390000 Runden, die Nutzlast ist ein Fernet-Token ueber JSON. Das neue Format
verwendet Argon2id und AES-GCM und bindet seinen Kopf als AAD ein. Die
Umschlaege sind also nicht ineinander ueberfuehrbar, wohl aber ihr Inhalt.
Absichtlich ein **Uebersetzer** und kein zweiter Importweg: die Daten werden
in das neue Format geschrieben und dann vom vorhandenen, geprueften Import
eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
die niemand pflegt.
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
ist.
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from typing import Any
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
#: nicht aendern -- sonst laesst sich keine alte Datei mehr oeffnen.
LEGACY_KDF_ITERATIONS = 390000
LEGACY_APP_TAG = "tesm"
#: Diese Abschnitte werden uebersetzt.
SUPPORTED = ("credentials", "switches", "devices", "dhcp")
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
KNOWN_UNSUPPORTED: dict[str, str] = {
"users": (
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein "
"Zweck. Konten neu anlegen."
),
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.",
"ldap": (
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
"Hand uebertragen."
),
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": (
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und "
"ueber die Seite 'Host-Schluessel' freigegeben."
),
}
#: Kategorien der Zugangsdaten -- in beiden Anwendungen dieselben vier Werte.
VALID_CATEGORIES = ("switch", "linux", "windows", "router")
FALLBACK_CATEGORY = "switch"
#: DHCP-Einstellungen, die in beiden Anwendungen gleich heissen.
DHCP_SETTING_KEYS = ("dhcp_domain", "dhcp_lease_default", "dhcp_lease_max", "dhcp_output_path")
class LegacyTransferError(Exception):
"""Fehler mit Klartextmeldung fuer Oberflaeche und Kommandozeile."""
@dataclass
class Report:
"""Was uebernommen wurde -- und was nicht, mit Grund."""
counts: dict[str, int] = field(default_factory=dict)
notes: list[str] = field(default_factory=list)
def took(self, category: str, amount: int = 1) -> None:
if amount:
self.counts[category] = self.counts.get(category, 0) + amount
def note(self, text: str) -> None:
if text not in self.notes:
self.notes.append(text)
def as_text(self) -> str:
zeilen = ["Uebersetzt:"]
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
" nichts"
]
if self.notes:
zeilen.append("")
zeilen.append("Zu beachten:")
zeilen += [f" - {note}" for note in self.notes]
return "\n".join(zeilen)
# --------------------------------------------------------------------------- #
# Alten Umschlag oeffnen
# --------------------------------------------------------------------------- #
def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
"""Entschluesselt eine Exportdatei des Vorgaengers."""
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
try:
envelope = json.loads(text)
except ValueError as exc:
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
if not isinstance(envelope, dict):
raise LegacyTransferError("Die Datei enthaelt kein Objekt.")
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
raise LegacyTransferError(
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
"nicht zu TESM."
)
for schluessel in ("salt", "payload"):
if not envelope.get(schluessel):
raise LegacyTransferError(f"Im Umschlag fehlt {schluessel!r}.")
try:
salt = base64.urlsafe_b64decode(str(envelope["salt"]))
except (ValueError, TypeError) as exc:
raise LegacyTransferError("Das Salt ist unlesbar.") from exc
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
try:
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
except (InvalidToken, ValueError) as exc:
raise LegacyTransferError(
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei."
) from exc
try:
payload = json.loads(klartext.decode("utf-8"))
except ValueError as exc:
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc
if not isinstance(payload, dict):
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.")
return payload
# --------------------------------------------------------------------------- #
# Uebersetzen
# --------------------------------------------------------------------------- #
def _liste(value: Any) -> list[dict[str, Any]]:
return [entry for entry in value if isinstance(entry, dict)] if isinstance(value, list) else []
def _category(value: Any, report: Report, subject: str) -> str:
kategorie = str(value or "").strip().lower()
if kategorie in VALID_CATEGORIES:
return kategorie
if kategorie:
report.note(
f"Unbekannte Kategorie {kategorie!r} bei {subject} auf {FALLBACK_CATEGORY!r} gesetzt."
)
return FALLBACK_CATEGORY
def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
ergebnis: list[dict[str, Any]] = []
ohne_passwort = 0
for entry in _liste(items):
name = str(entry.get("name") or "").strip()
if not name:
report.note("Zugangsdaten ohne Namen uebersprungen.")
continue
umgesetzt: dict[str, Any] = {
"name": name,
"username": str(entry.get("username") or ""),
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
# Der Vorgaenger kennt kein Beschreibungsfeld.
"description": "aus altem TESM uebernommen",
}
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
# verschluesselt es der Schluesselspeicher sofort wieder.
if entry.get("password"):
umgesetzt["secret"] = str(entry["password"])
else:
ohne_passwort += 1
ergebnis.append(umgesetzt)
if ohne_passwort:
report.note(
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- "
"sie muessen nachgetragen werden."
)
report.took("credentials", len(ergebnis))
return ergebnis
def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
ergebnis: list[dict[str, Any]] = []
for entry in _liste(items):
hostname = str(entry.get("hostname") or "").strip()
if not hostname:
report.note("Switch ohne Hostnamen uebersprungen.")
continue
ergebnis.append(
{
"hostname": hostname,
"ip": str(entry.get("ip") or ""),
"ssh_port": int(entry.get("ssh_port") or 22),
"credential_name": entry.get("credential_name"),
# Diese Felder gibt es im Vorgaenger nicht.
"model": "",
"location": "",
"notes": "",
}
)
report.took("switches", len(ergebnis))
return ergebnis
def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> list[dict[str, Any]]:
# Der Vorgaenger kennt keine Geraetekategorie. Sie steuert hier, was mit
# einem Geraet moeglich ist (Linux ueber SSH, Windows ueber RPC), und laesst
# sich aus den zugeordneten Zugangsdaten ableiten. Das ist besser als leer:
# ohne Kategorie erscheint das Geraet auf der Wartungsseite gar nicht.
kategorie_je_zugang = {str(c["name"]): str(c["category"]) for c in credentials}
ergebnis: list[dict[str, Any]] = []
abgeleitet = 0
for entry in _liste(items):
mac = str(entry.get("mac") or "").strip()
if not mac:
report.note("Client ohne MAC-Adresse uebersprungen.")
continue
zugang = entry.get("credential_name")
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
if kategorie:
abgeleitet += 1
ergebnis.append(
{
"mac": mac,
"ip": str(entry.get("ip") or ""),
"name": str(entry.get("name") or mac),
"port": str(entry.get("port") or ""),
"is_active": int(bool(entry.get("is_active", 1))),
"ssh_port": int(entry.get("ssh_port") or 22),
"switch_hostname": entry.get("switch_hostname"),
"credential_name": zugang,
"category": kategorie,
"location": "",
"notes": "",
# Im Vorgaenger war der automatische Neustart nicht abschaltbar.
"auto_restart": 1,
}
)
if abgeleitet:
report.note(
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen."
)
report.took("devices", len(ergebnis))
return ergebnis
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
if not isinstance(data, dict):
return {}, {}
subnets = [
{
"interface": str(entry.get("interface") or ""),
"range_start": str(entry.get("range_start") or ""),
"range_end": str(entry.get("range_end") or ""),
"gateway": str(entry.get("gateway") or ""),
"dns": str(entry.get("dns") or ""),
# Der Vorgaenger hat kein Kommentarfeld am Subnetz.
"comment": "",
"enabled": int(bool(entry.get("enabled", 1))),
"sort_order": int(entry.get("sort_order") or 0),
}
for entry in _liste(data.get("subnets"))
]
# Der Vorgaenger fuehrt am Eintrag ein Feld "name"; hier heisst es
# "hostname", weil genau das im Kea-Eintrag landet.
reservations = []
for entry in _liste(data.get("reservations")):
mac = str(entry.get("mac") or "").strip()
if not mac:
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.")
continue
reservations.append(
{
"mac": mac,
"ip": str(entry.get("ip") or ""),
"hostname": str(entry.get("name") or ""),
"comment": "aus altem TESM uebernommen",
}
)
# Der Vorgaenger schachtelt die Werte unter der Definition, hier steht je
# Wert eine Zeile mit den Feldern der Definition daneben.
options: list[dict[str, Any]] = []
ohne_wert = 0
for definition in _liste(data.get("options")):
gemeinsam = {
"code": int(definition.get("code") or 0),
"name": str(definition.get("name") or ""),
"type": str(definition.get("type") or "string"),
"description": str(definition.get("description") or ""),
"is_standard": int(bool(definition.get("is_standard"))),
}
if not gemeinsam["code"] or not gemeinsam["name"]:
report.note("DHCP-Option ohne Code oder Namen uebersprungen.")
continue
werte = _liste(definition.get("values"))
if not werte:
ohne_wert += 1
continue
for wert in werte:
options.append(
{
**gemeinsam,
"value": str(wert.get("value") or ""),
# Leerer Wert = globale Geltung; sonst die MAC des Geraets.
"device_mac": str(wert.get("device_mac") or "") or None,
}
)
if ohne_wert:
report.note(
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert "
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
)
dhcp: dict[str, Any] = {}
if subnets:
dhcp["subnets"] = subnets
if reservations:
dhcp["reservations"] = reservations
if options:
dhcp["options"] = options
report.took("dhcp-Subnetze", len(subnets))
report.took("dhcp-Reservierungen", len(reservations))
report.took("dhcp-Optionswerte", len(options))
alt_settings = data.get("settings") if isinstance(data.get("settings"), dict) else {}
settings = {
key: str(alt_settings[key])
for key in DHCP_SETTING_KEYS
if alt_settings.get(key) not in (None, "")
}
report.took("Einstellungen", len(settings))
return dhcp, settings
def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
"""Baut aus der alten Nutzlast eine Nutzlast im neuen Format."""
report = Report()
payload: dict[str, Any] = {}
credentials = _credentials(legacy.get("credentials"), report)
if credentials:
payload["credentials"] = credentials
switches = _switches(legacy.get("switches"), report)
if switches:
payload["switches"] = switches
devices = _devices(legacy.get("devices"), credentials, report)
if devices:
payload["devices"] = devices
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
if dhcp:
payload["dhcp"] = dhcp
if settings:
# Nur die vier DHCP-Schluessel, nichts sonst: alles andere heisst hier
# anders oder bedeutet etwas anderes.
payload["settings"] = settings
# Zugeordnete Zugangsdaten, die nicht mitexportiert wurden, laufen beim
# Import ins Leere -- er loest sie ueber den Namen auf und setzt sonst NULL.
# Lieber vorher sagen als hinterher suchen.
bekannt = {str(c["name"]) for c in credentials}
fehlend = sorted(
{
str(entry["credential_name"])
for entry in (*switches, *devices)
if entry.get("credential_name") and str(entry["credential_name"]) not in bekannt
}
)
if fehlend:
report.note(
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
"Kategorie 'Zugangsdaten' mitwaehlen."
)
for schluessel, grund in KNOWN_UNSUPPORTED.items():
if legacy.get(schluessel):
report.note(grund)
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
if unbekannt:
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.")
if not payload:
raise LegacyTransferError(
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
"Beim Export im alten TESM diese Kategorien mitwaehlen."
)
return payload, report
+713
View File
@@ -0,0 +1,713 @@
"""Wartung: Paketaktualisierung und Neustart, als nachvollziehbare Auftraege.
Klar getrennt vom PoE-Neustart: das Dashboard schaltet immer den Switch-Port,
die Wartung spricht immer direkt mit dem Geraet.
Der Weg dorthin haengt am Betriebssystem:
* **Linux** -- SSH. Host-Schluessel muss freigegeben sein, ``sudo`` bekommt bei
Bedarf das hinterlegte Passwort ueber stdin.
* **Windows** -- RPC ueber :mod:`tesm.services.windowsops`. Kein SSH, kein
Host-Schluessel; nur Neustart, keine Paketaktualisierung.
Aenderung gegenueber dem Vorgaenger: Auftragszustand lag dort ausschliesslich im
Prozessspeicher. Ein Dienst-Neustart verlor die Anzeige komplett -- inklusive der
Information, ob ueberhaupt etwas gelaufen war. Hier liegt jeder Auftrag in der
Tabelle ``jobs``: ueberlebt Neustarts, ist im Verlauf einsehbar und kann nach
einem Abbruch als "unklar beendet" erkannt werden.
Host-Schluessel werden hier **immer** streng geprueft. Bei einem Hintergrundlauf
sieht niemand zu; ein automatisches Akzeptieren waere ein Einfallstor.
"""
from __future__ import annotations
import logging
import re
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from tesm_core import audit
from tesm_core.db import Database
from . import windowsops
from .monitor import METHOD_SSH, record_restart, tcp_reachable
from .restart import METHOD_RPC
from .ssh import SshError, open_transport
logger = logging.getLogger(__name__)
JOB_KIND_UPDATE = "maintenance.update"
JOB_KIND_REBOOT = "maintenance.reboot"
MAX_OUTPUT_LINES = 400
CONNECT_TIMEOUT = 25
REBOOT_GRACE_SECONDS = 15
REBOOT_POLL_SECONDS = 5
#: Die Wartungsschritte. ``{sudo}`` wird zur Laufzeit eingesetzt -- je nachdem,
#: ob das Zielgeraet fuer ``sudo`` ein Passwort verlangt.
UPDATE_COMMANDS: tuple[tuple[str, str], ...] = (
(
"Paketlisten aktualisieren",
"env DEBIAN_FRONTEND=noninteractive {sudo} apt-get update",
),
(
"Pakete aktualisieren",
"env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y "
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
),
("Aufraeumen", "env DEBIAN_FRONTEND=noninteractive {sudo} apt-get -y autoremove"),
)
REBOOT_COMMAND = "{sudo} reboot"
#: Windows wird **nicht** ueber SSH bedient -- siehe :mod:`tesm.services.windowsops`.
#: Diese Geraete haben in aller Regel keinen SSH-Dienst und damit auch keinen
#: Host-Schluessel, den jemand freigeben koennte.
WINDOWS_CATEGORY = "windows"
#: Woran ein Windows-Geraet als "wieder da" erkannt wird: Port 445 (SMB/RPC) --
#: derselbe Dienst, ueber den der Neustart ausgeloest wurde.
WINDOWS_PROBE_PORT = 445
#: ``-S`` liest das Passwort von stdin, ``-p ''`` unterdrueckt die Aufforderung
#: (sie landete sonst in der Ausgabe), ``-k`` verwirft einen zwischen-
#: gespeicherten sudo-Zeitstempel, damit der Ablauf reproduzierbar bleibt.
SUDO_WITH_PASSWORD = "sudo -S -k -p ''"
SUDO_PASSWORDLESS = "sudo -n"
COMPLETION_MARKER = "### TESM-WARTUNG-ENDE ###"
#: Fortschrittsgeschwaetz von dpkg. Ohne Terminal kann dpkg die Zeile nicht
#: ueberschreiben und schreibt stattdessen jede Stufe einzeln -- zwanzig Zeilen
#: "(Reading database ... 45%)" je Paket. Das blaeht das Protokoll so weit auf,
#: dass die eigentlichen Meldungen darin untergehen und die Zeilenbegrenzung
#: alles Interessante hinausschiebt.
NOISE_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"^\(?Reading database \.\.\.\s*\d+%\)?\s*$"),
re.compile(r"^\(?Reading database \.\.\.\s*$"),
re.compile(r"^Progress: \[\s*\d+%\]"),
re.compile(r"^\s*$"),
)
def is_noise(line: str) -> bool:
"""Reine Fortschrittsanzeige ohne Informationsgehalt?
Die Abschlusszeile mit der Dateizahl bleibt erhalten -- sie sagt etwas aus.
"""
return any(pattern.match(line) for pattern in NOISE_PATTERNS)
#: Fehlerbilder, die auf dem Zielgeraet liegen und dort behoben werden muessen.
#: Die rohe Meldung sagt zwar die Wahrheit, aber nicht, was zu tun ist.
HINTS: tuple[tuple[str, str], ...] = (
(
"incorrect password attempt",
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die "
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein "
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
),
(
"is not in the sudoers file",
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem "
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
"NOPASSWD: entfaellt die Passwortabfrage ganz.",
),
(
"a password is required",
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines "
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht "
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.",
),
(
"no tty present",
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des "
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.",
),
(
"command not found",
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein "
"Debian- oder Ubuntu-System mit apt-get.",
),
(
"Access is denied",
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss "
"Mitglied der lokalen Administratoren sein.",
),
(
"is not recognized as an internal or external command",
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die "
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?",
),
(
"Could not get lock",
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa "
"unattended-upgrades). Spaeter erneut versuchen.",
),
)
def hint_for(output: str) -> str:
"""Sucht zu einer Fehlerausgabe den passenden Hinweis -- oder gibt "" zurueck."""
for needle, hint in HINTS:
if needle in output:
return hint
return ""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@dataclass(slots=True)
class JobHandle:
id: str
kind: str
subject: str
def create_job(
conn: sqlite3.Connection, *, kind: str, subject: str, actor: str, message: str = ""
) -> JobHandle:
job_id = secrets.token_urlsafe(12)
conn.execute(
"INSERT INTO jobs (id, kind, subject, state, progress, message, output, started_by, "
"created_at, updated_at) VALUES (?,?,?,'pending',0,?,'',?,?,?)",
(job_id, kind, subject, message, actor, _now(), _now()),
)
return JobHandle(id=job_id, kind=kind, subject=subject)
def update_job(
database: Any,
job_id: str,
*,
state: str | None = None,
progress: int | None = None,
message: str | None = None,
append_output: str = "",
finished: bool = False,
) -> None:
with database.session() as conn, database.transaction(conn):
fields: list[str] = ["updated_at=?"]
params: list[Any] = [_now()]
if state is not None:
fields.append("state=?")
params.append(state)
if progress is not None:
fields.append("progress=?")
params.append(max(0, min(100, progress)))
if message is not None:
fields.append("message=?")
params.append(message[:500])
if finished:
fields.append("finished_at=?")
params.append(_now())
if append_output:
current = str(
Database.value(conn, "SELECT output FROM jobs WHERE id=?", (job_id,), default="")
or ""
)
combined = current + append_output
lines = combined.splitlines()
trimmed = "\n".join(lines[-MAX_OUTPUT_LINES:])
# splitlines() wirft ein abschliessendes Zeilenende weg. Ohne diese
# Zeile klebte jede neue Ausgabe an der vorherigen, und das ganze
# Protokoll stand am Ende auf einer einzigen Zeile.
if combined.endswith("\n"):
trimmed += "\n"
fields.append("output=?")
params.append(trimmed)
params.append(job_id)
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id=?", tuple(params))
def list_jobs(
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
) -> list[dict[str, Any]]:
placeholders = ",".join("?" * len(kinds))
return Database.all(
conn,
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
(*kinds, limit),
)
def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
"""Auftraege, die beim Dienststart noch als "laufend" gelten, ehrlich kennzeichnen.
Ein abgebrochener Webprozess weiss nicht, was auf dem Zielgeraet passiert
ist -- also wird der Auftrag als "unklar" markiert statt als erfolgreich
oder fehlgeschlagen.
"""
cursor = conn.execute(
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
"WHERE state IN ('pending','running')",
(
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet "
"passiert ist, laesst sich von hier nicht mehr feststellen.",
_now(),
_now(),
),
)
return cursor.rowcount or 0
# --------------------------------------------------------------------------- #
# Ausfuehrung
# --------------------------------------------------------------------------- #
def _run_remote_command(
transport: Any,
command: str,
*,
timeout: int,
on_line: Callable[[str], None],
stdin_text: str = "",
) -> int:
"""Fuehrt ein Kommando aus und liefert dessen Rueckgabewert.
``stdin_text`` geht ueber den Kanal an das Kommando -- so und nur so bekommt
``sudo -S`` sein Passwort. Auf der Kommandozeile stuende es auf dem
Zielgeraet in ``ps``, in der Umgebung in ``/proc/<pid>/environ``.
"""
channel = transport.open_session()
channel.settimeout(timeout)
channel.set_combine_stderr(True)
channel.exec_command(command)
if stdin_text:
try:
channel.sendall(stdin_text.encode("utf-8"))
channel.shutdown_write()
except Exception as exc: # noqa: BLE001
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]")
deadline = time.monotonic() + timeout
pending = b""
while True:
if channel.recv_ready():
chunk = channel.recv(32768)
if not chunk:
break
pending += chunk
while b"\n" in pending:
line, pending = pending.split(b"\n", 1)
on_line(line.decode("utf-8", errors="replace"))
elif channel.exit_status_ready():
break
elif time.monotonic() > deadline:
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]")
channel.close()
return 124
else:
time.sleep(0.1)
if pending:
on_line(pending.decode("utf-8", errors="replace"))
status = channel.recv_exit_status()
channel.close()
return int(status)
def _sudo_mode(
transport: Any, password: str, on_line: Callable[[str], None]
) -> tuple[str, str]:
"""Bestimmt, wie ``sudo`` auf diesem Geraet aufzurufen ist.
Rueckgabe: ``(praefix, stdin)`` -- entweder ``sudo -n`` ohne Eingabe oder
``sudo -S`` mit dem Passwort auf stdin.
Die Reihenfolge ist Absicht: geht es ohne Passwort (sudoers-Regel mit
NOPASSWD), verlaesst das Passwort den Server gar nicht erst. Erst wenn
``sudo`` eines verlangt, wird das in den Zugangsdaten hinterlegte
weitergereicht -- ueber stdin der bereits verschluesselten Verbindung.
"""
swallowed: list[str] = []
try:
status = _run_remote_command(
transport, f"{SUDO_PASSWORDLESS} true", timeout=20, on_line=swallowed.append
)
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
status = 1
if status == 0:
on_line("sudo ist ohne Passwort moeglich.")
return SUDO_PASSWORDLESS, ""
if not password:
on_line(
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
)
return SUDO_PASSWORDLESS, ""
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.")
return SUDO_WITH_PASSWORD, password + "\n"
def run_update(
database: Any,
*,
job_id: str,
device: dict[str, Any],
username: str,
password: str,
timeout: int,
) -> None:
"""Fuehrt die Paketaktualisierung aus. Laeuft in einem eigenen Thread."""
name = str(device.get("name") or device.get("ip"))
def emit(line: str) -> None:
stamp = datetime.now().strftime("%H:%M:%S")
update_job(database, job_id, append_output=f"{stamp} {line}\n")
update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut")
try:
with database.session() as conn:
transport = open_transport(
conn,
host=str(device["ip"]),
port=int(device.get("ssh_port") or 22),
username=username,
password=password,
)
except SshError as exc:
emit(f"FEHLER: {exc}")
update_job(database, job_id, state="failed", message=str(exc), finished=True)
return
failed = False
problem = ""
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
try:
total = len(UPDATE_COMMANDS)
for index, (label, template) in enumerate(UPDATE_COMMANDS, start=1):
update_job(
database,
job_id,
progress=int(5 + (index - 1) / total * 90),
message=f"Schritt {index}/{total}: {label}",
)
emit(f"--- {label} ---")
collected: list[str] = []
def capture(line: str) -> None:
# Fuer die Fehlersuche wird alles gesammelt, ins Protokoll
# kommt nur, was etwas aussagt.
collected.append(line)
if not is_noise(line):
emit(line)
command = template.format(sudo=sudo)
status = _run_remote_command(
transport, command, timeout=timeout, on_line=capture, stdin_text=sudo_stdin
)
if status != 0:
failed = True
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.")
problem = hint_for("\n".join(collected))
if problem:
emit("")
emit(f"Hinweis: {problem}")
break
finally:
try:
transport.close()
except Exception: # noqa: BLE001
pass
emit(COMPLETION_MARKER)
update_job(
database,
job_id,
state="failed" if failed else "succeeded",
progress=100,
message=(
(problem or "Ein Schritt ist fehlgeschlagen.")
if failed
else "Aktualisierung abgeschlossen."
)[:500],
finished=True,
)
with database.session() as conn, database.transaction(conn):
audit.log(
conn,
actor="system",
action="maintenance.update_finished",
target=name,
detail="fehlgeschlagen" if failed else "erfolgreich",
severity="warning" if failed else "notice",
)
def _reboot_windows(
database: Any,
*,
job_id: str,
device: dict[str, Any],
username: str,
password: str,
actor: str,
emit: Callable[[str], None],
elapsed: Callable[[], int],
) -> bool:
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
Getrennt gehalten, weil hier fast nichts gemeinsam ist: keine
SSH-Verbindung, kein Host-Schluessel, kein sudo. Nur das Warten auf die
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
"""
host = str(device["ip"])
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
result = windowsops.reboot(host, username, password)
for line in result.output.splitlines():
if not is_noise(line):
emit(line)
if result.ok:
return True
detail = result.hint or result.output or "Der Neustart wurde abgewiesen."
if result.hint:
emit("")
emit(f"Hinweis: {result.hint}")
update_job(database, job_id, state="failed", progress=100, message=detail[:500], finished=True)
_log_restart(
database,
device,
result="error",
actor=actor,
duration_ms=elapsed(),
detail=detail,
method=METHOD_RPC,
)
return False
def run_reboot(
database: Any,
*,
job_id: str,
device: dict[str, Any],
username: str,
password: str,
wait_seconds: int,
actor: str = "system",
) -> None:
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
Der Verbindungsabbruch direkt nach dem Kommando ist der **erwartete** Fall
und gilt als Erfolg. Nur ein Fehler *vor* dem Kommando (Anmeldung,
Erreichbarkeit) ist ein echter Fehlschlag.
"""
name = str(device.get("name") or device.get("ip"))
started = time.monotonic()
def emit(line: str) -> None:
stamp = datetime.now().strftime("%H:%M:%S")
update_job(database, job_id, append_output=f"{stamp} {line}\n")
def elapsed() -> int:
return int((time.monotonic() - started) * 1000)
host = str(device["ip"])
is_windows = str(device.get("category") or "") == WINDOWS_CATEGORY
# Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber
# den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows.
probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22)
update_job(database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut")
collected: list[str] = []
def capture(line: str) -> None:
collected.append(line)
if not is_noise(line):
emit(line)
if is_windows:
# Windows wird nicht ueber SSH bedient -- kein Transport, kein
# Host-Schluessel, kein sudo.
if not _reboot_windows(
database,
job_id=job_id,
device=device,
username=username,
password=password,
actor=actor,
emit=emit,
elapsed=elapsed,
):
return
else:
try:
with database.session() as conn:
transport = open_transport(
conn,
host=host,
port=int(device.get("ssh_port") or 22),
username=username,
password=password,
)
except SshError as exc:
emit(f"FEHLER: {exc}")
update_job(database, job_id, state="failed", message=str(exc), finished=True)
_log_restart(
database,
device,
result="error",
actor=actor,
duration_ms=elapsed(),
detail=str(exc),
)
return
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
command = REBOOT_COMMAND.format(sudo=sudo)
try:
emit(f"Neustart wird ausgeloest ({command})")
try:
_run_remote_command(
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
)
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.")
finally:
try:
transport.close()
except Exception: # noqa: BLE001
pass
# Scheitert schon das Kommando, geht das Geraet gar nicht erst herunter.
# Dann waere die Wartezeit reine Zeitverschwendung -- und die Meldung
# "nicht zurueckgekehrt" waere irrefuehrend: es war nie weg.
problem = hint_for("\n".join(collected))
if problem:
emit("")
emit(f"Hinweis: {problem}")
update_job(
database,
job_id,
state="failed",
progress=100,
message=problem[:500],
finished=True,
)
_log_restart(
database,
device,
result="error",
actor=actor,
duration_ms=elapsed(),
detail=problem,
)
return
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets")
time.sleep(REBOOT_GRACE_SECONDS)
deadline = time.monotonic() + wait_seconds
ssh_port = probe_port
emit(f"Warte darauf, dass {host}:{ssh_port} wieder antwortet.")
while time.monotonic() < deadline:
# TCP statt ICMP: der Webprozess laeuft ohne CAP_NET_RAW, dort schlaegt
# jeder Ping fehl -- der Auftrag lief deshalb immer in die
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
if tcp_reachable(host, ssh_port):
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).")
update_job(
database,
job_id,
state="succeeded",
progress=100,
message="Neustart abgeschlossen.",
finished=True,
)
with database.session() as conn, database.transaction(conn):
audit.log(
conn,
actor="system",
action="maintenance.reboot_finished",
target=name,
detail="erfolgreich",
severity="notice",
)
_log_restart(
database,
device,
result="ok",
actor=actor,
duration_ms=elapsed(),
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
)
return
time.sleep(REBOOT_POLL_SECONDS)
emit(f"{host}:{ssh_port} hat innerhalb der Wartezeit nicht wieder geantwortet.")
update_job(
database,
job_id,
state="failed",
progress=100,
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).",
finished=True,
)
_log_restart(
database,
device,
result="error",
actor=actor,
duration_ms=elapsed(),
detail=(
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
f"{wait_seconds}s nicht wieder geantwortet."
),
)
def _log_restart(
database: Any,
device: dict[str, Any],
*,
result: str,
actor: str,
duration_ms: int,
detail: str,
method: str = METHOD_SSH,
) -> None:
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
Der Wartungsauftrag steht ohnehin in ``jobs`` -- aber wer wissen will,
warum ein *Geraet* weg war, sieht in dessen Neustart-Historie nach, nicht
in einer Auftragsliste. Vorher tauchten dort nur PoE-Neustarts auf.
"""
device_id = device.get("id")
if not device_id:
return
try:
with database.session() as conn, database.transaction(conn):
record_restart(
conn,
device_id=int(device_id),
action="reboot",
result=result,
trigger="maintenance",
actor=actor,
duration_ms=duration_ms,
detail=detail,
method=method,
)
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id)
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
thread = threading.Thread(target=target, kwargs=kwargs, daemon=True)
thread.start()
+595
View File
@@ -0,0 +1,595 @@
"""Erreichbarkeitspruefung und automatischer PoE-Neustart.
Ersetzt drei Bausteine des Vorgaengers durch einen: ``poe.sh`` (Bash/expect),
``poe_wrapper.py`` und ``generate_ips.py``. Vorteile, die daraus folgen:
* **Ein Prozess, eine Sprache.** Kein Datenaustausch mehr ueber eine
pipe-getrennte Textzeile pro Geraet, kein zweiter Interpreter, keine
Passwoerter in Kommandozeilen.
* **Echte Nebenlaeufigkeit mit Grenze.** Der Vorgaenger startete pro Geraet eine
Hintergrund-Subshell -- bei vielen Geraeten ein Fork-Sturm. Hier laeuft ein
Thread-Pool mit fester Obergrenze.
* **Jeder Vorgang ist nachvollziehbar.** Erreichbarkeit landet in
``device_status``, jeder PoE-Vorgang mit Ergebnis und Transkript in
``restart_events``.
* **Zustandswechsel statt Dauerrauschen.** Ins laufende Protokoll wird
geschrieben, wenn sich etwas *aendert* -- nicht bei jedem Durchlauf jede Zeile.
Beibehalten wird die Lizenzregel des Vorgaengers: der automatische Neustart ist
lizenzpflichtig, der manuelle Knopf im Dashboard nicht. Und: das Recht
``devices.execute`` gilt nur fuer den manuellen Knopf -- der automatische Lauf
gehoert dem System.
"""
from __future__ import annotations
import logging
import platform
import re
import shutil
import sqlite3
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from tesm_core import audit
from tesm_core.db import Database
from . import inventory
from .ssh import SshError
from .switchcli import SwitchCli
logger = logging.getLogger(__name__)
DEFAULT_INTERVAL_SECONDS = 300
PING_TIMEOUT_SECONDS = 2
MAX_PARALLEL_CHECKS = 24
#: Ab wie vielen aufeinanderfolgenden Fehlschlaegen ein Neustart ausgeloest wird.
DEFAULT_FAILURES_BEFORE_RESTART = 2
#: Mindestabstand zwischen zwei automatischen Neustarts desselben Geraets.
RESTART_COOLDOWN_SECONDS = 900
_LATENCY_RE = re.compile(r"time[=<]\s*([\d.]+)\s*ms", re.IGNORECASE)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
@dataclass(frozen=True, slots=True)
class PingResult:
ok: bool
latency_ms: float | None = None
detail: str = ""
def tcp_reachable(host: str, port: int, *, timeout: float = 3.0) -> bool:
"""Nimmt der Host auf diesem Port eine Verbindung an?
Braucht -- anders als ICMP -- keinerlei Sonderrechte. Der Webprozess laeuft
mit leerem ``CapabilityBoundingSet``; damit greift die Datei-Capability von
``/usr/bin/ping`` nicht mehr (``NoNewPrivileges`` schaltet sie ab) und jeder
Ping schlaegt dort fehl. Ein Wartungsauftrag, der auf die Rueckkehr eines
Geraets wartet, wuerde dadurch **immer** in die Zeitueberschreitung laufen,
obwohl das Geraet laengst wieder da ist -- genau so beobachtet.
Fuer die Wartung ist der TCP-Test ohnehin der treffendere: sie will nicht
wissen, ob der Kernel wieder pingbar ist, sondern ob SSH wieder antwortet.
"""
import socket
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return True
except OSError:
return False
def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
"""Ein einzelner ICMP-Versuch, wie im Vorgaenger (kein Retry).
Ein Retry hier wuerde nur den Ausfall verschleiern; die Entscheidung
"Neustart oder nicht" faellt ueber mehrere *Durchlaeufe*, nicht ueber
mehrere Pakete.
"""
binary = shutil.which("ping")
if not binary:
return PingResult(False, detail="ping ist nicht installiert")
if platform.system() == "Windows":
command = [binary, "-n", "1", "-w", str(timeout * 1000), host]
else:
command = [binary, "-c", "1", "-W", str(timeout), host]
try:
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
command, capture_output=True, text=True, timeout=timeout + 3, check=False
)
except subprocess.TimeoutExpired:
return PingResult(False, detail="Zeitlimit ueberschritten")
except OSError as exc:
return PingResult(False, detail=str(exc))
if completed.returncode != 0:
return PingResult(False, detail="keine Antwort")
match = _LATENCY_RE.search(completed.stdout)
latency = float(match.group(1)) if match else None
return PingResult(True, latency_ms=latency)
# --------------------------------------------------------------------------- #
# Laufendes Protokoll
# --------------------------------------------------------------------------- #
class LiveLog:
"""Schreibt das laufende Protokoll.
Eine Datei, fortlaufend beschrieben; Rotation macht ``logrotate`` mit
``copytruncate`` (keiner der schreibenden Prozesse kann ein Reopen-Signal
verarbeiten). Zeilen sind bewusst maschinenlesbar aufgebaut:
``ZEIT EBENE BEREICH MELDUNG``.
"""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
def write(self, level: str, scope: str, message: str) -> None:
line = f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {level:<7} {scope:<10} {message}\n"
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream:
stream.write(line)
except OSError as exc: # pragma: no cover - vollgelaufene Platte o. Ae.
logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc)
def info(self, scope: str, message: str) -> None:
self.write("INFO", scope, message)
def warning(self, scope: str, message: str) -> None:
self.write("WARNUNG", scope, message)
def error(self, scope: str, message: str) -> None:
self.write("FEHLER", scope, message)
def tail(self, lines: int = 400, *, needle: str = "") -> list[str]:
"""Liest die letzten Zeilen -- ohne die ganze Datei in den Speicher zu holen."""
if not self.path.is_file():
return []
try:
size = self.path.stat().st_size
block = min(size, max(8192, lines * 200))
with self.path.open("rb") as stream:
stream.seek(max(0, size - block))
content = stream.read().decode("utf-8", errors="replace")
except OSError:
return []
result = content.splitlines()
if needle:
lowered = needle.lower()
result = [line for line in result if lowered in line.lower()]
return result[-lines:]
# --------------------------------------------------------------------------- #
# Geraetepruefung
# --------------------------------------------------------------------------- #
def load_monitored_devices(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Alle aktiven Geraete mit ihren Switch- und Zugangsdaten."""
return Database.all(
conn,
"""
SELECT d.id, d.mac, d.ip, d.name, d.port, d.auto_restart, d.switch_id,
s.hostname AS switch_hostname, s.ip AS switch_ip, s.ssh_port AS switch_ssh_port,
s.credential_id AS switch_credential_id,
st.state, st.fail_count, st.last_restart_at
FROM devices d
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL AND d.is_active = 1
ORDER BY d.name COLLATE NOCASE
""",
)
def _within_restart_grace(row: Any) -> bool:
"""Liegt der letzte Neustart noch im Kulanzfenster?"""
if not row:
return False
moment = (row or {}).get("last_restart_at")
if not moment:
return False
try:
stamp = datetime.fromisoformat(str(moment))
except ValueError:
return False
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - stamp).total_seconds() < RESTART_GRACE_SECONDS
def record_status(
conn: sqlite3.Connection, device_id: int, result: PingResult
) -> tuple[str, str]:
"""Schreibt den Zustand fort. Rueckgabe: ``(vorher, nachher)``."""
row = Database.one(
conn,
"SELECT state, fail_count, last_restart_at FROM device_status WHERE device_id=?",
(device_id,),
)
previous = str(row["state"]) if row else "unknown"
new_state = "online" if result.ok else "offline"
# Ein Geraet, das gerade neu startet, ist nicht "nicht erreichbar" -- es ist
# erwartbar kurz weg. Erst wenn es nach dem Kulanzfenster noch schweigt,
# wird daraus ein Ausfall.
if not result.ok and previous == STATE_RESTARTING and _within_restart_grace(row):
new_state = STATE_RESTARTING
fail_count = 0 if result.ok else int((row or {}).get("fail_count") or 0) + 1
changed = previous != new_state
conn.execute(
"""
INSERT INTO device_status (device_id, state, latency_ms, fail_count, last_check_at,
last_ok_at, last_change_at, detail)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(device_id) DO UPDATE SET
state=excluded.state,
latency_ms=excluded.latency_ms,
fail_count=excluded.fail_count,
last_check_at=excluded.last_check_at,
last_ok_at=COALESCE(excluded.last_ok_at, device_status.last_ok_at),
last_change_at=CASE WHEN device_status.state <> excluded.state
THEN excluded.last_change_at ELSE device_status.last_change_at END,
detail=excluded.detail
""",
(
device_id,
new_state,
result.latency_ms,
fail_count,
_now(),
_now() if result.ok else None,
_now(),
result.detail,
),
)
return previous, new_state if changed else previous
#: Wie ein Geraet neu gestartet wurde.
METHOD_POE = "poe"
METHOD_SSH = "ssh"
METHOD_RPC = "rpc"
METHOD_LABELS = {
METHOD_POE: "PoE-Port",
METHOD_SSH: "Neustart ueber SSH",
METHOD_RPC: "Neustart ueber RPC",
}
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
#:
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
#: beim naechsten Pruefdurchlauf, und der laeuft standardmaessig alle fuenf
#: Minuten -- ein Neustart ueber SSH ist in vierzig Sekunden durch. Das Geraet
#: war also weg und wieder da, ohne dass es je jemand gesehen haette.
STATE_RESTARTING = "restarting"
#: Wie lange ein Geraet als "startet neu" gilt, wenn es nicht antwortet.
#: Danach ist es schlicht nicht erreichbar -- ein Neustart, der zehn Minuten
#: dauert, ist keiner mehr.
RESTART_GRACE_SECONDS = 300
def record_restart(
conn: sqlite3.Connection,
*,
device_id: int,
action: str,
result: str,
trigger: str,
actor: str,
duration_ms: int,
detail: str,
method: str = METHOD_POE,
) -> None:
"""Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.
PoE-Portzyklus und ``reboot`` ueber SSH sind fuer den Betrieb dasselbe
Ereignis. Sie landen deshalb in derselben Tabelle und unterscheiden sich
nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung
neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
auf.
"""
conn.execute(
"INSERT INTO restart_events "
"(device_id, ts, trigger, actor, action, result, duration_ms, detail, method) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
)
if result == "ok":
# Der Zustand wird hier gesetzt, nicht in den einzelnen Neustartwegen:
# PoE, SSH und RPC laufen alle durch diese Funktion. Eine zweite Stelle
# waere eine, die beim naechsten Verfahren vergessen wird.
conn.execute(
"UPDATE device_status SET last_restart_at=?, restart_count=restart_count+1, "
"state=?, last_change_at=?, fail_count=0 WHERE device_id=?",
(_now(), STATE_RESTARTING, _now(), device_id),
)
# --------------------------------------------------------------------------- #
# PoE-Neustart
# --------------------------------------------------------------------------- #
def restart_device_poe(
conn: sqlite3.Connection,
device: dict[str, Any],
*,
keystore: Any,
trigger: str,
actor: str,
live: LiveLog | None = None,
database: Any = None,
) -> dict[str, Any]:
"""Fuehrt einen PoE-Neustart durch und protokolliert ihn.
Rueckgabe: ``{"ok": bool, "message": str, "transcript": str}``.
``database`` bestimmt, wie geschrieben wird. Wird es uebergeben, oeffnet
jede Schreiboperation ihre **eigene kurze** Transaktion -- die SSH-Sitzung
zum Switch laeuft dann ausserhalb. Das ist fuer den Ueberwachungsdienst
entscheidend: ein Durchlauf mit drei nicht erreichbaren Switchen dauert
knapp 40 Sekunden, und solange lag die Schreibsperre auf der gesamten
Datenbank. Die Oberflaeche antwortete in dieser Zeit mit "database is
locked".
Ohne ``database`` (aus einem Request heraus, wo ohnehin schon eine kurze
Transaktion offen ist) wird direkt auf ``conn`` geschrieben.
"""
from contextlib import nullcontext
def write(): # type: ignore[no-untyped-def]
return database.transaction(conn) if database is not None else nullcontext()
name = str(device.get("name") or device.get("mac") or device.get("id"))
port = str(device.get("port") or "")
switch_ip = str(device.get("switch_ip") or "")
credential_id = device.get("switch_credential_id")
if not port or not switch_ip:
message = (
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
)
with write():
record_restart(
conn,
device_id=int(device["id"]),
action="cycle",
result="skipped",
trigger=trigger,
actor=actor,
duration_ms=0,
detail=message,
)
return {"ok": False, "message": message, "transcript": ""}
if not credential_id:
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
with write():
record_restart(
conn,
device_id=int(device["id"]),
action="cycle",
result="error",
trigger=trigger,
actor=actor,
duration_ms=0,
detail=message,
)
return {"ok": False, "message": message, "transcript": ""}
# Bewusst kein Rueckruf: der wuerde die vorhandene Verbindung nicht kennen
# und sich eine neue oeffnen -- mitten in dieser Schreibtransaktion. Genau
# daran ist der Ueberwachungsdienst gestorben.
username, password = inventory.credential_secret(conn, keystore, int(credential_id))
started = time.monotonic()
try:
with SwitchCli(
conn,
host=switch_ip,
port=int(device.get("switch_ssh_port") or 22),
username=username,
password=password,
) as cli:
outcome = cli.cycle_poe(port)
except SshError as exc:
duration = int((time.monotonic() - started) * 1000)
with write():
record_restart(
conn,
device_id=int(device["id"]),
action="cycle",
result="error",
trigger=trigger,
actor=actor,
duration_ms=duration,
detail=str(exc),
)
if live:
live.error("poe", f"{name}: {exc}")
return {"ok": False, "message": str(exc), "transcript": ""}
duration = int((time.monotonic() - started) * 1000)
with write():
record_restart(
conn,
device_id=int(device["id"]),
action="cycle",
result="ok" if outcome.ok else "error",
trigger=trigger,
actor=actor,
duration_ms=duration,
detail=(outcome.error or "; ".join(outcome.steps))[:8000],
)
if live:
if outcome.ok:
live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich")
else:
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
return {
"ok": outcome.ok,
"message": outcome.error or "PoE-Neustart durchgefuehrt.",
"transcript": outcome.transcript,
}
# --------------------------------------------------------------------------- #
# Durchlauf
# --------------------------------------------------------------------------- #
@dataclass(slots=True)
class SweepReport:
checked: int = 0
online: int = 0
offline: int = 0
restarts_triggered: int = 0
restarts_ok: int = 0
skipped_unlicensed: int = 0
duration_ms: int = 0
def run_sweep(
*,
database: Any,
live: LiveLog,
keystore: Any,
license_allows_auto_restart: bool,
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
) -> SweepReport:
"""Ein vollstaendiger Ueberwachungsdurchlauf.
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
keine halb geschriebenen Durchlaeufe.
"""
report = SweepReport()
started = time.monotonic()
with database.session() as conn:
devices = load_monitored_devices(conn)
if not devices:
report.duration_ms = int((time.monotonic() - started) * 1000)
return report
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices))
restart_candidates: list[dict[str, Any]] = []
with database.session() as conn:
with database.transaction(conn):
for device, result in results:
report.checked += 1
previous, current = record_status(conn, int(device["id"]), result)
if result.ok:
report.online += 1
else:
report.offline += 1
if previous != current:
name = str(device["name"])
if current == "online":
live.info("ping", f"{name} ({device['ip']}) ist wieder erreichbar")
else:
live.warning("ping", f"{name} ({device['ip']}) antwortet nicht")
if result.ok or not int(device.get("auto_restart") or 0):
continue
fail_count = int((device.get("fail_count") or 0)) + 1
if fail_count < failures_before_restart:
continue
if _in_cooldown(device.get("last_restart_at")):
continue
restart_candidates.append(device)
for device in restart_candidates:
name = str(device["name"])
if not license_allows_auto_restart:
report.skipped_unlicensed += 1
live.warning(
"poe",
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.",
)
continue
report.restarts_triggered += 1
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
# SSH-Sitzung zum Switch, und die dauert bei einem nicht erreichbaren
# Geraet zehn Sekunden und mehr. Lag die Schreibsperre solange auf der
# Datenbank, antwortete die Oberflaeche mit "database is locked". Die
# Aufzeichnung schreibt jetzt in eigenen, kurzen Transaktionen.
with database.session() as conn:
outcome = restart_device_poe(
conn,
device,
keystore=keystore,
trigger="auto",
actor="system",
live=live,
database=database,
)
with database.transaction(conn):
audit.log(
conn,
actor="system",
action="device.poe_auto_restart",
target=name,
detail=outcome["message"],
severity="notice" if outcome["ok"] else "warning",
)
if outcome["ok"]:
report.restarts_ok += 1
report.duration_ms = int((time.monotonic() - started) * 1000)
return report
def _in_cooldown(last_restart_at: Any) -> bool:
if not last_restart_at:
return False
try:
moment = datetime.fromisoformat(str(last_restart_at))
except ValueError:
return False
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - moment).total_seconds() < RESTART_COOLDOWN_SECONDS
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
row = Database.one(
conn,
"""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL
""",
)
row = row or {}
return {
key: int(row.get(key) or 0)
for key in ("total", "online", "offline", "unknown", "disabled", "restarting")
}
+117
View File
@@ -0,0 +1,117 @@
"""Welche Neustartwege ein Geraet hat -- und welcher davon der beste ist.
Ein Geraet kann auf mehreren Wegen neu startbar sein: ueber den PoE-Port des
Switches, ueber SSH, oder bei Windows ueber RPC. Bisher gab es nur den ersten,
und der Knopf verschwand einfach, wenn Switch oder Port fehlten -- ohne zu
sagen, dass ein anderer Weg offen gestanden haette.
Die Rangfolge ist bewusst festgelegt und nicht dem Zufall ueberlassen:
1. **PoE-Port.** Schaltet die Stromversorgung. Wirkt auch dann noch, wenn das
Betriebssystem nicht mehr antwortet -- und genau darum geht es meistens,
wenn jemand einen Neustart ausloest.
2. **SSH** (``reboot``). Sauberes Herunterfahren, setzt aber ein Geraet
voraus, das noch reagiert.
3. **RPC** (Windows). Wie SSH, nur ueber einen anderen Weg.
Stehen mehrere Wege offen, ist PoE vorausgewaehlt; die Oberflaeche laesst die
Wahl trotzdem zu. Ein sauberes Herunterfahren ist manchmal das Richtige --
etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
METHOD_POE = "poe"
METHOD_SSH = "ssh"
METHOD_RPC = "rpc"
#: Rangfolge -- der erste verfuegbare Weg ist die Vorauswahl.
PREFERENCE: tuple[str, ...] = (METHOD_POE, METHOD_SSH, METHOD_RPC)
@dataclass(frozen=True, slots=True)
class Method:
key: str
label: str
hint: str
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
licensed: bool = False
@property
def is_poe(self) -> bool:
return self.key == METHOD_POE
DEFINITIONS: dict[str, Method] = {
METHOD_POE: Method(
key=METHOD_POE,
label="PoE-Port",
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.",
),
METHOD_SSH: Method(
key=METHOD_SSH,
label="Neustart ueber SSH",
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet "
"und einen freigegebenen Host-Schluessel voraus.",
licensed=True,
),
METHOD_RPC: Method(
key=METHOD_RPC,
label="Neustart ueber RPC",
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
licensed=True,
),
}
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
schlimmer als kein Knopf.
"""
def field(name: str) -> Any:
try:
return device[name]
except (KeyError, IndexError, TypeError):
return None
methods: list[Method] = []
if field("switch_id") and str(field("port") or "").strip():
methods.append(DEFINITIONS[METHOD_POE])
category = str(field("credential_category") or "")
has_login = bool(field("credential_id"))
if has_login and maintenance_active:
if category == "windows":
methods.append(DEFINITIONS[METHOD_RPC])
elif category == "linux":
methods.append(DEFINITIONS[METHOD_SSH])
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None:
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
options = available(device, maintenance_active=maintenance_active)
return options[0] if options else None
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None:
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
sonst startet jemand ein Geraet anders neu, als er dachte.
"""
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)}
if not options:
return None
if not requested:
return options[sorted(options, key=PREFERENCE.index)[0]]
return options.get(requested)
+149
View File
@@ -0,0 +1,149 @@
"""Zeilenweise Kommandoausfuehrung auf einem Linux-Client.
Gegenstueck zu :mod:`switchcli`. Der Unterschied liegt im Verfahren, nicht in
der Bedienung:
* ArubaOS braucht eine **interaktive Shell** mit PTY, und die Antwort endet
erst am Prompt -- ``switchcli`` muss den Prompt erkennen.
* Ein Linux-Client kann jedes Kommando in einer eigenen ``exec``-Sitzung
ausfuehren. Das ist deutlich robuster: kein Prompt-Raten, kein PTY, ein
sauberer Rueckgabewert je Kommando, und ein haengendes Kommando reisst die
Sitzung nicht mit.
Warum ueberhaupt eine Konsole fuer Clients? Ohne sie gab es keinen Weg, einen
unbekannten Host-Schluessel im laufenden Betrieb zu pruefen und freizugeben --
und ohne freigegebenen Schluessel verweigert die Wartung den Dienst. Genau in
diese Sackgasse laeuft man sonst: die Wartung sagt "Schluessel unbekannt", und
es gibt keine Stelle, an der man ihn ansehen koennte.
Bewusst **keine** interaktive Shell fuer Clients: eine solche Sitzung braucht
einen dauerhaft blockierten Thread je Nutzer und einen Prompt-Parser, der bei
jedem abweichenden ``PS1`` bricht. Wer wirklich interaktiv arbeiten will,
nimmt einen SSH-Client -- dafuer ist er da.
"""
from __future__ import annotations
import sqlite3
import time
from typing import Any
from .ssh import SshError, open_transport
DEFAULT_COMMAND_TIMEOUT = 60
MAX_OUTPUT_BYTES = 256 * 1024
class ShellSession:
"""Offene SSH-Verbindung zu einem Client; ein ``exec``-Kanal je Kommando."""
def __init__(
self,
conn: sqlite3.Connection,
*,
host: str,
port: int,
username: str,
password: str,
allow_unknown_host_key: bool = False,
) -> None:
self.conn = conn
self.host = host
self.port = int(port)
self.username = username
self.password = password
self.allow_unknown_host_key = allow_unknown_host_key
self._transport: Any = None
def __enter__(self) -> "ShellSession":
self._transport = open_transport(
self.conn,
host=self.host,
port=self.port,
username=self.username,
password=self.password,
allow_unknown=self.allow_unknown_host_key,
)
return self
def __exit__(self, *_exc: object) -> None:
self.close()
def close(self) -> None:
try:
if self._transport is not None:
self._transport.close()
except Exception: # noqa: BLE001
pass
self._transport = None
# ------------------------------------------------------------------ #
def banner(self) -> str:
"""Kurze Auskunft ueber das Gegenueber -- als erste Zeile der Sitzung."""
try:
result = self.run("hostname; uname -sr; id -un", timeout=15)
except SshError:
return ""
return result["output"].strip()
def run(self, command: str, *, timeout: int = DEFAULT_COMMAND_TIMEOUT) -> dict[str, Any]:
"""Fuehrt ein Kommando aus und liefert Ausgabe samt Rueckgabewert.
Rueckgabe: ``{"output": str, "status": int, "duration_ms": int}``.
"""
if self._transport is None or not self._transport.is_active():
raise SshError("Die Verbindung wurde getrennt.")
started = time.monotonic()
channel = self._transport.open_session()
channel.settimeout(timeout)
channel.set_combine_stderr(True)
try:
channel.exec_command(command)
except Exception as exc: # noqa: BLE001
channel.close()
raise SshError(f"Das Kommando konnte nicht gestartet werden ({exc}).") from exc
chunks: list[bytes] = []
size = 0
deadline = time.monotonic() + timeout
truncated = False
try:
while True:
if channel.recv_ready():
chunk = channel.recv(32768)
if not chunk:
break
size += len(chunk)
if size > MAX_OUTPUT_BYTES:
truncated = True
break
chunks.append(chunk)
elif channel.exit_status_ready():
# Rest abholen, bevor der Kanal zugeht.
while channel.recv_ready():
chunks.append(channel.recv(32768))
break
elif time.monotonic() > deadline:
channel.close()
raise SshError(
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten."
)
else:
time.sleep(0.05)
status = channel.recv_exit_status() if not truncated else -1
finally:
try:
channel.close()
except Exception: # noqa: BLE001
pass
output = b"".join(chunks).decode("utf-8", errors="replace")
if truncated:
output += f"\n[Ausgabe nach {MAX_OUTPUT_BYTES // 1024} KB abgeschnitten]"
return {
"output": output,
"status": int(status),
"duration_ms": int((time.monotonic() - started) * 1000),
}
+317
View File
@@ -0,0 +1,317 @@
"""SSH-Grundlagen: Host-Schluessel-Pruefung und Verbindungsaufbau.
Der Vorgaenger hatte hier zwei Verfahren nebeneinander: das Browser-Terminal
akzeptierte unbekannte Host-Schluessel interaktiv (``AutoAddPolicy``-artig), die
Wartung wies sie hart ab (``RejectPolicy``). Die Begruendung war richtig -- beim
Terminal sieht ein Mensch zu, bei einem Hintergrundjob nicht -- aber die
Umsetzung lag in einer Datei ``known_hosts``, die niemand einsehen konnte.
Hier liegt die Vertrauensentscheidung in der Datenbank (``switch_host_keys`` --
der Name stammt aus der Zeit, als es nur Switche gab; die Tabelle ist nach
Host und Port geschluesselt und gilt fuer Switche wie Clients gleichermassen):
* **Unbekannter Schluessel**: Aufbau wird abgebrochen. Die Oberflaeche zeigt den
Fingerprint und erlaubt eine ausdrueckliche, protokollierte Freigabe. Kein
Hintergrundprozess kann diese Freigabe erteilen.
* **Geaenderter Schluessel**: immer ein Fehler, nie eine stille Uebernahme --
das ist der Fall, der bei einem Man-in-the-Middle auftritt.
* Jede Freigabe steht mit Zeitpunkt und Benutzer im Aenderungsprotokoll.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import re
import socket
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from tesm_core.db import Database
logger = logging.getLogger(__name__)
CONNECT_TIMEOUT = 12
BANNER_TIMEOUT = 20
AUTH_TIMEOUT = 20
class SshError(Exception):
"""Verbindungs- oder Anmeldefehler mit Klartextmeldung fuer die Oberflaeche."""
class UnknownHostKey(SshError):
"""Der Host-Schluessel ist unbekannt und muss freigegeben werden."""
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt "
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben."
)
self.host = host
self.port = port
self.key_type = key_type
self.fingerprint = fingerprint
class HostKeyChanged(SshError):
"""Der Host-Schluessel weicht vom gespeicherten ab."""
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert "
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
"Das kann ein Geraetetausch sein -- oder ein Angriff."
)
self.host = host
self.port = port
self.expected = expected
self.received = received
#: Ein SHA256-Fingerprint in OpenSSH-Schreibweise: "SHA256:" plus 43 Zeichen
#: Base64 ohne Fuellzeichen (32 Byte Hash).
FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
def valid_fingerprint(value: str) -> bool:
"""Prueft die Form eines Fingerprints, bevor er gespeichert wird.
Vorher wurde nur auf das Praefix ``SHA256:`` geprueft -- ``SHA256:unsinn``
kam damit durch. Ein abgeschnittener oder vertippter Wert laesst sich
danach von keinem echten Schluessel mehr treffen: die Anwendung meldet bei
jeder Verbindung "Schluessel geaendert" und der Verdacht faellt auf das
Geraet statt auf den Tippfehler.
"""
return bool(FINGERPRINT_RE.match((value or "").strip()))
def fingerprint_of(key: Any) -> str:
"""OpenSSH-kompatibler SHA256-Fingerprint (``SHA256:...``)."""
digest = hashlib.sha256(key.asbytes()).digest()
return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=")
@dataclass(frozen=True, slots=True)
class HostKeyRecord:
host: str
port: int
key_type: str
fingerprint: str
def known_key(
conn: sqlite3.Connection, host: str, port: int, key_type: str
) -> HostKeyRecord | None:
row = Database.one(
conn,
"SELECT host, port, key_type, fingerprint FROM switch_host_keys "
"WHERE host=? AND port=? AND key_type=?",
(host, int(port), key_type),
)
if row is None:
return None
return HostKeyRecord(
host=str(row["host"]),
port=int(row["port"]),
key_type=str(row["key_type"]),
fingerprint=str(row["fingerprint"]),
)
def trust_key( # noqa: PLR0913 - benannte Argumente, bewusst explizit
conn: sqlite3.Connection,
*,
host: str,
port: int,
key_type: str,
fingerprint: str,
actor: str,
) -> None:
conn.execute(
"INSERT INTO switch_host_keys (host, port, key_type, fingerprint, added_at, added_by) "
"VALUES (?,?,?,?,?,?) ON CONFLICT(host, port, key_type) DO UPDATE SET "
"fingerprint=excluded.fingerprint, added_at=excluded.added_at, added_by=excluded.added_by",
(
host,
int(port),
key_type,
fingerprint,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor,
),
)
def forget_key(conn: sqlite3.Connection, host: str, port: int) -> None:
conn.execute("DELETE FROM switch_host_keys WHERE host=? AND port=?", (host, int(port)))
def all_known_keys(conn: sqlite3.Connection, host: str, port: int) -> list[HostKeyRecord]:
"""Alle gespeicherten Schluessel eines Ziels, unabhaengig vom Typ."""
return [
HostKeyRecord(
host=str(row["host"]),
port=int(row["port"]),
key_type=str(row["key_type"]),
fingerprint=str(row["fingerprint"]),
)
for row in Database.all(
conn,
"SELECT host, port, key_type, fingerprint FROM switch_host_keys "
"WHERE host=? AND port=? ORDER BY key_type",
(host, int(port)),
)
]
def is_trusted(conn: sqlite3.Connection, host: str, port: int) -> bool:
"""Gibt es ueberhaupt einen freigegebenen Schluessel fuer dieses Ziel?"""
return bool(all_known_keys(conn, host, int(port)))
def describe_probe(
conn: sqlite3.Connection, record: HostKeyRecord
) -> tuple[str, str]:
"""Formuliert das Ergebnis einer Schluesselpruefung als (Stufe, Text).
Steht hier und nicht in den Blueprints, weil Switche und Clients denselben
Ablauf haben -- und weil die drei Faelle (unbekannt, abweichend, gleich)
ueberall gleich beurteilt werden muessen. Zwei Kopien dieser Logik waeren
zwei Gelegenheiten, den mittleren Fall zu verharmlosen.
"""
known = known_key(conn, record.host, record.port, record.key_type)
if known is None:
return (
"info",
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. "
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.",
)
if known.fingerprint != record.fingerprint:
return (
"danger",
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert "
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
"Geraetetausch sein -- oder ein Angriff.",
)
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.")
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
"""Holt den Host-Schluessel, ohne sich anzumelden -- fuer den Freigabedialog."""
import paramiko
try:
sock = socket.create_connection((host, int(port)), timeout=timeout)
except OSError as exc:
raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc
transport = paramiko.Transport(sock)
try:
transport.start_client(timeout=timeout)
key = transport.get_remote_server_key()
return HostKeyRecord(
host=host, port=int(port), key_type=key.get_name(), fingerprint=fingerprint_of(key)
)
except paramiko.SSHException as exc:
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
finally:
transport.close()
try:
sock.close()
except OSError:
pass
def open_transport(
conn: sqlite3.Connection,
*,
host: str,
port: int,
username: str,
password: str,
allow_unknown: bool = False,
) -> Any:
"""Baut eine geprueft-vertrauenswuerdige SSH-Verbindung auf.
Bewusst die Low-Level-``Transport``-API statt ``SSHClient.connect()``: nur so
laesst sich der Host-Schluessel *vor* der Passwortuebergabe pruefen -- und nur
so kann das Browser-Terminal eine Freigabe zwischen Handshake und Anmeldung
einschieben.
Authentisierungsreihenfolge ``password`` vor ``keyboard-interactive``:
mehrere ArubaOS-Varianten bieten letzteres unzuverlaessig an; wer damit
beginnt, sieht nie einen Prompt und bekommt sofort "Zugangsdaten falsch".
"""
import paramiko
try:
sock = socket.create_connection((host, int(port)), timeout=CONNECT_TIMEOUT)
except OSError as exc:
raise SshError(f"{host}:{port} ist nicht erreichbar ({exc}).") from exc
transport = paramiko.Transport(sock)
transport.banner_timeout = BANNER_TIMEOUT
transport.auth_timeout = AUTH_TIMEOUT
# Wenn fuer dieses Ziel schon Schluessel freigegeben sind, wird die
# Aushandlung auf genau diese Typen begrenzt -- so macht es OpenSSH mit
# known_hosts auch.
#
# Ohne diese Begrenzung entsteht ein Zustand, der niemandem zu erklaeren
# ist: die Oberflaeche zeigt "freigegeben", der Verbindungsversuch meldet
# trotzdem "Schluessel unbekannt". Grund waere schlicht, dass der Server
# einen anderen Typ angeboten hat (etwa ssh-rsa statt ssh-ed25519) als den
# freigegebenen. Der Fehler steckt dann nicht im Vertrauen, sondern in der
# Auswahl -- und die laesst sich hier treffen.
trusted_types = [record.key_type for record in all_known_keys(conn, host, int(port))]
if trusted_types:
options = transport.get_security_options()
supported = [name for name in options.key_types if name in trusted_types]
if supported:
options.key_types = tuple(supported)
try:
transport.start_client(timeout=CONNECT_TIMEOUT)
except paramiko.SSHException as exc:
transport.close()
if trusted_types:
raise SshError(
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der "
"Schluessel neu ausgelesen und freigegeben werden."
) from exc
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
server_key = transport.get_remote_server_key()
key_type = server_key.get_name()
received = fingerprint_of(server_key)
record = known_key(conn, host, int(port), key_type)
if record is None:
if not allow_unknown:
transport.close()
raise UnknownHostKey(host, int(port), key_type, received)
elif record.fingerprint != received:
transport.close()
raise HostKeyChanged(host, int(port), record.fingerprint, received)
try:
transport.auth_password(username, password)
except paramiko.AuthenticationException:
try:
transport.auth_interactive_dumb(username, lambda *_: [password])
except paramiko.SSHException as exc:
transport.close()
raise SshError("Die Zugangsdaten wurden abgewiesen.") from exc
except paramiko.SSHException as exc:
transport.close()
raise SshError(f"Anmeldung fehlgeschlagen ({exc}).") from exc
if not transport.is_authenticated():
transport.close()
raise SshError("Die Zugangsdaten wurden abgewiesen.")
return transport
+255
View File
@@ -0,0 +1,255 @@
"""ArubaOS-Kommandozeile: PoE schalten und Zustand auslesen.
Der Vorgaenger steuerte den Switch ueber ein ``expect``-Skript. Das brachte drei
Probleme mit, die hier verschwinden:
1. **Fehlererkennung war unmoeglich.** Ein Versuch, harte Zeitlimits pro
Schritt einzufuehren, war eine Regression: das Prompt-Muster erkannte nur den
Operator-Modus (``switch>``), nicht den privilegierten (``switch#``), den
manche Konten direkt nach der Anmeldung haben -- gueltige Anmeldungen wurden
als Fehlschlag gewertet.
Loesung hier: das Prompt-Muster erkennt **beide** Modi, und der Erfolg wird
nicht am Prompt gemessen, sondern am tatsaechlichen Zustand
(``show power-over-ethernet``). Damit braucht es kein Raten mehr.
2. **Passwoerter standen in der Prozessliste.** ``expect`` bekam sie als
Argument. Hier gehen sie nie durch eine Kommandozeile.
3. **Kein Transkript.** Jetzt liefert jeder Vorgang die vollstaendige Ausgabe
zurueck, die als Ereignis gespeichert wird.
"""
from __future__ import annotations
import logging
import re
import sqlite3
import time
from dataclasses import dataclass, field
from typing import Any
from .ssh import SshError, open_transport
logger = logging.getLogger(__name__)
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
#: Konfigurationsmodus (``switch(config)#``).
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$")
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
ERROR_RE = re.compile(
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
)
READ_CHUNK = 65536
DEFAULT_COMMAND_TIMEOUT = 20
POE_SETTLE_SECONDS = 2
@dataclass(slots=True)
class CliResult:
ok: bool
transcript: str = ""
error: str = ""
steps: list[str] = field(default_factory=list)
def add(self, message: str) -> None:
self.steps.append(message)
class SwitchCli:
"""Interaktive CLI-Sitzung auf einem ArubaOS-Switch."""
def __init__(
self,
conn: sqlite3.Connection,
*,
host: str,
port: int,
username: str,
password: str,
allow_unknown_host_key: bool = False,
) -> None:
self.conn = conn
self.host = host
self.port = int(port)
self.username = username
self.password = password
self.allow_unknown_host_key = allow_unknown_host_key
self._transport: Any = None
self._channel: Any = None
self._buffer = b""
self.transcript: list[str] = []
# ------------------------------------------------------------------ #
def __enter__(self) -> "SwitchCli":
self._transport = open_transport(
self.conn,
host=self.host,
port=self.port,
username=self.username,
password=self.password,
allow_unknown=self.allow_unknown_host_key,
)
self._channel = self._transport.open_session()
self._channel.settimeout(DEFAULT_COMMAND_TIMEOUT)
# ArubaOS braucht ein PTY; ohne bleibt die Shell stumm.
self._channel.get_pty(term="vt100", width=200, height=1000)
self._channel.invoke_shell()
self._read_until_prompt(timeout=DEFAULT_COMMAND_TIMEOUT, initial=True)
# Seitenweise Ausgabe abschalten -- sonst blockiert jedes "show" am Pager.
for command in ("no page", "terminal length 1000"):
self.send(command, tolerate_error=True)
return self
def __exit__(self, *_exc: object) -> None:
self.close()
def close(self) -> None:
for closer in (self._channel, self._transport):
try:
if closer is not None:
closer.close()
except Exception: # noqa: BLE001
pass
self._channel = None
self._transport = None
# ------------------------------------------------------------------ #
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
assert self._channel is not None
deadline = time.monotonic() + timeout
collected = b""
while time.monotonic() < deadline:
if self._channel.recv_ready():
chunk = self._channel.recv(READ_CHUNK)
if not chunk:
break
collected += chunk
if PAGER_RE.search(collected[-200:]):
self._channel.send(" ")
collected = PAGER_RE.sub(b"", collected)
continue
if PROMPT_RE.search(collected[-200:]):
break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
if initial and collected.rstrip().endswith(b"continue"):
self._channel.send("\r\n")
else:
time.sleep(0.05)
text = collected.decode("utf-8", errors="replace")
self.transcript.append(text)
return text
def send(
self, command: str, *, timeout: int = DEFAULT_COMMAND_TIMEOUT, tolerate_error: bool = False
) -> str:
"""Schickt ein Kommando und liest bis zum Prompt.
Ein erkannter Fehlertext fuehrt zu einer Ausnahme -- ausser bei
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
"""
if self._channel is None:
raise SshError("Die Sitzung ist nicht geoeffnet.")
self.transcript.append(f"\n$ {command}\n")
self._channel.send(command + "\r\n")
output = self._read_until_prompt(timeout=timeout)
if not tolerate_error and ERROR_RE.search(output):
raise SshError(f"Der Switch hat das Kommando abgelehnt: {command!r}")
return output
@property
def full_transcript(self) -> str:
return "".join(self.transcript)
# ------------------------------------------------------------------ #
# PoE
# ------------------------------------------------------------------ #
def set_poe(self, port: str, enabled: bool) -> None:
self.send("configure terminal")
try:
self.send(f"interface {port}")
self.send("power-over-ethernet" if enabled else "no power-over-ethernet")
finally:
# Immer zurueck in den privilegierten Modus, auch nach einem Fehler --
# sonst bleibt die Sitzung im Konfigurationsmodus haengen.
self.send("exit", tolerate_error=True)
self.send("exit", tolerate_error=True)
def poe_state(self, port: str) -> str:
"""``enabled`` / ``disabled`` / ``unknown`` -- gelesen, nicht geraten."""
output = self.send(f"show power-over-ethernet {port}", tolerate_error=True)
lowered = output.lower()
if "power enable" in lowered:
for line in lowered.splitlines():
if "power enable" in line:
if "yes" in line or "enabled" in line:
return "enabled"
if "no" in line or "disabled" in line:
return "disabled"
if re.search(r"poe port status\s*:\s*enabled", lowered):
return "enabled"
if re.search(r"poe port status\s*:\s*disabled", lowered):
return "disabled"
return "unknown"
def cycle_poe(self, port: str) -> CliResult:
"""Schaltet PoE aus, wartet und wieder ein. Prueft das Ergebnis."""
result = CliResult(ok=False)
try:
before = self.poe_state(port)
result.add(f"Zustand vor dem Vorgang: {before}")
self.set_poe(port, enabled=False)
result.add("PoE ausgeschaltet")
time.sleep(POE_SETTLE_SECONDS)
off_state = self.poe_state(port)
if off_state == "enabled":
result.error = (
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- "
"der Neustart hat nicht stattgefunden."
)
result.transcript = self.full_transcript
return result
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
self.set_poe(port, enabled=True)
result.add("PoE wieder eingeschaltet")
after = self.poe_state(port)
result.add(f"Zustand nach dem Vorgang: {after}")
if after == "disabled":
result.error = "Der Port ist nach dem Vorgang weiter abgeschaltet."
result.transcript = self.full_transcript
return result
result.ok = True
except SshError as exc:
result.error = str(exc)
except Exception as exc: # noqa: BLE001 - Netzabbruch mitten im Vorgang
logger.exception("PoE-Vorgang auf %s Port %s fehlgeschlagen", self.host, port)
result.error = f"Unerwarteter Fehler: {exc}"
result.transcript = self.full_transcript
return result
# ------------------------------------------------------------------ #
def interface_overview(self) -> str:
return self.send("show power-over-ethernet brief", tolerate_error=True)
def system_info(self) -> str:
return self.send("show system information", tolerate_error=True)
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
("show system information", "Systeminformationen"),
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"),
("show interfaces brief", "Portstatus"),
("show vlans", "VLAN-Uebersicht"),
("show mac-address", "MAC-Adresstabelle"),
("show lldp info remote-device", "Nachbargeraete (LLDP)"),
("show version", "Firmware-Version"),
("show logging", "Switch-Protokoll"),
)
+637
View File
@@ -0,0 +1,637 @@
"""Sicherung von TESM: welche Kategorien es gibt und wie sie gelesen/geschrieben werden.
Der verschluesselte Umschlag selbst (Argon2id + AES-256-GCM, Zwischenstand des
Imports) liegt im Kern -- ``tesm_core.transfer``. Hier steht nur die Fachlogik.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import datetime, timezone
from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.keystore import Keystore
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
Category,
TransferError,
drop_preview,
load_preview,
seal as _seal,
store_preview,
summarize as _summarize,
unseal as _unseal,
)
logger = logging.getLogger(__name__)
def _now() -> datetime:
return datetime.now(timezone.utc)
APP_TAG = "tesm"
def seal(payload: dict[str, Any], passphrase: str) -> str:
return _seal(payload, passphrase, app_tag=APP_TAG)
def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, Any]]:
return _unseal(raw, passphrase, app_tag=APP_TAG)
# --------------------------------------------------------------------------- #
# Kategorien
# --------------------------------------------------------------------------- #
CATEGORIES: tuple[Category, ...] = (
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"),
Category("switches", "Switche", "Switch-Inventar"),
Category(
"credentials",
"Zugangsdaten",
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt "
"ausschliesslich durch die Passphrase.",
sensitive=True,
),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
)
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
# --------------------------------------------------------------------------- #
# Export
# --------------------------------------------------------------------------- #
def build_export(
conn: sqlite3.Connection,
keystore: Keystore,
*,
categories: Iterable[str],
include_secrets: bool,
) -> dict[str, Any]:
from .inventory import aad_for_credential
wanted = [key for key in categories if key in CATEGORY_BY_KEY]
payload: dict[str, Any] = {}
if "devices" in wanted:
payload["devices"] = Database.all(
conn,
"SELECT d.mac, d.ip, d.name, d.port, d.is_active, d.ssh_port, d.category, "
"d.location, d.notes, d.auto_restart, s.hostname AS switch_hostname, "
"c.name AS credential_name FROM devices d "
"LEFT JOIN switches s ON s.id = d.switch_id "
"LEFT JOIN credentials c ON c.id = d.credential_id "
"WHERE d.deleted_at IS NULL",
)
if "switches" in wanted:
payload["switches"] = Database.all(
conn,
"SELECT s.hostname, s.ip, s.ssh_port, s.model, s.location, s.notes, "
"c.name AS credential_name FROM switches s "
"LEFT JOIN credentials c ON c.id = s.credential_id WHERE s.deleted_at IS NULL",
)
if "credentials" in wanted:
rows = Database.all(
conn,
"SELECT id, name, username, category, description, secret_encrypted FROM credentials "
"WHERE deleted_at IS NULL",
)
entries = []
for row in rows:
entry = {
"name": row["name"],
"username": row["username"],
"category": row["category"],
"description": row["description"],
}
if include_secrets and str(row["secret_encrypted"] or ""):
try:
entry["secret"] = keystore.decrypt(
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
)
except Exception: # noqa: BLE001
entry["secret_error"] = "nicht entschluesselbar"
entries.append(entry)
payload["credentials"] = entries
if "dhcp" in wanted:
payload["dhcp"] = {
"subnets": Database.all(
conn,
"SELECT interface, range_start, range_end, gateway, dns, comment, enabled, "
"sort_order FROM dhcp_subnets",
),
"options": Database.all(
conn,
"SELECT o.code, o.name, o.type, o.description, o.is_standard, v.value, "
"d.mac AS device_mac FROM dhcp_options o "
"JOIN dhcp_option_values v ON v.option_id = o.id "
"LEFT JOIN devices d ON d.id = v.device_id",
),
"reservations": Database.all(
conn, "SELECT mac, ip, hostname, comment FROM dhcp_reservations"
),
}
if "fileshare" in wanted:
payload["fileshare"] = Database.all(
conn, "SELECT ad_group_dn, ad_group_name, label, unc FROM fileshare_mappings"
)
if "users" in wanted:
# Verzeichniskonten werden nie exportiert -- sie legen sich am Ziel bei
# der ersten Anmeldung selbst an, und ihr Passwort liegt ohnehin nicht hier.
payload["users"] = Database.all(
conn,
"SELECT u.username, u.password_hash, u.is_admin, u.is_locked, u.first_name, "
"u.last_name, u.email, u.must_change_password, "
"(SELECT group_concat(g.name, char(10)) FROM user_groups ug "
" JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id) AS group_names "
"FROM users u WHERE u.deleted_at IS NULL AND u.auth_source='local'",
)
if "groups" in wanted:
payload["groups"] = [
{
"name": row["name"],
"description": row["description"],
"is_default": row["is_default"],
"is_system": row["is_system"],
"preset_key": row["preset_key"],
"permissions": [
str(entry["permission"])
for entry in Database.all(
conn,
"SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission",
(row["id"],),
)
],
}
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
]
if "settings" in wanted:
payload["settings"] = {
str(row["key"]): str(row["value"])
for row in Database.all(conn, "SELECT key, value FROM settings")
# Lizenz- und Aktivierungszustand gehoeren zur Maschine, nicht zur
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
if not str(row["key"]).startswith("license_")
}
return payload
# --------------------------------------------------------------------------- #
# Zwischenstand des Imports
# --------------------------------------------------------------------------- #
# ``store_preview``/``load_preview``/``drop_preview`` kommen aus dem Kern (siehe
# Import oben). Hier standen zeichengleiche Kopien, die die Importe
# ueberschatteten -- und zwar defekte: ihnen fehlten ``secrets``, ``datetime``,
# ``timedelta``, ``timezone``, ``_now`` und ``PREVIEW_JOB_KIND``. Jeder Aufruf
# waere mit NameError abgebrochen, also der ganze Importweg. Aufgefallen ist es
# erst, als der erste Test ihn wirklich benutzt hat.
def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]:
entries = _summarize(payload, CATEGORIES)
for entry in entries:
if entry["key"] == "credentials":
data = payload.get("credentials") or []
entry["has_secrets"] = any(
isinstance(row, dict) and "secret" in row for row in data
)
else:
entry["has_secrets"] = False
return entries
# --------------------------------------------------------------------------- #
# Uebernahme
# --------------------------------------------------------------------------- #
def apply_import(
conn: sqlite3.Connection,
keystore: Keystore,
payload: dict[str, Any],
*,
categories: Iterable[str],
set_setting: Callable[[str, Any], None],
) -> dict[str, dict[str, int]]:
"""Uebernimmt ausgewaehlte Kategorien. Rueckgabe: ``{kategorie: {angelegt, aktualisiert}}``.
Abgleich nach natuerlichem Schluessel (Name/Hostname/MAC/Benutzername).
Eintraege, die im Papierkorb liegen, werden **nicht** stillschweigend
wiederbelebt -- das war im Vorgaenger ein Fehler, der geloeschte Geraete
ueber den Import zurueckbrachte.
"""
from .inventory import ValidationError, aad_for_credential, normalize_mac
def mac_or_none(value: Any) -> str | None:
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
``normalize_mac`` wirft bei unbrauchbarer Eingabe -- auch bei leerer.
Aufgerufen wurde es hier ungeprueft, obwohl der Code danach ``if not
mac: continue`` erwartet. Ein einziger Eintrag ohne MAC haette damit
den gesamten Import mit einer Meldung ueber Hexzeichen abgebrochen.
"""
try:
return normalize_mac(str(value or ""))
except ValidationError:
return None
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {}
stamp = _now().isoformat(timespec="seconds")
def bump(category: str, field: str) -> None:
report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1
# Reihenfolge ist wichtig: Zugangsdaten vor Switchen, Switche vor Geraeten.
if "credentials" in wanted:
for entry in payload.get("credentials", []):
name = str(entry.get("name") or "").strip()
if not name:
continue
existing = Database.one(
conn,
"SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name,),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO credentials (name, username, secret_encrypted, category, "
"description, created_at, updated_at, updated_by) VALUES (?,?,'',?,?,?,?,'import')",
(
name,
str(entry.get("username") or ""),
str(entry.get("category") or "switch"),
str(entry.get("description") or ""),
stamp,
stamp,
),
)
credential_id = int(cursor.lastrowid or 0)
bump("credentials", "created")
else:
credential_id = int(existing["id"])
conn.execute(
"UPDATE credentials SET username=?, category=?, description=?, updated_at=?, "
"updated_by='import' WHERE id=?",
(
str(entry.get("username") or ""),
str(entry.get("category") or "switch"),
str(entry.get("description") or ""),
stamp,
credential_id,
),
)
bump("credentials", "updated")
if entry.get("secret"):
conn.execute(
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
(
keystore.encrypt(str(entry["secret"]), aad=aad_for_credential(credential_id)),
credential_id,
),
)
def credential_id_by_name(name: str | None) -> int | None:
if not name:
return None
value = Database.value(
conn,
"SELECT id FROM credentials WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name,),
)
return int(value) if value is not None else None
if "switches" in wanted:
for entry in payload.get("switches", []):
hostname = str(entry.get("hostname") or "").strip()
if not hostname:
continue
existing = Database.one(
conn,
"SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND deleted_at IS NULL",
(hostname,),
)
values = (
str(entry.get("ip") or ""),
int(entry.get("ssh_port") or 22),
credential_id_by_name(entry.get("credential_name")),
str(entry.get("model") or ""),
str(entry.get("location") or ""),
str(entry.get("notes") or ""),
)
if existing is None:
conn.execute(
"INSERT INTO switches (hostname, ip, ssh_port, credential_id, model, location, "
"notes, created_at, updated_at, updated_by) VALUES (?,?,?,?,?,?,?,?,?,'import')",
(hostname, *values, stamp, stamp),
)
bump("switches", "created")
else:
conn.execute(
"UPDATE switches SET ip=?, ssh_port=?, credential_id=?, model=?, location=?, "
"notes=?, updated_at=?, updated_by='import' WHERE id=?",
(*values, stamp, existing["id"]),
)
bump("switches", "updated")
def switch_id_by_hostname(hostname: str | None) -> int | None:
if not hostname:
return None
value = Database.value(
conn,
"SELECT id FROM switches WHERE hostname=? COLLATE NOCASE AND deleted_at IS NULL",
(hostname,),
)
return int(value) if value is not None else None
if "devices" in wanted:
for entry in payload.get("devices", []):
mac = normalize_mac(str(entry.get("mac") or ""))
if not mac:
bump("devices", "skipped")
continue
existing = Database.one(
conn,
"SELECT id, deleted_at FROM devices WHERE mac=? COLLATE NOCASE",
(mac,),
)
if existing is not None and existing["deleted_at"]:
# Im Papierkorb liegende Geraete werden nicht wiederbelebt.
bump("devices", "skipped")
continue
values = (
str(entry.get("ip") or ""),
str(entry.get("name") or mac),
switch_id_by_hostname(entry.get("switch_hostname")),
str(entry.get("port") or ""),
int(bool(entry.get("is_active", 1))),
credential_id_by_name(entry.get("credential_name")),
int(entry.get("ssh_port") or 22),
str(entry.get("category") or ""),
str(entry.get("location") or ""),
str(entry.get("notes") or ""),
int(bool(entry.get("auto_restart", 1))),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO devices (mac, ip, name, switch_id, port, is_active, credential_id, "
"ssh_port, category, location, notes, auto_restart, created_at, updated_at, "
"updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import')",
(mac, *values, stamp, stamp),
)
conn.execute(
"INSERT INTO device_status (device_id, state, last_change_at) "
"VALUES (?,'unknown',?)",
(int(cursor.lastrowid or 0), stamp),
)
bump("devices", "created")
else:
conn.execute(
"UPDATE devices SET ip=?, name=?, switch_id=?, port=?, is_active=?, "
"credential_id=?, ssh_port=?, category=?, location=?, notes=?, auto_restart=?, "
"updated_at=?, updated_by='import' WHERE id=?",
(*values, stamp, existing["id"]),
)
bump("devices", "updated")
if "dhcp" in wanted:
data = payload.get("dhcp") or {}
for entry in data.get("subnets", []):
conn.execute(
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, "
"comment, enabled, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
(
str(entry.get("interface") or ""),
str(entry.get("range_start") or ""),
str(entry.get("range_end") or ""),
str(entry.get("gateway") or ""),
str(entry.get("dns") or ""),
str(entry.get("comment") or ""),
int(bool(entry.get("enabled", 1))),
int(entry.get("sort_order") or 0),
stamp,
stamp,
),
)
bump("dhcp", "created")
# Optionen: Definition und Wert stehen je Zeile beieinander. Sie wurden
# exportiert, aber nie importiert -- ein Export/Import verlor damit
# stillschweigend alle eigenen Optionen und alle Werte, auch die je
# Geraet gesetzten.
for entry in data.get("options", []):
code = int(entry.get("code") or 0)
name = str(entry.get("name") or "").strip()
if not code or not name:
continue
conn.execute(
"INSERT INTO dhcp_options (code, name, type, description, is_standard) "
"VALUES (?,?,?,?,?) ON CONFLICT(code) DO UPDATE SET name=excluded.name, "
"type=excluded.type, description=excluded.description",
(
code,
name,
str(entry.get("type") or "string"),
str(entry.get("description") or ""),
int(bool(entry.get("is_standard"))),
),
)
option_id = Database.value(conn, "SELECT id FROM dhcp_options WHERE code=?", (code,))
if option_id is None:
continue
wert = str(entry.get("value") or "")
if not wert:
continue
roh_mac = str(entry.get("device_mac") or "")
geraet_mac = mac_or_none(roh_mac) if roh_mac else None
if roh_mac and geraet_mac is None:
bump("dhcp", "skipped")
continue
device_id = (
Database.value(
conn,
"SELECT id FROM devices WHERE mac=? AND deleted_at IS NULL",
(geraet_mac,),
)
if geraet_mac
else None
)
# Ein Wert, der zu einem Geraet gehoert, das es hier nicht gibt,
# darf nicht als globaler Wert landen -- das wuerde ihn an alle
# ausliefern. Er wird uebersprungen.
if geraet_mac and device_id is None:
bump("dhcp", "skipped")
continue
if device_id is None:
conn.execute(
"INSERT INTO dhcp_option_values (option_id, device_id, value) "
"VALUES (?,NULL,?) ON CONFLICT(option_id) WHERE device_id IS NULL "
"DO UPDATE SET value=excluded.value",
(int(option_id), wert),
)
else:
conn.execute(
"INSERT INTO dhcp_option_values (option_id, device_id, value) "
"VALUES (?,?,?) ON CONFLICT(option_id, device_id) WHERE device_id IS NOT NULL "
"DO UPDATE SET value=excluded.value",
(int(option_id), int(device_id), wert),
)
bump("dhcp", "updated")
for entry in data.get("reservations", []):
mac = mac_or_none(entry.get("mac"))
if not mac:
bump("dhcp", "skipped")
continue
conn.execute(
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, "
"hostname=excluded.hostname, comment=excluded.comment",
(
mac,
str(entry.get("ip") or ""),
str(entry.get("hostname") or ""),
str(entry.get("comment") or ""),
stamp,
),
)
bump("dhcp", "updated")
if "fileshare" in wanted:
for entry in payload.get("fileshare", []):
conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
"label=excluded.label, ad_group_name=excluded.ad_group_name",
(
str(entry.get("ad_group_dn") or ""),
str(entry.get("ad_group_name") or ""),
str(entry.get("label") or ""),
str(entry.get("unc") or ""),
stamp,
),
)
bump("fileshare", "updated")
if "groups" in wanted:
for entry in payload.get("groups", []):
name = str(entry.get("name") or "").strip()
if not name:
continue
existing = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name,),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO groups (name, description, is_default, is_system, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(
name,
str(entry.get("description") or ""),
int(bool(entry.get("is_default"))),
int(bool(entry.get("is_system"))),
str(entry.get("preset_key") or ""),
stamp,
stamp,
),
)
group_id = int(cursor.lastrowid or 0)
bump("groups", "created")
else:
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
)
if "users" in wanted:
for entry in payload.get("users", []):
username = str(entry.get("username") or "").strip()
if not username:
continue
existing = Database.one(
conn,
"SELECT id, deleted_at FROM users WHERE username=? COLLATE NOCASE",
(username,),
)
if existing is not None and existing["deleted_at"]:
bump("users", "skipped")
continue
if existing is None:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, "
"first_name, last_name, email, must_change_password, created_at, updated_at) "
"VALUES (?,?,?,?,'local',?,?,?,?,?,?)",
(
username,
str(entry.get("password_hash") or ""),
int(bool(entry.get("is_admin"))),
int(bool(entry.get("is_locked"))),
str(entry.get("first_name") or ""),
str(entry.get("last_name") or ""),
str(entry.get("email") or ""),
int(bool(entry.get("must_change_password"))),
stamp,
stamp,
),
)
user_id = int(cursor.lastrowid or 0)
bump("users", "created")
else:
user_id = int(existing["id"])
conn.execute(
"UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?",
(
str(entry.get("first_name") or ""),
str(entry.get("last_name") or ""),
str(entry.get("email") or ""),
stamp,
user_id,
),
)
bump("users", "updated")
names = [
name for name in str(entry.get("group_names") or "").split("\n") if name.strip()
]
if names:
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
for name in names:
group_id = Database.value(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name.strip(),),
)
if group_id is not None:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
(user_id, int(group_id)),
)
if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"):
continue
# Ueber den Store, damit Typ und Wertebereich geprueft werden -- und
# bewusst mit derselben Verbindung (keine zweite oeffnen!).
set_setting(str(key), value)
bump("settings", "updated")
return report
+168
View File
@@ -0,0 +1,168 @@
"""Windows-Clients aus der Ferne neu starten -- ohne SSH.
Windows-Geraete werden hier **nicht** ueber SSH verwaltet. Sie haben in aller
Regel keinen SSH-Dienst, und damit gibt es auch keinen Host-Schluessel, den
jemand freigeben koennte. Alles, was in TESM mit Host-Schluesseln zu tun hat,
laesst Windows-Geraete deshalb bewusst aus.
Stattdessen der klassische Weg von einem Linux-Host aus: ``net rpc shutdown``
aus ``samba-common-bin``. Das spricht denselben RPC-Dienst an, den auch
``shutdown /m \\\\host`` unter Windows benutzt, und funktioniert mit lokalen
wie mit Domaenenkonten.
Zum Passwort: es geht **nie** ueber die Kommandozeile. ``net -U benutzer%pass``
waere auf dem Zielsystem in ``ps`` sichtbar -- und auch auf diesem hier. Es
wird darum in eine 0600-Datei geschrieben, die nur der Dienstbenutzer lesen
kann, und ueber ``--authentication-file`` uebergeben. Die Datei wird danach
ueberschrieben und geloescht.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
logger = logging.getLogger(__name__)
#: Das Paket, das ``net`` mitbringt. Steht in der Positivliste des Helfers,
#: damit es sich aus der Oberflaeche nachinstallieren laesst.
REQUIRED_PACKAGE = "samba-common-bin"
DEFAULT_TIMEOUT = 45
@dataclass(frozen=True, slots=True)
class RemoteResult:
ok: bool
output: str
hint: str = ""
def available() -> bool:
"""Ist ``net`` auf diesem Host installiert?"""
return shutil.which("net") is not None
#: Fehlerbilder von ``net rpc``, uebersetzt in etwas Handlungsleitendes.
HINTS: tuple[tuple[str, str], ...] = (
(
"NT_STATUS_LOGON_FAILURE",
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem "
"Domaenenkonto die Form DOMAENE\\\\benutzer verwenden.",
),
(
"NT_STATUS_ACCESS_DENIED",
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht "
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen "
"Administratoren genuegt dafuer in der Regel.",
),
(
"NT_STATUS_CONNECTION_REFUSED",
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
),
(
"NT_STATUS_IO_TIMEOUT",
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?",
),
(
"NT_STATUS_HOST_UNREACHABLE",
"Das Geraet ist nicht erreichbar.",
),
)
def hint_for(output: str) -> str:
for needle, hint in HINTS:
if needle in output:
return hint
return ""
def reboot(
host: str,
username: str,
password: str,
*,
domain: str = "",
comment: str = "Neustart durch TESM-Wartung",
delay: int = 5,
timeout: int = DEFAULT_TIMEOUT,
) -> RemoteResult:
"""Loest einen Neustart auf einem Windows-Geraet aus."""
binary = shutil.which("net")
if binary is None:
return RemoteResult(
False,
"",
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
"ein Windows-Geraet von hier nicht neu starten.",
)
if not username:
return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.")
# Ein Benutzer der Form DOMAENE\benutzer bringt seine Domaene selbst mit.
if "\\" in username and not domain:
domain, username = username.split("\\", 1)
handle, path = tempfile.mkstemp(prefix="tesm-winauth-")
try:
# mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem
# ausdruecklich -- die Datei traegt gleich ein Klartextpasswort, das
# will man nicht von einer Vorgabe abhaengig machen.
try:
os.chmod(path, 0o600)
except OSError: # pragma: no cover - Windows kennt den Modus nicht
pass
with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
stream.write(f"username={username}\npassword={password}\n")
if domain:
stream.write(f"domain={domain}\n")
command = [
binary,
"rpc",
"shutdown",
"-r", # neu starten statt herunterfahren
"-f", # laufende Anwendungen nicht blockieren lassen
"-t",
str(max(0, int(delay))),
"-C",
comment,
"-I",
host,
"--authentication-file",
path,
]
try:
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
command, capture_output=True, text=True, timeout=timeout, check=False
)
except subprocess.TimeoutExpired:
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
except OSError as exc:
return RemoteResult(False, "", str(exc))
output = (completed.stdout + completed.stderr).strip()
if completed.returncode == 0:
return RemoteResult(True, output or "Neustart ausgeloest.")
return RemoteResult(False, output, hint_for(output))
finally:
# Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort.
try:
with open(path, "r+b") as stream:
length = stream.seek(0, os.SEEK_END)
stream.seek(0)
stream.write(b"\0" * length)
stream.flush()
os.fsync(stream.fileno())
except OSError: # pragma: no cover - defensiv
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path)
try:
os.unlink(path)
except OSError: # pragma: no cover - defensiv
pass
+129
View File
@@ -0,0 +1,129 @@
"""Laufzeiteinstellungen von TESM."""
from __future__ import annotations
from tesm_core.settings_store import SettingSpec
from .services.kea import DEFAULT_CONFIG_PATH
TESM_SETTINGS: tuple[SettingSpec, ...] = (
# -- Ueberwachung ------------------------------------------------------- #
SettingSpec(
"monitor_interval_seconds",
300,
"int",
"Pruefintervall (Sekunden)",
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.",
section="monitoring",
minimum=30,
maximum=86400,
),
SettingSpec(
"monitor_failures_before_restart",
2,
"int",
"Fehlversuche bis zum automatischen PoE-Neustart",
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. "
"Der Vorgaenger schaltete sofort beim ersten Ausfall.",
section="monitoring",
minimum=1,
maximum=10,
),
SettingSpec(
"monitor_auto_restart",
True,
"bool",
"Automatischen PoE-Neustart bei Ausfall aktivieren",
section="monitoring",
),
SettingSpec(
"dashboard_public",
True,
"bool",
"Statusuebersicht ohne Anmeldung erreichbar",
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine "
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
section="monitoring",
),
# -- DHCP --------------------------------------------------------------- #
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
SettingSpec(
"dhcp_lease_default", 600, "int", "Lease-Dauer (Sekunden)", section="dhcp",
minimum=60, maximum=604800,
),
SettingSpec(
"dhcp_lease_max", 7200, "int", "Maximale Lease-Dauer (Sekunden)", section="dhcp",
minimum=60, maximum=604800,
),
SettingSpec(
"dhcp_output_path",
DEFAULT_CONFIG_PATH,
"str",
"Zielpfad der Kea-Konfiguration",
section="dhcp",
),
# -- Dateifreigaben ----------------------------------------------------- #
SettingSpec(
"fileshare_mount_root",
"/mnt/tesm-shares",
"str",
"Wurzelverzeichnis der Einbindungen",
section="fileshare",
),
SettingSpec(
"fileshare_max_age_hours",
12,
"int",
"Einbindungen automatisch loesen nach (Stunden)",
section="fileshare",
minimum=1,
maximum=168,
),
SettingSpec(
"fileshare_upload_max_mb",
512,
"int",
"Maximale Dateigroesse beim Hochladen (MB)",
section="fileshare",
minimum=1,
maximum=8192,
),
# -- Wartung ------------------------------------------------------------ #
SettingSpec(
"maintenance_command_timeout",
900,
"int",
"Zeitlimit eines Wartungslaufs (Sekunden)",
section="maintenance",
minimum=60,
maximum=7200,
),
SettingSpec(
"maintenance_reboot_wait",
300,
"int",
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)",
section="maintenance",
minimum=30,
maximum=1800,
),
# -- Terminal ----------------------------------------------------------- #
SettingSpec(
"terminal_enabled",
True,
"bool",
"Browser-Terminal fuer Switche aktiv",
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
"Passwortbestaetigung und wird vollstaendig protokolliert.",
section="monitoring",
),
SettingSpec(
"terminal_idle_timeout",
300,
"int",
"Terminal bei Untaetigkeit trennen nach (Sekunden)",
section="monitoring",
minimum=60,
maximum=3600,
),
)
@@ -0,0 +1,125 @@
{# Teilbereich der Statusuebersicht -- wird auch einzeln per fetch geladen. #}
{% from "tesm_core/macros.html" import pill, empty_state %}
{# Beide Bloecke in einen Stapel: sonst klebt die erste Gruppe an den
Filterkacheln, waehrend die Gruppen untereinander Abstand haben. #}
<div class="stack">
<div class="grid grid--4" data-behavior="tile-filter" data-filter-scope="tile-sections">
<button type="button" class="stat" data-filter-value="" aria-pressed="false">
<span class="stat__label">Gesamt</span>
<span class="stat__value">{{ counts.total }}</span>
<span class="stat__hint">Filter aufheben</span>
</button>
<button type="button" class="stat stat--ok" data-filter-value="online" aria-pressed="false">
<span class="stat__label">Erreichbar</span>
<span class="stat__value">{{ counts.online }}</span>
</button>
<button type="button" class="stat stat--danger" data-filter-value="offline" aria-pressed="false">
<span class="stat__label">Nicht erreichbar</span>
<span class="stat__value">{{ counts.offline }}</span>
</button>
{% if detailed %}
<button type="button" class="stat stat--muted" data-filter-value="disabled" aria-pressed="false">
<span class="stat__label">Deaktiviert</span>
<span class="stat__value">{{ counts.disabled }}</span>
</button>
{% else %}
<div class="stat stat--warning">
<span class="stat__label">Noch nicht geprueft</span>
<span class="stat__value">{{ counts.unknown }}</span>
</div>
{% endif %}
</div>
<div id="tile-sections" class="stack">
{% for section in sections %}
{% if section.tiles %}
<details class="card" data-tile-section="{{ section.key }}"
data-persist="dash-{{ section.key }}" {% if section.key != 'disabled' %}open{% endif %}>
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">
{{ pill(section.tiles | length, section.kind if section.kind != 'muted' else '') }}
{{ section.label }}
</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="card__body">
<div class="grid grid--tiles">
{% for tile in section.tiles %}
{# Kopf zuerst Kennzeichen, dann Name: das Auge findet die Kachel
ueber Farbe und Symbol, den genauen Namen liest es danach.
Die IP steht gross darunter statt in der Merkmalliste -- sie ist
die Angabe, nach der man auf dieser Seite tatsaechlich sucht. #}
<article class="tile tile--{{ tile.state }}" data-filter-item
data-filter-text="{{ tile.name }} {{ tile.get('ip','') }} {{ tile.get('switch','') }} {{ tile.location }}">
<div class="tile__head">
<span class="tile__badge" aria-hidden="true">{{ icon('cpu', size=17) }}</span>
{% if tile.state == 'online' %}{{ pill('Online', 'online') }}
{% elif tile.state == 'restarting' %}{{ pill('Startet neu', 'warning') }}
{% elif tile.state == 'offline' %}{{ pill('Offline', 'offline') }}
{% elif tile.state == 'disabled' %}{{ pill('Deaktiviert', '') }}
{% else %}{{ pill('Unbekannt', 'warning') }}{% endif %}
</div>
<div class="tile__name truncate" title="{{ tile.name }}">{{ tile.name }}</div>
{% if detailed %}<div class="tile__ip truncate">{{ tile.ip }}</div>{% endif %}
{% if tile.location %}
<div class="tile__meta truncate">{{ icon('map-pin', size=12) }} {{ tile.location }}</div>
{% endif %}
{% if detailed %}
<dl class="kv fs-xs mt-3">
{% if tile.switch %}
<dt>Switch</dt><dd class="truncate">{{ tile.switch }} / {{ tile.port }}</dd>
{% endif %}
{% if tile.latency_ms %}
<dt>Antwortzeit</dt><dd>{{ '%.1f' | format(tile.latency_ms) }} ms</dd>
{% endif %}
{% if tile.last_change_at %}
<dt>Seit</dt>
<dd><span data-relative-time="{{ tile.last_change_at }}">{{ tile.last_change_at | dt }}</span></dd>
{% endif %}
</dl>
<div class="row row--tight mt-3">
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
Details
</a>
{% if current_user.has('devices.execute') and tile.restart_methods %}
{% set method = tile.restart_methods[0] %}
<form method="post" data-no-guard
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
data-busy-label="Startet ..."
data-confirm="{{ tile.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=14) }} {{ method.label }}
</button>
</form>
{% endif %}
{% if current_user.has('devices.edit') and tile.state == 'disabled' %}
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="active" value="1">
<button class="btn btn--sm" type="submit">Aktivieren</button>
</form>
{% endif %}
</div>
{% endif %}
</article>
{% endfor %}
</div>
</div>
</details>
{% endif %}
{% endfor %}
{% if not sections | map(attribute='tiles') | select | list %}
{{ empty_state('Keine Geraete erfasst',
'Legen Sie das erste Geraet unter Ueberwachung an.', 'cpu') }}
{% endif %}
</div>
</div>
@@ -0,0 +1 @@
<pre class="log-view">{{ job.output or 'Noch keine Ausgabe.' }}</pre>
@@ -0,0 +1,161 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
{% block page_title %}Sicherung und Uebertragung{% endblock %}
{% block page_sub %}Verschluesselter Export &middot; zweistufiger Import{% endblock %}
{% block content %}
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer. Bewahren Sie
sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
<div class="card__sub">Auswahl je Kategorie &middot; verlangt eine frische Passwortbestaetigung</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<fieldset class="fieldset">
<legend>Kategorien</legend>
<div class="stack stack--tight">
{% for category in all_categories %}
{% set allowed = category in categories %}
<label class="check {{ 'check--disabled' if not allowed }}">
<input type="checkbox" name="categories" value="{{ category.key }}"
{% if allowed and not category.sensitive %}checked{% endif %}
{% if not allowed %}disabled{% endif %}>
<span>
{{ category.label }}
{% if category.sensitive %}{{ pill('Geheimnisse', 'danger') }}{% endif %}
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
<div class="fs-xs faint">{{ category.hint }}</div>
{% if not allowed %}
<div class="fs-xs danger-text">Fuer diese Kategorie fehlt Ihnen das Recht.</div>
{% endif %}
</span>
</label>
{% endfor %}
</div>
</fieldset>
{% if can_secrets %}
{{ checkbox('include_secrets', 'Passwoerter der Zugangsdaten mit exportieren',
hint_text='Sie liegen dann im Klartext innerhalb der verschluesselten
Datei. Ohne dieses Kennzeichen enthaelt die Sicherung nur Namen und
Benutzer.') }}
{% endif %}
<div class="form-grid">
{{ field('passphrase', 'Passphrase', type='password', required=True,
autocomplete='new-password',
hint_text='Mindestens ' ~ min_passphrase ~ ' Zeichen.') }}
{{ field('passphrase_repeat', 'Passphrase wiederholen', type='password', required=True,
autocomplete='new-password') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" {% if license_hint %}disabled{% endif %}>
Sicherung herunterladen
</button>
</div>
</form>
</section>
<div class="stack">
{% if preview %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
<div class="card__sub">
Waehlen Sie, was uebernommen werden soll. Gueltig fuer {{ preview_ttl }} Minuten.
</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.import_apply') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
<tbody>
{% for entry in preview.summary %}
<tr>
<td>
<label class="check">
<input type="checkbox" name="categories" value="{{ entry.key }}" checked>
<span class="sr-only">{{ entry.label }}</span>
</label>
</td>
<td>
{{ entry.label }}
{% if entry.has_secrets %}{{ pill('mit Passwoertern', 'danger') }}{% endif %}
</td>
<td class="numeric">{{ entry.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ alert('Bestehende Eintraege werden nach ihrem natuerlichen Schluessel abgeglichen
(Name, Hostname, MAC, Benutzername) und aktualisiert. Eintraege, die im Papierkorb
liegen, werden nicht wiederbelebt.', 'info') }}
</div>
<div class="card__footer modal__footer--split">
<button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
<button class="btn btn--primary" type="submit"
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen? Bestehende Daten werden aktualisiert."
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
data-confirm-style="primary">Uebernehmen</button>
</div>
</form>
</section>
{% else %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('backup.import_preview') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<input class="input" type="file" name="backup_file" accept=".json" required>
{{ field('passphrase', 'Passphrase der Datei', type='password', required=True,
autocomplete='off') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
Datei oeffnen
</button>
</div>
</form>
</section>
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Was gesichert wird</div></div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm muted">
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehoeren zur Maschine, nicht zur Sicherung.</li>
<li>Sitzungen, Ratenbegrenzung und das Aenderungsprotokoll sind nicht Teil der Sicherung.</li>
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
</ul>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,160 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert, pill %}
{% set is_switch = target.kind == 'switch' %}
{% set back_url = url_for(target.back_endpoint, switch_id=target.id) if is_switch
else url_for(target.back_endpoint, device_id=target.id) %}
{% block page_title %}Konsole {{ target.label }}{% endblock %}
{% block page_sub %}{{ target.host }}:{{ target.port }} &middot; Sitzung wird mitgeschrieben{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('switches.overview') if is_switch else url_for('devices.overview') }}">{{ target.back_label }}</a></span>
<span><a href="{{ back_url }}">{{ target.label }}</a></span>
<span>Konsole</span>
</nav>
{% endblock %}
{% block content %}
{% if not host_key_ready %}
{{ alert('Fuer dieses Ziel ist kein SSH-Host-Schluessel freigegeben. Die Konsole verweigert
den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.',
'danger', 'Host-Schluessel fehlt') }}
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schluessel freigeben</a></p>
{% endif %}
{{ alert('Die Konsole ist zeilenorientiert: ein Kommando pro Zeile, Antwort darunter. Jede
Eingabe und jede Ausgabe wird auf dem Server mitgeschrieben und im Aenderungsprotokoll
vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
'warning', 'Mitschrift') }}
{% if not is_switch %}
{{ alert('Jedes Kommando laeuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
auf das naechste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
hier nicht. Fuer solche Arbeiten einen richtigen SSH-Client nehmen.', 'info',
'Ein Kommando, eine Sitzung') }}
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('terminal', size=17) }} {{ target.label }}
<span id="console-state">{{ pill('verbinde ...', 'warning') }}</span>
</div>
<div class="card__sub" id="console-hint">Warte auf Verbindung</div>
</div>
<button class="btn btn--sm btn--ghost" type="button" data-copy="#console-output">
{{ icon('copy', size=14) }} Mitschrift kopieren
</button>
</div>
<pre class="log-view" id="console-output" style="max-height:56dvh"></pre>
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
<select class="select" id="console-preset" style="max-width:280px">
<option value="">Kommando aus der Liste ...</option>
{% for command, label in presets %}
<option value="{{ command }}">{{ label }}</option>
{% endfor %}
</select>
<input class="input input--mono" id="console-input" placeholder="Kommando eingeben und Enter"
autocomplete="off" spellcheck="false" style="flex:1" {% if not host_key_ready %}disabled{% endif %}>
<button class="btn btn--primary" type="button" id="console-send"
{% if not host_key_ready %}disabled{% endif %}>Senden</button>
</div>
</section>
<p class="fs-xs faint">
Pfeiltasten hoch/runter blättern durch die letzten Eingaben. <kbd>exit</kbd> beendet die Sitzung.
</p>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
const output = document.getElementById("console-output");
const input = document.getElementById("console-input");
const send = document.getElementById("console-send");
const preset = document.getElementById("console-preset");
const state = document.getElementById("console-state");
const hint = document.getElementById("console-hint");
const history = [];
let historyIndex = -1;
function append(text, kind = "") {
const line = document.createElement("span");
if (kind === "command") line.className = "accent-text";
if (kind === "error") line.className = "danger-text";
if (kind === "info") line.className = "warning-text";
line.textContent = text.endsWith("\n") ? text : text + "\n";
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function setState(label, kind) {
state.innerHTML = "";
const pill = document.createElement("span");
pill.className = "pill" + (kind ? " pill--" + kind : "");
pill.textContent = label;
state.appendChild(pill);
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${scheme}//${location.host}{{ websocket_path }}`);
socket.addEventListener("open", () => {
setState("verbunden", "online");
hint.textContent = "Bereit";
});
socket.addEventListener("close", () => {
setState("getrennt", "offline");
hint.textContent = "Die Verbindung ist beendet.";
input.disabled = true;
send.disabled = true;
});
socket.addEventListener("error", () => {
setState("Fehler", "danger");
});
socket.addEventListener("message", (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
if (message.type === "output") append(message.data);
else if (message.type === "error") { append(message.data, "error"); setState("Fehler", "danger"); }
else if (message.type === "info") append(message.data, "info");
else if (message.type === "ready") { append(message.data, "info"); }
else if (message.type === "done") { hint.textContent = "Antwort in " + message.data; }
});
function submit() {
const command = input.value.trim();
if (!command || socket.readyState !== WebSocket.OPEN) return;
append("$ " + command, "command");
socket.send(JSON.stringify({ command }));
history.push(command);
historyIndex = history.length;
input.value = "";
hint.textContent = "Warte auf Antwort ...";
}
send.addEventListener("click", submit);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); submit(); }
else if (event.key === "ArrowUp" && history.length) {
event.preventDefault();
historyIndex = Math.max(0, historyIndex - 1);
input.value = history[historyIndex] ?? "";
} else if (event.key === "ArrowDown" && history.length) {
event.preventDefault();
historyIndex = Math.min(history.length, historyIndex + 1);
input.value = history[historyIndex] ?? "";
}
});
preset.addEventListener("change", () => {
if (preset.value) { input.value = preset.value; input.focus(); preset.value = ""; }
});
input.focus();
</script>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block page_title %}{{ entry.name }}{% endblock %}
{% block page_sub %}Klartextanzeige &ndash; dieser Zugriff wurde protokolliert{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('credentials.overview') }}">Zugangsdaten</a></span><span>{{ entry.name }}</span>
</nav>
{% endblock %}
{% block content %}
{{ alert('Dieser Aufruf steht mit Zeitpunkt, Benutzer und IP im Aenderungsprotokoll.
Schliessen Sie die Seite, sobald Sie den Wert uebernommen haben.', 'warning') }}
<section class="card" style="max-width:560px">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} {{ entry.name }}</div>
<div class="card__sub">{{ entry.category }}</div></div>
</div>
<div class="card__body stack">
<label class="field">
<span class="field__label">Benutzer</span>
<div class="input-group">
<input class="input input--mono" id="reveal-user" value="{{ username }}" readonly>
<button class="btn" type="button" data-copy="#reveal-user">{{ icon('copy', size=15) }}</button>
</div>
</label>
<label class="field">
<span class="field__label">Passwort</span>
<div class="input-group">
<input class="input input--mono" id="reveal-secret" value="{{ secret }}" readonly>
<button class="btn" type="button" data-copy="#reveal-secret">{{ icon('copy', size=15) }}</button>
</div>
</label>
</div>
<div class="card__footer">
<a class="btn btn--primary" href="{{ url_for('credentials.overview') }}">Fertig</a>
</div>
</section>
{% endblock %}
@@ -0,0 +1,138 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
empty_state, alert %}
{% macro credential_form(entry=None) %}
<div class="form-grid">
{{ field('name', 'Name', value=entry.name if entry else '', required=True,
hint_text='Frei waehlbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }}
{{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }}
{{ field('secret', 'Passwort' ~ (' (leer lassen = unveraendert)' if entry else ''),
type='password', autocomplete='new-password',
required=False if entry else True) }}
<label class="field">
<span class="field__label">Kategorie</span>
<select class="select" name="category">
{% for value, label in categories %}
<option value="{{ value }}" {% if entry and entry.category == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
{{ textarea_field('description', 'Beschreibung', value=entry.description if entry else '',
rows=2, extra_class='field--full') }}
</div>
{% endmacro %}
{% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}{{ credentials | length }} Eintraege &middot; verschluesselt mit AES-256-GCM{% endblock %}
{% block topbar_extra %}
{{ search_box('credentials-table', 'Name, Benutzer ...') }}
{% if current_user.has('credentials.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="credential-new">
{{ icon('plus', size=15) }} Anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
{{ alert('Passwoerter liegen AES-256-GCM-verschluesselt und an ihren Verwendungszweck gebunden.
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestaetigung und wird
protokolliert. Die Kategorie setzen Sie ausdruecklich -- sie wird bewusst nicht geraten, weil
davon abhaengt, welche Geraete auf der Wartungsseite erscheinen.', 'info') }}
<section class="card">
<div class="table-wrap">
<table class="table" id="credentials-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Name</th>
<th class="sortable">Benutzer</th>
<th class="sortable">Kategorie</th>
<th class="sortable numeric">Verwendet von</th>
<th>Passwort</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for entry in credentials %}
<tr data-filter-text="{{ entry.name }} {{ entry.username }} {{ entry.category }} {{ entry.description }}">
<td>
<div class="fw-600">{{ entry.name }}</div>
{% if entry.description %}<div class="fs-xs faint">{{ entry.description }}</div>{% endif %}
</td>
<td class="mono fs-sm">{{ entry.username or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ dict(categories).get(entry.category, entry.category) }}</td>
<td class="numeric">{{ entry.in_use }}</td>
<td>
{% if entry.has_secret %}{{ pill('gesetzt', 'online') }}
{% else %}{{ pill('fehlt', 'warning') }}{% endif %}
</td>
<td class="actions">
{% if current_user.has('credentials.secrets') and entry.has_secret %}
<form method="post" action="{{ url_for('credentials.reveal', credential_id=entry.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit" aria-label="Klartext anzeigen"
data-confirm="Passwort von {{ entry.name }} im Klartext anzeigen? Dieser Zugriff wird protokolliert."
data-confirm-title="Klartext anzeigen" data-confirm-ok="Anzeigen"
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
</form>
{% endif %}
{% if current_user.has('credentials.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
</button>
{% endif %}
{% if current_user.has('credentials.delete') %}
<form method="post" action="{{ url_for('credentials.delete', credential_id=entry.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Eintraegen verwendet"{% endif %}
data-confirm="{{ entry.name }} in den Papierkorb verschieben?"
data-confirm-title="Zugangsdaten loeschen" data-confirm-ok="Loeschen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not credentials %}
{{ empty_state('Keine Zugangsdaten', 'Legen Sie den ersten Eintrag an.', 'key') }}
{% endif %}
</section>
{% endblock %}
{% block dialogs %}
{% if current_user.has('credentials.create') %}
{% call modal('credential-new', 'Zugangsdaten anlegen') %}
<form method="post" action="{{ url_for('credentials.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ credential_form() }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% if current_user.has('credentials.edit') %}
{% for entry in credentials %}
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ credential_form(entry) }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import search_box %}
{% block page_title %}Statusuebersicht{% endblock %}
{% block page_sub %}
{{ counts.total }} Geraete &middot; Pruefintervall {{ interval }} s
{% if not detailed %}&middot; oeffentliche Ansicht{% endif %}
{% endblock %}
{% block topbar_extra %}
{{ search_box('tile-sections', 'Geraet suchen ...') }}
{% if current_user.has('settings_system.execute') %}
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" title="Sofort pruefen">
{{ icon('refresh', size=15) }} Jetzt pruefen
</button>
</form>
{% endif %}
{% endblock %}
{% block content %}
<div id="dashboard-tiles" data-refresh-url="{{ url_for('dashboard.tiles') }}"
data-refresh-interval="20000">
{% include "tesm/_dashboard_tiles.html" %}
</div>
{% endblock %}
@@ -0,0 +1,244 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
{% block page_title %}{{ device.name }}{% endblock %}
{% block page_sub %}{{ device.ip }} &middot; {{ device.mac }}{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span><span>{{ device.name }}</span>
</nav>
{% endblock %}
{% block topbar_extra %}
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %}
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=15) }} Konsole
</a>
{% endif %}
{% if current_user.has('devices.execute') and restart_methods %}
{% for method in restart_methods %}
<form method="post" style="display:inline"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="method" value="{{ method.key }}">
<button class="btn btn--sm {{ 'btn--primary' if loop.first else 'btn--ghost' }}"
type="submit" title="{{ method.hint }}" data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ method.label }} neu starten? {{ method.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=15) }} {{ method.label }}
</button>
</form>
{% endfor %}
{% endif %}
{% endblock %}
{% block content %}
{% if device.deleted_at %}
{{ alert('Dieses Geraet liegt im Papierkorb (geloescht am ' ~ (device.deleted_at | dt) ~ ').',
'warning') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('activity', size=17) }} Zustand
{% if not device.is_active %}{{ pill('deaktiviert', '') }}
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
</div>
</div>
</div>
<div class="card__body">
<div class="grid grid--4">
<div class="stat">
<span class="stat__label">Antwortzeit</span>
<span class="stat__value">{{ '%.1f' | format(device.latency_ms) if device.latency_ms else '&ndash;' | safe }}</span>
<span class="stat__hint">Millisekunden</span>
</div>
<div class="stat {{ 'stat--danger' if device.fail_count else '' }}">
<span class="stat__label">Fehlversuche</span>
<span class="stat__value">{{ device.fail_count or 0 }}</span>
<span class="stat__hint">in Folge</span>
</div>
<div class="stat">
<span class="stat__label">Neustarts</span>
<span class="stat__value">{{ device.restart_count or 0 }}</span>
<span class="stat__hint">insgesamt</span>
</div>
<div class="stat">
<span class="stat__label">Zuletzt erreichbar</span>
<span class="stat__value fs-lg">
{% if device.last_ok_at %}
<span data-relative-time="{{ device.last_ok_at }}">{{ device.last_ok_at | dt }}</span>
{% else %}&ndash;{% endif %}
</span>
</div>
</div>
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
<div class="card__sub">
Die letzten {{ events | length }} Neustarts -- ueber den PoE-Port wie per SSH
</div>
</div>
</div>
{% if events %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Zeit</th><th>Methode</th><th>Ausloeser</th><th>Ergebnis</th><th class="numeric">Dauer</th><th>Detail</th></tr></thead>
<tbody>
{% for event in events %}
<tr>
<td class="fs-sm nowrap">{{ event.ts | dt }}</td>
<td class="fs-sm">
{% if event.method == 'ssh' %}{{ pill('SSH', 'info') }}
{% else %}{{ pill('PoE-Port', '') }}{% endif %}
</td>
<td class="fs-sm">{{ event.trigger }} <span class="faint">/ {{ event.actor }}</span></td>
<td>
{% if event.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
{% elif event.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }}
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td>
<td class="numeric fs-sm">{{ event.duration_ms or 0 }} ms</td>
<td class="fs-xs muted break-all">{{ event.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Noch keine Neustarts', 'Dieses Geraet wurde noch nie neu gestartet.', 'power') }}
{% endif %}
</section>
<section class="card {{ '' if host_keys else 'card--danger' }}">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} SSH-Host-Schluessel
{% if host_keys %}{{ pill('freigegeben', 'online') }}
{% else %}{{ pill('offen', 'warning') }}{% endif %}
</div>
<div class="card__sub">
Ohne freigegebenen Schluessel verweigern Wartung und Konsole die Verbindung.
</div>
</div>
{% if current_user.has('devices.edit') %}
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" data-busy-label="Lese ...">
{{ icon('refresh', size=14) }} Auslesen
</button>
</form>
{% endif %}
</div>
<div class="card__body stack">
{% set probed_type = request.args.get('probed_type') %}
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<div class="alert__title">Gelesener Schluessel</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsole am
Geraet, Inventardokumentation), bevor Sie ihn freigeben.
</p>
<form method="post" class="mt-2"
action="{{ url_for('devices.trust_host_key', device_id=device.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="key_type" value="{{ probed_type }}">
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
data-confirm-style="primary">Freigeben</button>
</form>
</div>
</div>
{% endif %}
{% if host_keys %}
<dl class="kv fs-sm">
{% for key in host_keys %}
<dt>{{ key.key_type }}</dt>
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
{% endfor %}
</dl>
{% if current_user.has('devices.edit') %}
<form method="post"
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Gespeicherte Host-Schluessel dieses Geraets entfernen? Danach muss der Schluessel erneut freigegeben werden."
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
Verwerfen
</button>
</form>
{% endif %}
{% else %}
<p class="muted fs-sm">
Noch kein Schluessel hinterlegt. Erst lesen, Fingerprint pruefen, dann freigeben.
</p>
{% endif %}
</div>
</section>
</div>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
{% if current_user.has('devices.edit') %}
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
{% endif %}
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Name</dt><dd>{{ device.name }}</dd>
<dt>IP</dt><dd class="mono">{{ device.ip }}</dd>
<dt>MAC</dt><dd class="mono">{{ device.mac }}</dd>
<dt>Switch</dt>
<dd>
{% if device.switch_hostname %}
<a href="{{ url_for('switches.detail', switch_id=device.switch_id) }}">{{ device.switch_hostname }}</a>
/ {{ device.port }}
{% else %}<span class="faint">nicht zugeordnet</span>{% endif %}
</dd>
<dt>Kategorie</dt><dd>{{ device.category or '&ndash;' | safe }}</dd>
<dt>Standort</dt><dd>{{ device.location or '&ndash;' | safe }}</dd>
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
<dt>Geaendert</dt>
<dd>{{ device.updated_at | dt }} <span class="faint">von {{ device.updated_by or '-' }}</span></dd>
</dl>
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
</div>
{% if current_user.has('devices.edit') %}
<div class="card__footer">
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="active" value="{{ '0' if device.is_active else '1' }}">
<button class="btn btn--sm" type="submit">
{{ 'Ueberwachung aussetzen' if device.is_active else 'Ueberwachung aktivieren' }}
</button>
</form>
</div>
{% endif %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,279 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, textarea_field, checkbox, pill,
search_box, modal, empty_state, alert %}
{# Neustart-Bedienelement.
Ein einziger moeglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
fuer den bevorzugten Weg (PoE, weil er auch bei haengendem Betriebssystem
wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der
verlaesslich nichts bewirkt, ist schlimmer als kein Knopf. #}
{% macro restart_control(device, methods, compact=False) %}
{% if methods %}
{% set first = methods[0] %}
<form method="post" class="row row--tight" style="display:inline-flex"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="method" value="{{ first.key }}">
<button class="btn btn--sm{{ ' btn--ghost' if compact else '' }}" type="submit"
title="{{ first.hint }}" data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ first.label }} neu starten? {{ first.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ icon('power', size=15) }}{% if not compact %} {{ first.label }}{% endif %}
</button>
</form>
{% if methods | length > 1 %}
{% for other in methods[1:] %}
<form method="post" style="display:inline"
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="method" value="{{ other.key }}">
<button class="btn btn--sm btn--ghost" type="submit" title="{{ other.hint }}"
data-busy-label="Startet ..."
data-confirm="{{ device.name }} ueber {{ other.label }} neu starten? {{ other.hint }}"
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
data-confirm-style="primary">
{{ other.label }}
</button>
</form>
{% endfor %}
{% endif %}
{% endif %}
{% endmacro %}
{% macro device_form(device=None, scope='neu') %}
<div class="form-grid">
{{ field('name', 'Name', value=device.name if device else '', required=True) }}
{{ field('mac', 'MAC-Adresse', value=device.mac if device else '', required=True,
placeholder='aa:bb:cc:dd:ee:ff', extra_class='') }}
{{ field('ip', 'IP-Adresse', value=device.ip if device else '', required=True,
placeholder='192.168.1.50') }}
<label class="field">
<span class="field__label">Switch</span>
<select class="select" name="switch_id">
<option value="">kein Switch</option>
{% for switch in switches %}
<option value="{{ switch.id }}"
{% if device and device.switch_id == switch.id %}selected{% endif %}>
{{ switch.hostname }} ({{ switch.ip }})
</option>
{% endfor %}
</select>
</label>
{{ field('port', 'Port am Switch', value=device.port if device else '',
placeholder='1 oder 1/1/3',
hint_text='Pflicht, sobald ein Switch zugeordnet ist.') }}
<label class="field">
<span class="field__label">Zugangsdaten (fuer Wartung)</span>
<select class="select" name="credential_id" data-behavior="toggle-target"
data-toggle-value="__neu__" data-toggle-target="#new-credential-{{ scope }}">
<option value="">keine</option>
{% for credential in credentials %}
<option value="{{ credential.id }}"
{% if device and device.credential_id == credential.id %}selected{% endif %}>
{{ credential.name }} ({{ category_labels.get(credential.category, credential.category) }})
</option>
{% endfor %}
{% if current_user.has('credentials.create') %}
<option value="__neu__">-- neue Zugangsdaten anlegen --</option>
{% endif %}
</select>
</label>
{% if current_user.has('credentials.create') %}
<fieldset class="field field--full" id="new-credential-{{ scope }}" hidden>
<legend class="field__label">Neue Zugangsdaten</legend>
<div class="form-grid">
{{ field('new_credential_name', 'Bezeichnung', placeholder='z. B. Wartung Linux',
hint_text='Der Eintrag wird zusaetzlich unter Zugangsdaten gespeichert und
ist danach fuer weitere Geraete verwendbar.') }}
{{ field('new_credential_username', 'Benutzer') }}
{{ field('new_credential_secret', 'Passwort', type='password') }}
<label class="field">
<span class="field__label">Kategorie</span>
<select class="select" name="new_credential_category">
{% for value, label in credential_categories %}
<option value="{{ value }}" {% if value == 'linux' %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<span class="field__hint" id="category-effect-{{ scope }}"></span>
</label>
</div>
</fieldset>
{% endif %}
{{ field('ssh_port', 'SSH-Port des Geraets', value=device.ssh_port if device else 22, type='number') }}
<label class="field">
<span class="field__label">Kategorie</span>
<select class="select" name="category">
{% for value, label in categories %}
<option value="{{ value }}" {% if device and device.category == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
{{ field('location', 'Standort', value=device.location if device else '') }}
{{ textarea_field('notes', 'Notizen', value=device.notes if device else '', rows=2,
extra_class='field--full') }}
</div>
<div class="stack stack--tight">
{{ checkbox('is_active', 'Geraet wird ueberwacht', checked=device.is_active if device else True) }}
{{ checkbox('auto_restart', 'Automatischer PoE-Neustart bei Ausfall',
checked=device.auto_restart if device else True,
hint_text='Greift nur mit gueltiger Lizenz und zugeordnetem Switch-Port.') }}
</div>
{% endmacro %}
{% block page_title %}Clients{% endblock %}
{% block page_sub %}
{{ devices | length }} Geraete &middot; {{ counts.online }} erreichbar, {{ counts.offline }} nicht erreichbar
{% endblock %}
{% block topbar_extra %}
{{ search_box('devices-table', 'Name, IP, MAC, Switch ...', count_id='devices-visible') }}
{% if current_user.has('devices.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="device-new">
{{ icon('plus', size=15) }} Geraet anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
<section class="card">
<div class="table-toolbar">
<span class="muted fs-sm"><span id="devices-visible">{{ devices | length }}</span> sichtbar</span>
<span class="table-toolbar__spacer"></span>
<a class="btn btn--sm btn--ghost" href="{{ url_for('tesmlogs.restarts') }}">
{{ icon('power', size=15) }} Neustarts
</a>
</div>
<div class="table-wrap">
<table class="table" id="devices-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Name</th>
<th class="sortable">Zustand</th>
<th class="sortable">IP</th>
<th class="sortable">MAC</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable">Switch / Port</th>
<th class="sortable">Letzte Pruefung</th>
<th class="sortable">Host-Schluessel</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr data-filter-text="{{ device.name }} {{ device.ip }} {{ device.mac }} {{ device.switch_hostname or '' }} {{ device.location }} {{ device.category }}">
<td>
<a class="fw-600" href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
{% if not device.is_active %}{{ pill('deaktiviert', '') }}{% endif %}
{% if not device.auto_restart %}
<span class="hint-icon" title="Automatischer PoE-Neustart ist fuer dieses Geraet abgeschaltet">!</span>
{% endif %}
{% if device.location %}<div class="fs-xs faint">{{ device.location }}</div>{% endif %}
</td>
<td data-sort-value="{{ device.state }}">
{% if not device.is_active %}{{ pill('aus', '') }}
{% elif device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}&times;)</span>{% endif %}
</td>
<td class="mono fs-sm">{{ device.ip }}</td>
<td class="mono fs-sm">{{ device.mac }}</td>
<td class="fs-sm">
{% if device.credential_name %}
{{ device.credential_name }}
<div class="fs-xs faint">
{{ category_labels.get(device.credential_category, device.credential_category) }}
</div>
{% else %}<span class="faint">keine</span>{% endif %}
</td>
<td class="fs-sm">
{% if device.switch_hostname %}
{{ device.switch_hostname }} <span class="faint">/</span> {{ device.port }}
{% else %}<span class="faint">nicht zugeordnet</span>{% endif %}
</td>
<td class="fs-sm" data-sort-value="{{ device.last_check_at or '' }}">
{% if device.last_check_at %}
<span data-relative-time="{{ device.last_check_at }}">{{ device.last_check_at | dt }}</span>
{% else %}<span class="faint">nie</span>{% endif %}
</td>
<td data-sort-value="{{ '1' if device.host_key_known else '0' }}">
{% if device.credential_category == 'windows' %}
<span class="faint fs-xs" title="Windows wird nicht ueber SSH verwaltet">
nicht noetig
</span>
{% elif device.host_key_known %}{{ pill('freigegeben', 'online') }}
{% else %}
<a href="{{ url_for('hostkeys.overview') }}"
title="Ohne Freigabe verweigern Wartung und Konsole die Verbindung">
{{ pill('offen', 'warning') }}
</a>
{% endif %}
</td>
<td class="actions">
{% if current_user.has('devices.execute') %}
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
{% endif %}
{% if current_user.has('devices.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
</button>
{% endif %}
{% if current_user.has('devices.delete') %}
<form method="post" action="{{ url_for('devices.delete', device_id=device.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
data-confirm="{{ device.name }} in den Papierkorb verschieben? Die MAC bleibt dabei reserviert."
data-confirm-title="Geraet loeschen" data-confirm-ok="Loeschen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not devices %}
{{ empty_state('Keine Geraete', 'Legen Sie das erste ueberwachte Geraet an.', 'cpu') }}
{% endif %}
</section>
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart ueber SSH oder
RPC setzt passende Zugangsdaten voraus. Ist keine Methode moeglich, erscheint der Knopf
nicht -- damit kein Neustart vorgetaeuscht wird, der nichts bewirkt.', 'info') }}
{% endblock %}
{% block dialogs %}
{% if current_user.has('devices.create') %}
{% call modal('device-new', 'Geraet anlegen', 'MAC und IP sind Pflicht', 'wide') %}
<form method="post" action="{{ url_for('devices.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ device_form(scope='neu') }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% if current_user.has('devices.edit') %}
{% for device in devices %}
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ device_form(device, scope=device.id) }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}
+446
View File
@@ -0,0 +1,446 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, pill, empty_state, alert, search_box %}
{% block page_title %}DHCP-Server{% endblock %}
{% block page_sub %}
Kea &middot; {{ subnets | length }} Subnetze &middot; {{ reservations | length }} Reservierungen
{% endblock %}
{% block content %}
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Konfiguration und Dienstzustand koennen hier nur
angezeigt, nicht geschrieben werden.', 'warning') }}
{% endif %}
<div class="grid grid--4">
<div class="stat {{ 'stat--ok' if service.get('active') == 'active' else 'stat--warning' }}">
<span class="stat__label">Dienst</span>
<span class="stat__value fs-lg">{{ service.get('active', 'unbekannt') }}</span>
<span class="stat__hint">Autostart: {{ service.get('enabled', '?') }}</span>
</div>
<div class="stat">
<span class="stat__label">Programm</span>
<span class="stat__value fs-lg">{{ service.get('binary', '?') }}</span>
<span class="stat__hint">kea-dhcp4</span>
</div>
<div class="stat">
<span class="stat__label">Reservierungen</span>
<span class="stat__value">{{ reservations | length }}</span>
<span class="stat__hint">automatisch + manuell</span>
</div>
<div class="stat">
<span class="stat__label">Leases</span>
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
<span class="stat__hint">
gueltig{% if leases | selectattr('is_expired') | list %},
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
</span>
</div>
</div>
{% if current_user.has('dhcp.execute') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('power', size=17) }} Dienst</div>
<div class="card__sub">
Bewusst getrennt vom Speichern: eine abgelegte Konfiguration startet keinen Dienst.
</div>
</div>
</div>
<div class="card__body row row--tight">
{% for action, label, style, question in [
('install', 'Kea installieren', '', 'Kea-DHCP-Server per apt installieren?'),
('start', 'Aktivieren und starten', 'primary', 'Dienst aktivieren und starten? Er verteilt danach IP-Adressen im Netz.'),
('stop', 'Stoppen und deaktivieren', 'danger', 'Dienst stoppen und deaktivieren? Clients erhalten danach keine Adressen mehr.')] %}
<form method="post" action="{{ url_for('dhcp.service_action', action=action) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm {{ 'btn--' ~ style if style }}" type="submit"
data-busy-label="Arbeitet ..." data-confirm="{{ question }}"
data-confirm-title="{{ label }}" data-confirm-ok="Ausfuehren"
data-confirm-style="{{ style or 'primary' }}">{{ label }}</button>
</form>
{% endfor %}
</div>
</section>
{% endif %}
{# -- Subnetze ---------------------------------------------------------- #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
<div class="card__sub">
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich.
</div>
</div>
</div>
{% if subnets %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Interface</th><th>Bereich</th><th>Gateway</th><th>DNS</th><th>Aktiv</th><th class="actions"></th></tr></thead>
<tbody>
{% for subnet in subnets %}
<tr>
<td class="mono fs-sm">{{ subnet.interface }}</td>
<td class="mono fs-sm">{{ subnet.range_start }} &ndash; {{ subnet.range_end }}</td>
<td class="mono fs-sm">{{ subnet.gateway or '&ndash;' | safe }}</td>
<td class="mono fs-sm">{{ subnet.dns or '&ndash;' | safe }}</td>
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
<td class="actions">
{% if current_user.has('dhcp.edit') %}
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Subnetz {{ subnet.range_start }}-{{ subnet.range_end }} entfernen?"
data-confirm-title="Subnetz entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
{% endif %}
{% if current_user.has('dhcp.edit') %}
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
<label class="field">
<span class="field__label">Interface</span>
<select class="select" name="interface" required>
{% for network in networks %}
<option value="{{ network.interface }}">{{ network.interface }} ({{ network.cidr }})</option>
{% endfor %}
</select>
</label>
{{ field('range_start', 'Von', required=True, placeholder='192.168.1.100') }}
{{ field('range_end', 'Bis', required=True, placeholder='192.168.1.200') }}
{{ field('gateway', 'Gateway', value=default_gateway) }}
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
{{ field('comment', 'Kommentar') }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Subnetz anlegen</button></div>
</form>
{% endif %}
</section>
{# -- Globale Einstellungen --------------------------------------------- #}
{% if current_user.has('dhcp.edit') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
</div>
<form method="post" action="{{ url_for('dhcp.save_settings') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('dhcp_domain', 'Domainname', value=values.dhcp_domain) }}
{{ field('dhcp_lease_default', 'Lease-Dauer (s)', value=values.dhcp_lease_default, type='number') }}
{{ field('dhcp_lease_max', 'Maximale Lease-Dauer (s)', value=values.dhcp_lease_max, type='number') }}
{{ field('dhcp_output_path', 'Zielpfad der Konfiguration', value=values.dhcp_output_path,
extra_class='field--full') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern und schreiben</button>
</div>
</form>
</section>
{% endif %}
{# -- Optionen ---------------------------------------------------------- #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} DHCP-Optionen</div>
<div class="card__sub">
{{ options | length }} Optionen &middot; Standardoptionen sind nicht loeschbar, ihr Wert
laesst sich leeren
</div>
</div>
{{ search_box('options-table', 'Option suchen ...') }}
</div>
<div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact" id="options-table">
<thead><tr><th class="numeric">Code</th><th>Name</th><th>Typ</th><th>Wert</th><th class="actions"></th></tr></thead>
<tbody>
{% for option in options %}
<tr data-filter-text="{{ option.code }} {{ option.name }} {{ option.description }}">
<td class="numeric fs-sm">{{ option.code }}</td>
<td class="fs-sm">
<div class="mono">{{ option.name }}</div>
<div class="fs-xs faint">{{ option.description }}</div>
</td>
<td class="fs-xs mono">{{ option.type }}</td>
<td>
{% if current_user.has('dhcp.edit') %}
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="option_id" value="{{ option.id }}">
<input class="input input--mono" type="text" name="value" value="{{ option.value or '' }}"
style="min-width:180px" placeholder="leer = nicht senden">
<button class="btn btn--sm" type="submit">{{ icon('check', size=14) }}</button>
</form>
{% else %}
<span class="mono fs-sm">{{ option.value or '&ndash;' | safe }}</span>
{% endif %}
</td>
<td class="actions">
{% if current_user.has('dhcp.edit') and not option.is_standard %}
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Option {{ option.name }} loeschen?"
data-confirm-title="Option loeschen" data-confirm-ok="Loeschen">
{{ icon('trash', size=14) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if current_user.has('dhcp.edit') %}
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('code', 'Code', type='number', required=True) }}
{{ field('name', 'Name', required=True, placeholder='meine-option') }}
<label class="field">
<span class="field__label">Typ</span>
<select class="select" name="type">
{% for option_type in option_types %}<option value="{{ option_type }}">{{ option_type }}</option>{% endfor %}
</select>
</label>
{{ field('description', 'Beschreibung') }}
</div>
</div>
<div class="card__footer"><button class="btn" type="submit">Eigene Option anlegen</button></div>
</form>
{# Der Wert einer Option gilt sonst fuer alle. Genau dafuer gibt es
``dhcp_option_values.device_id``: ein eigener Wert je Geraet, der in
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
Geraet anders lautet. Auswertbar war das von Anfang an, eingeben liess
es sich nicht. #}
<div class="card__header" style="border-top:1px solid var(--border-subtle)">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Option nur fuer ein Geraet</div>
<div class="card__sub">
Der Wert landet in der Reservierung dieses Geraets und ueberschreibt dort den
globalen Wert. Leeres Feld entfernt ihn wieder.
</div>
</div>
</div>
<form method="post" action="{{ url_for('dhcp.set_device_option_value') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
<label class="field">
<span class="field__label">Geraet<span class="req" aria-hidden="true">*</span></span>
<select class="select" name="device_id" required>
{% for reservation in reservations if reservation.device_id %}
<option value="{{ reservation.device_id }}">
{{ reservation.name or reservation.hostname }} &mdash; {{ reservation.ip }}
</option>
{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Option<span class="req" aria-hidden="true">*</span></span>
<select class="select" name="option_id" required>
{% for option in options %}
<option value="{{ option.id }}">{{ option.code }} &mdash; {{ option.name }}</option>
{% endfor %}
</select>
</label>
{{ field('value', 'Wert', extra_class='field--full',
hint_text='Leer = Wert fuer dieses Geraet entfernen') }}
</div>
</div>
<div class="card__footer">
<button class="btn" type="submit">Fuer dieses Geraet speichern</button>
</div>
</form>
{% endif %}
</section>
{# -- Reservierungen und Leases ----------------------------------------- #}
<div class="grid grid--2">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('credit-card', size=17) }} Reservierungen</div>
<div class="card__sub">
Automatisch aus dem Geraetebestand; bei MAC-Kollision gewinnt der Geraetebestand.
Nur Adressen aus eingerichteten Bereichen.
</div>
</div>
</div>
{% if skipped_reservations %}
{# Nicht verschweigen, sondern benennen: vorher standen diese Eintraege in
der Liste, obwohl Kea sie nie ausgeliefert hat. #}
<div class="card__body pt-0">
{{ alert(skipped_reservations | length ~ ' Geraet(e) liegen in keinem eingerichteten
Bereich und bekommen keine Reservierung. Legen Sie den passenden Bereich an, wenn sie
eine bekommen sollen.', 'warning') }}
<details class="fs-xs faint">
<summary>Welche?</summary>
<ul class="mt-1">
{% for entry in skipped_reservations %}
<li><span class="mono">{{ entry.ip }}</span> &ndash; {{ entry.name }}
({{ entry.source }})</li>
{% endfor %}
</ul>
</details>
</div>
{% endif %}
<div class="table-wrap" style="max-height:360px;overflow:auto">
<table class="table table--compact">
<thead><tr><th>MAC</th><th>IP</th><th>Hostname</th><th>Zustand</th><th>Quelle</th>
<th>Eigene Optionen</th><th class="actions"></th></tr></thead>
<tbody>
{% for reservation in reservations %}
<tr>
<td class="mono fs-sm">{{ reservation.mac }}</td>
<td class="mono fs-sm">{{ reservation.ip }}</td>
<td class="fs-sm">{{ reservation.hostname }}</td>
<td title="{{ reservation.state_hint }}">
{{ pill(reservation.state | replace('_', ' '), reservation.state_style) }}
{% if reservation.state == 'andere_adresse' and reservation.lease %}
<div class="fs-xs faint mono">jetzt {{ reservation.lease.address }}</div>
{% elif reservation.state == 'aktiv' and reservation.lease %}
<div class="fs-xs faint">{{ reservation.lease.remaining_text }}</div>
{% endif %}
</td>
<td>{{ pill(reservation.source, 'accent' if reservation.source == 'auto' else '') }}</td>
<td class="fs-xs">
{% set eigene = device_option_values.get(reservation.device_id) if reservation.device_id else none %}
{% if eigene %}
{% for wert in eigene %}
<div class="mono break-all">{{ wert.code }} {{ wert.name }} = {{ wert.value }}</div>
{% endfor %}
{% else %}
<span class="faint">&ndash;</span>
{% endif %}
</td>
<td class="actions">
{% if reservation.source == 'manuell' and current_user.has('dhcp.edit') %}
<form method="post" data-no-guard
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Reservierung {{ reservation.mac }} entfernen?"
data-confirm-title="Reservierung entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=14) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if current_user.has('dhcp.edit') %}
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('mac', 'MAC', required=True, placeholder='aa:bb:cc:dd:ee:ff') }}
{{ field('ip', 'IP', required=True) }}
{{ field('hostname', 'Hostname') }}
{{ field('comment', 'Kommentar') }}
</div>
</div>
<div class="card__footer"><button class="btn" type="submit">Reservierung speichern</button></div>
</form>
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
<div class="card__sub">
Direkt aus {{ lease_file }} gelesen &ndash; je Adresse gilt der letzte Eintrag.
</div>
</div>
</div>
{% if leases and service.get('active') != 'active' %}
{# Sonst sieht man alte Eintraege und haelt sie fuer den aktuellen Stand:
ohne laufenden Dienst wird keine Lease erneuert, und die Datei
veraltet still. #}
<div class="card__body pt-0">
{{ alert('Der DHCP-Dienst laeuft nicht (' ~ service.get('active', 'unbekannt') ~ ').
Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die
aktuelle Lage. Der Grund steht im Kea-Protokoll.', 'warning') }}
</div>
{% endif %}
{% if leases %}
<div class="table-wrap" style="max-height:420px;overflow:auto">
<table class="table table--compact">
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
<th>Laeuft ab</th></tr></thead>
<tbody>
{% for lease in leases %}
<tr>
<td class="mono fs-sm">{{ lease.address }}</td>
<td class="mono fs-sm">{{ lease.mac }}</td>
<td class="fs-sm">{{ lease.hostname or '&ndash;' | safe }}</td>
<td>
{% if lease.address in reserved_ips %}{{ pill('Reservierung', 'accent') }}
{% else %}{{ pill('dynamisch', '') }}{% endif %}
</td>
<td>
{% if lease.is_expired %}{{ pill('abgelaufen', 'warning') }}
{% elif lease.is_active %}{{ pill('gueltig', 'online') }}
{% else %}{{ pill('inaktiv', '') }}{% endif %}
</td>
<td class="fs-sm">{{ lease.expires_text }}
<div class="fs-xs faint">{{ lease.remaining_text }}</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Leases', 'Es sind keine vergebenen Adressen bekannt.', 'clock') }}
{% endif %}
</section>
</div>
{# -- Vorschau ---------------------------------------------------------- #}
<details class="card" data-persist="dhcp-preview">
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte Kea-Konfiguration</div>
<div class="card__sub">Vorschau &ndash; so wuerde die Datei geschrieben</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
{% if current_user.has('dhcp.edit') %}
<div class="card__footer">
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit">Datei jetzt schreiben</button>
</form>
</div>
{% endif %}
</details>
{% endblock %}
@@ -0,0 +1,168 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, empty_state, alert, pill, modal %}
{% block page_title %}Dateifreigaben{% endblock %}
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
{% block topbar_extra %}
{% if current_user.has('fileshare.edit') %}
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
{{ icon('settings', size=15) }} Zuordnungen
</a>
{% endif %}
{% endblock %}
{% block content %}
{% if not mounts %}
{{ empty_state('Keine Freigaben eingebunden',
'Einbindungen entstehen nach einer Anmeldung gegen den Verzeichnisdienst und richten sich
nach Ihrer Gruppenmitgliedschaft.', 'folder') }}
{% else %}
{% if error %}{{ alert(error, 'danger') }}{% endif %}
<div class="tabs">
{% for mount in mounts %}
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a>
{% endfor %}
</div>
{% if listing %}
{% set current = (mounts | selectattr('token', 'equalto', token) | list | first) %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<nav class="breadcrumb">
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span>
{% for crumb in listing.breadcrumb %}
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span>
{% endfor %}
</nav>
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
</div>
<span class="fs-xs faint">
Einbindung endet <span data-relative-time="{{ current.expires_at }}">{{ current.expires_at | dt }}</span>
</span>
{% if current_user.has('fileshare.create') %}
<button class="btn btn--sm" type="button" data-dialog-open="folder-new">
{{ icon('plus', size=14) }} Ordner
</button>
{% endif %}
</div>
<form method="post" action="{{ url_for('fileshare.delete', token=token) }}" id="entries-form">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="parent" value="{{ listing.relative }}">
<div class="table-wrap">
<table class="table table--compact">
<thead>
<tr>
{% if current_user.has('fileshare.delete') %}<th style="width:40px"></th>{% endif %}
<th>Name</th>
<th class="numeric">Groesse</th>
<th>Geaendert</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% if listing.breadcrumb %}
<tr>
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
<td colspan="4">
<a href="{{ url_for('fileshare.browse', freigabe=token,
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
{{ icon('chevron-right', size=14) }} eine Ebene hoeher
</a>
</td>
</tr>
{% endif %}
{% for entry in listing.directories %}
<tr>
{% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td>
{% endif %}
<td>
{{ icon('folder', size=15) }}
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a>
</td>
<td class="numeric faint">&ndash;</td>
<td class="fs-sm">{{ entry.modified | dt }}</td>
<td class="actions"></td>
</tr>
{% endfor %}
{% for entry in listing.files %}
<tr>
{% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswaehlen</span></label></td>
{% endif %}
<td>{{ icon('file-text', size=15) }} {{ entry.name }}</td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
<td class="fs-sm">{{ entry.modified | dt }}</td>
<td class="actions">
{% if entry.previewable %}
<a class="btn btn--sm btn--ghost" target="_blank" rel="noopener"
href="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
aria-label="Vorschau">{{ icon('eye', size=15) }}</a>
{% endif %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not listing.directories and not listing.files %}
{{ empty_state('Leeres Verzeichnis', '', 'folder') }}
{% endif %}
{% if current_user.has('fileshare.delete') %}
<div class="card__footer">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Die ausgewaehlten Eintraege endgueltig loeschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
data-confirm-title="Loeschen" data-confirm-ok="Loeschen">
Ausgewaehlte loeschen
</button>
</div>
{% endif %}
</form>
</section>
{% if current_user.has('fileshare.create') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Dateien hochladen</div>
<div class="card__sub">nach {{ listing.relative or 'Wurzelverzeichnis' }} &middot; max. {{ max_upload_mb }} MB je Datei</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('fileshare.upload', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}">
<div class="card__body row">
<input class="input" type="file" name="files" multiple required style="flex:1">
<button class="btn btn--primary" type="submit" data-busy-label="Laedt ...">Hochladen</button>
</div>
</form>
</section>
{% endif %}
{% endif %}
{% endif %}
{% endblock %}
{% block dialogs %}
{% if listing and current_user.has('fileshare.create') %}
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}">
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -0,0 +1,72 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/_group_picker.html" import group_picker %}
{% from "tesm_core/macros.html" import field, empty_state, alert %}
{% block page_title %}Freigaben einrichten{% endblock %}
{% block page_sub %}{{ mappings | length }} Zuordnungen &middot; Wurzel {{ mount_root }}{% endblock %}
{% block content %}
{{ alert('Eine Freigabe wird bei der Anmeldung eingebunden, wenn der Benutzer Mitglied der
zugeordneten Verzeichnisgruppe ist. Eingebunden wird mit dem Konto dieses Benutzers -- deshalb
ist die Verzeichnisgruppe das einzige moegliche Ziel, und lokale Konten bekommen keine
Einbindungen.', 'info') }}
{% if not directory_enabled %}
{{ alert('Die Verzeichnisanmeldung ist nicht aktiv. Ohne sie kann keine Freigabe eingebunden
werden.', 'warning') }}
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('folder', size=17) }} Zuordnungen</div></div>
</div>
{% if mappings %}
<div class="table-wrap">
<table class="table table--compact">
<thead>
<tr><th>Anzeigename</th><th>UNC-Pfad</th><th>Verzeichnisgruppe</th><th class="actions"></th></tr>
</thead>
<tbody>
{% for entry in mappings %}
<tr>
<td class="fw-600">{{ entry.label }}</td>
<td class="mono fs-sm break-all">{{ entry.unc }}</td>
<td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td class="actions">
<form method="post" action="{{ url_for('fileshare.mapping_delete', mapping_id=entry.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Zuordnung {{ entry.label }} entfernen?"
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=15) }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Zuordnungen', 'Legen Sie die erste Freigabe an.', 'folder') }}
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('plus', size=17) }} Freigabe einrichten</div></div>
</div>
<form method="post" action="{{ url_for('fileshare.mapping_add') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('label', 'Anzeigename', required=True, placeholder='Abteilung Technik') }}
{{ field('unc', 'UNC-Pfad', required=True, placeholder='\\\\dc2022\\daten',
hint_text='Schraegstriche gehen auch: //dc2022/daten') }}
{{ group_picker('ad_group_name', 'ad_group_dn', 'share', required=True,
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{% endblock %}
@@ -0,0 +1,179 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
{% block page_title %}Host-Schluessel{% endblock %}
{% block page_sub %}
{{ targets | length }} Ziele mit SSH-Zugang
{%- if open_count %} &middot; {{ open_count }} ohne Freigabe{% endif %}
{% endblock %}
{% block topbar_extra %}{{ search_box('hostkey-table', 'Geraet, IP, Fingerprint ...') }}{% endblock %}
{% block content %}
{% if open_count %}
{{ alert('Fuer ' ~ open_count ~ ' Ziel(e) ist noch kein Schluessel freigegeben. Wartung,
Konsole und PoE-Neustart brechen dort mit "Schluessel unbekannt" ab -- absichtlich: ein
Hintergrundauftrag darf keinen fremden Schluessel annehmen.', 'warning',
'Freigaben offen') }}
{% else %}
{{ alert('Fuer jedes Ziel mit SSH-Zugang liegt ein freigegebener Schluessel vor.',
'success', 'Vollstaendig') }}
{% endif %}
{{ alert('Ein Schluessel wird in zwei Schritten uebernommen: erst auslesen, dann -- nach
Vergleich mit einer unabhaengigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
aendert sich die Adresse eines Geraets, ist erneut freizugeben. Ein *geaenderter* Schluessel
wird nie stillschweigend uebernommen: das ist der Fall, der bei einem Angriff auftritt.',
'info', 'So funktioniert die Freigabe') }}
{% if probed_fingerprint %}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<div class="alert__title">Gelesener Schluessel von {{ probed_host }}:{{ probed_port }}</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle -- Konsolenzugang am
Geraet, Inventardokumentation --, bevor Sie ihn unten freigeben.
</p>
</div>
</div>
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Ziele</div>
<div class="card__sub">Ohne Freigabe zuerst</div>
</div>
</div>
<div class="table-wrap">
<table class="table" id="hostkey-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Ziel</th>
<th class="sortable">Art</th>
<th class="sortable">Adresse</th>
<th class="sortable">Zustand</th>
<th>Fingerprint</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in targets %}
{% set may_edit = current_user.has(edit_right[row.kind]) %}
<tr data-filter-text="{{ row.label }} {{ row.host }} {{ row.kind }}
{% for key in row.host_keys %}{{ key.fingerprint }} {{ key.key_type }}{% endfor %}">
<td class="fw-600">
<a href="{{ url_for(row.detail_endpoint, device_id=row.id) if row.kind == 'client'
else url_for(row.detail_endpoint, switch_id=row.id) }}">{{ row.label }}</a>
</td>
<td class="fs-sm">
{% if row.kind == 'switch' %}{{ pill('Switch', 'info') }}
{% else %}{{ pill('Client', '') }}{% endif %}
</td>
<td class="mono fs-sm nowrap">{{ row.host }}:{{ row.port }}</td>
<td data-sort-value="{{ '1' if row.trusted else '0' }}">
{% if row.trusted %}{{ pill('freigegeben', 'online') }}
{% else %}{{ pill('offen', 'danger') }}{% endif %}
</td>
<td class="fs-xs mono break-all" style="max-width:420px">
{% for key in row.host_keys %}
<div>{{ key.key_type }} {{ key.fingerprint }}</div>
{% else %}
<span class="faint">&ndash;</span>
{% endfor %}
</td>
<td class="nowrap">
{% if may_edit %}
<div class="row row--tight">
<form method="post" data-no-guard
action="{{ url_for('hostkeys.probe', kind=row.kind, target_id=row.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" data-busy-label="Lese ...">
{{ icon('refresh', size=14) }} Auslesen
</button>
</form>
{% if probed_fingerprint and probed_host == row.host
and probed_port == row.port | string %}
<form method="post"
action="{{ url_for('hostkeys.trust', kind=row.kind, target_id=row.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="key_type" value="{{ probed_type }}">
<input type="hidden" name="fingerprint" value="{{ probed_fingerprint }}">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Fingerprint {{ probed_fingerprint }} fuer {{ row.label }} dauerhaft als vertrauenswuerdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben"
data-confirm-ok="Freigeben" data-confirm-style="primary">
{{ icon('check', size=14) }} Freigeben
</button>
</form>
{% endif %}
{% if row.trusted %}
<form method="post" data-no-guard
action="{{ url_for('hostkeys.forget', kind=row.kind, target_id=row.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Freigabe fuer {{ row.label }} entfernen? Wartung und Konsole verweigern danach die Verbindung, bis erneut freigegeben wurde."
data-confirm-title="Freigabe verwerfen" data-confirm-ok="Verwerfen">
Verwerfen
</button>
</form>
{% endif %}
</div>
{% else %}
<span class="faint fs-xs">keine Berechtigung</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not targets %}
{{ empty_state('Keine Ziele', 'Legen Sie zuerst Clients oder Switche an.', 'shield') }}
{% endif %}
</section>
{% if orphans %}
<section class="card card--danger">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
<div class="card__sub">
Zu diesen Eintraegen gibt es kein Ziel mehr -- geloescht oder mit neuer IP.
</div>
</div>
</div>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Adresse</th><th>Typ</th><th>Fingerprint</th><th>Freigegeben</th><th></th></tr></thead>
<tbody>
{% for row in orphans %}
<tr>
<td class="mono fs-sm nowrap">{{ row.host }}:{{ row.port }}</td>
<td class="fs-sm">{{ row.key_type }}</td>
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
<td>
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %}
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="host" value="{{ row.host }}">
<input type="hidden" name="port" value="{{ row.port }}">
<button class="btn btn--sm btn--ghost" type="submit"
data-confirm="Verwaiste Freigabe fuer {{ row.host }}:{{ row.port }} entfernen?"
data-confirm-title="Eintrag entfernen" data-confirm-ok="Entfernen">
Entfernen
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% endblock %}
@@ -0,0 +1,312 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}
{% if state and state.valid and state.activated %}aktiv
{% elif state and state.valid %}gueltig, noch nicht aktiviert
{% elif state and state.present %}ungueltig
{% else %}keine Lizenz installiert{% endif %}
{% endblock %}
{% block content %}
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
{% for warning in (state.warnings if state else []) %}{{ alert(warning, 'warning') }}{% endfor %}
<div class="grid grid--split">
<div class="stack">
{% if state and state.valid and state.status %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('key', size=17) }} Installierte Lizenz
{% if state.activated %}{{ pill('aktiviert', 'online') }}
{% else %}{{ pill('nicht aktiviert', 'warning') }}{% endif %}
{% if state.issuer_pinned %}{{ pill('Aussteller gepinnt', 'info') }}{% endif %}
</div>
<div class="card__sub">
{{ type_labels.get(state.status.type, state.status.type) }} &middot;
{{ role_labels.get(state.role, state.role) }}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Kunde</dt><dd>{{ state.customer_name or '&ndash;' | safe }}</dd>
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
<dt>Ausgestellt</dt><dd>{{ state.status.issued_at | dt }}</dd>
<dt>Laeuft ab</dt>
<dd>
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
{{ state.status.expires_at | dt }}
{% if state.status.expired %}
{{ pill('abgelaufen seit ' ~ state.status.days_since_expiry ~ ' Tagen', 'danger') }}
{% if state.status.grace_active %}
<div class="fs-xs warning-text">
Kulanzfrist laeuft ({{ grace_days }} Tage) &ndash; Funktionen bleiben vorerst nutzbar.
</div>
{% endif %}
{% elif state.status.expiring_soon %}
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'warning') }}
{% else %}
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'online') }}
{% endif %}
{% endif %}
</dd>
<dt>Module</dt>
<dd>
{% if state.modules %}
{% for module in modules %}
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
{% endfor %}
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
</dd>
<dt>Lizenzserver</dt>
<dd class="break-all">{{ state.status.master_endpoint or 'nicht hinterlegt (nur Offline-Weg)' }}</dd>
<dt>Letzter Heartbeat</dt>
<dd>
{{ state.last_heartbeat_at | dt }}
{% if state.heartbeat_error %}
<div class="fs-xs danger-text">{{ state.heartbeat_error }}</div>
{% endif %}
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit" data-busy-label="Aktiviere ...">
Lizenz aktivieren
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('license.heartbeat') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt pruefen</button>
</form>
<form method="post" action="{{ url_for('license.deactivate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Lizenz auf diesem System deaktivieren? Lizenzpflichtige Funktionen werden abgeschaltet; danach kann die Lizenz auf einem anderen System aktiviert werden."
data-confirm-title="Lizenz deaktivieren" data-confirm-ok="Deaktivieren">
Deaktivieren (Systemwechsel)
</button>
</form>
{% endif %}
</div>
{% endif %}
</section>
{% endif %}
{% if pending %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)</div>
<div class="card__sub">
Aktion: {{ pending.action }} &middot; erzeugt {{ pending.created_at | dt }}
</div>
</div>
</div>
<div class="card__body stack">
{% if pending.reason %}
{{ alert(pending.reason, 'warning', 'Warum der Online-Weg nicht ging') }}
{% endif %}
{{ alert('Beide Codes sind signiert -- sie lassen sich unterwegs nicht veraendern.
Der Anfragecode gehoert zu genau dieser Maschine und genau dieser Anfrage; eine
Antwort auf einen anderen Code wird abgelehnt.', 'info') }}
{% if pending.code %}
<div class="field">
<span class="field__label">
Anfragecode -- an den Anbieter uebermitteln
</span>
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
<div class="row row--end mt-2">
<button class="btn btn--sm" type="button" data-copy="#offline-request">
{{ icon('copy', size=14) }} Code kopieren
</button>
</div>
</div>
{% else %}
{{ alert('Zu dieser Anfrage liegt kein Code vor -- sie stammt aus einer aelteren
Fassung. Loesen Sie die Aktion erneut aus, dann entsteht ein neuer Code.',
'warning') }}
{% endif %}
<form method="post" action="{{ url_for('license.offline_response') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Antwortcode des Lizenzservers</span>
<textarea class="textarea input--mono" name="code" rows="4" required
placeholder="Code einfuegen ..."></textarea>
</label>
<div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
</div>
</form>
</div>
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat des Lizenzservers</div>
<div class="card__sub">
Nur noetig, wenn der Lizenzserver ein eigenes Zertifikat benutzt
</div>
</div>
</div>
<div class="card__body stack">
{{ alert('Ein Lizenzserver im eigenen Netz hat meist ein selbstsigniertes
Zertifikat. Ohne Anker scheitert die Pruefung, und jeder Versuch faellt auf den
Offline-Weg zurueck. Die Pruefung abzuschalten waere der falsche Weg -- hinterlegen
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprueft.', 'info') }}
<form method="post" action="{{ url_for('license.server_certificate') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Zertifikat (PEM)</span>
<textarea class="textarea input--mono" name="certificate" rows="6"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
>{{ server_certificate }}</textarea>
<span class="field__hint">
Leer lassen, um wieder gegen die Systemanker zu pruefen. Auf dem Lizenzserver
liegt es unter <span class="mono">/etc/tesm/certs/&lt;name&gt;/fullchain.pem</span>.
</span>
</label>
<div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
</div>
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
<div class="card__sub">
Die Datei enthaelt das Lizenzdokument und den zugehoerigen Client-Schluessel
</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('license.upload') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
{% if trusted_keys and current_user.is_admin %}
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen',
hint_text='Nur ankreuzen, wenn die neue Lizenz bewusst von einem anderen
Lizenzserver kommt.') }}
{% endif %}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Einspielen</button>
</div>
</form>
</section>
{% endif %}
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('hard-drive', size=17) }} Diese Maschine</div>
<div class="card__sub">Wird zur Bindung der Lizenz verwendet</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
</dl>
<p class="fs-xs faint mt-3">
Der Fingerprint stammt aus der stabilen Maschinenidentitaet. Eine Umbenennung des Hosts
aendert ihn <strong>nicht</strong> mehr -- im Vorgaenger fuehrte genau das zu
&bdquo;bereits anderweitig gebunden&ldquo;.
</p>
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
</div>
</div>
{% if trusted_keys %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Schluessel-ID</th><th>Bezeichnung</th><th>Quelle</th><th class="actions"></th></tr></thead>
<tbody>
{% for key in trusted_keys %}
<tr>
<td class="mono fs-xs">{{ key.key_id }}</td>
<td class="fs-sm">{{ key.label }}
<div class="fs-xs faint">{{ key.added_at | dt }}</div></td>
<td class="fs-xs">{{ key.source }}</td>
<td class="actions">
{% if current_user.is_admin %}
<form method="post" data-no-guard
action="{{ url_for('license.remove_trusted_key', key_id=key.key_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Diesen Aussteller-Schluessel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswuerdig."
data-confirm-title="Aussteller entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=14) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Kein Aussteller gepinnt',
'Beim ersten Import wird der Schluessel des Lizenzservers uebernommen und danach
erwartet.', 'shield') }}
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Was die Lizenz freischaltet</div>
</div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm">
{% for key, label in features.items() %}
<li class="row row--tight">
{% if feature_allowed(key) %}
<span class="success-text">{{ icon('check', size=14) }}</span>
{% else %}
<span class="faint">{{ icon('lock', size=14) }}</span>
{% endif %}
<span>{{ label }}</span>
</li>
{% endfor %}
</ul>
<p class="fs-xs faint mt-3">
Diese Liste ist die einzige Wahrheit: was hier steht, wird serverseitig durchgesetzt --
nicht nur in der Oberflaeche ausgegraut.
</p>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,14 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block page_title %}Kea-DHCP-Protokoll{% endblock %}
{% block page_sub %}{{ path }}{% endblock %}
{% block content %}
{% if error %}{{ alert(error, 'warning') }}{% endif %}
{{ alert('Das Protokoll liegt unter /var/log/kea/. Es darf nicht in das
Protokollverzeichnis der Anwendung wandern: das AppArmor-Profil von Kea erlaubt
ausschliesslich diesen fest einprogrammierten Pfad.', 'info') }}
<section class="card">
<div class="card__header"><div class="flex-1"><div class="card__title">{{ icon('network', size=17) }} Letzte Zeilen</div></div></div>
{% include "tesm_core/_log_lines.html" %}
</section>
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
{% block page_title %}Neustarts{% endblock %}
{% block page_sub %}{{ rows | length }} Neustarts (neueste zuerst){% endblock %}
{% block topbar_extra %}{{ search_box('restart-table', 'Geraet, Ergebnis, Methode ...') }}{% endblock %}
{% block content %}
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Ausloeser festgehalten -- automatisch
wie manuell, ueber den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">Verlauf</div>
<div class="card__sub">
{% for value, label in methods %}
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a>
{% endfor %}
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
</div>
</div>
</div>
<div class="table-wrap">
<table class="table table--compact" id="restart-table" data-behavior="sortable">
<thead><tr>
<th class="sortable">Zeit</th><th class="sortable">Geraet</th>
<th class="sortable">Methode</th><th class="sortable">Ausloeser</th>
<th class="sortable">Benutzer</th><th class="sortable">Ergebnis</th>
<th class="sortable numeric">Dauer</th><th>Detail</th>
</tr></thead>
<tbody>
{% for row in rows %}
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ row.detail }}">
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
<td class="fw-600 fs-sm">{{ row.device_name or '&ndash;' | safe }}</td>
<td class="fs-sm">
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
{% else %}{{ pill('PoE-Port', '') }}{% endif %}
</td>
<td class="fs-sm">{{ row.trigger }}</td>
<td class="fs-sm">{{ row.actor }}</td>
<td>
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
{% elif row.result == 'skipped' %}{{ pill('uebersprungen', 'warning') }}
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td>
<td class="numeric fs-sm">{{ row.duration_ms or 0 }} ms</td>
<td class="fs-xs muted break-all" style="max-width:420px">{{ row.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not rows %}{{ empty_state('Noch keine Neustarts', '', 'power') }}{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,219 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
{% block page_title %}Wartung{% endblock %}
{% block page_sub %}
{{ devices | length }} Geraete mit Zugangsdaten der Kategorie
&bdquo;Linux-Client&ldquo; oder &bdquo;Windows-Client&ldquo;
{% endblock %}
{% block topbar_extra %}{{ search_box('maintenance-table', 'Geraet suchen ...') }}{% endblock %}
{% block content %}
{% if devices | rejectattr('host_key_ready') | list %}
{{ alert('Fuer mindestens ein Geraet ist der SSH-Host-Schluessel noch nicht freigegeben.
Die Wartung bricht dort mit "Schluessel unbekannt" ab. Die Freigabe erfolgt auf der
Detailseite des Geraets -- erst auslesen, Fingerprint pruefen, dann freigeben.',
'warning', 'Host-Schluessel offen') }}
{% endif %}
{% if windows_tool_missing %}
{# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf waere sie
eine Sackgasse. #}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<strong>Windows-Neustart nicht moeglich</strong>
Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt
<code>net</code> mit, ueber das der Neustart eines Windows-Geraetes laeuft.
</div>
<form method="post" action="{{ url_for('maintenance.install_windows_tool') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit"
data-confirm="Paket {{ windows_package }} jetzt installieren?"
data-confirm-title="Paket nachinstallieren" data-confirm-ok="Installieren">
Nachinstallieren
</button>
</form>
</div>
{% endif %}
{{ alert('Welche Geraete hier erscheinen und was mit ihnen moeglich ist, entscheidet die
Kategorie ihrer Zugangsdaten. Linux-Clients werden ueber SSH aktualisiert und neu gestartet.
Windows-Clients werden nur neu gestartet, und zwar ueber RPC (wie "shutdown /m") -- sie
brauchen dafuer weder SSH noch einen Host-Schluessel, sondern die offene Datei- und
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
'info') }}
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<section class="card">
<div class="table-toolbar">
<label class="check">
<input type="checkbox" id="select-all">
<span>Alle auswaehlen</span>
</label>
<span class="table-toolbar__spacer"></span>
{% if current_user.has('maintenance.execute') %}
<button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}"
title="Nur fuer Linux-Clients -- Windows-Geraete werden dabei uebersprungen."
data-confirm="Paketaktualisierung auf den ausgewaehlten Geraeten starten?"
data-confirm-title="Aktualisieren" data-confirm-ok="Starten" data-confirm-style="primary">
{{ icon('download', size=15) }} Aktualisieren
</button>
<button class="btn btn--sm btn--danger" type="submit"
formaction="{{ url_for('maintenance.start_reboot') }}"
data-confirm="Die ausgewaehlten Geraete jetzt neu starten? Sie sind dabei kurzzeitig nicht erreichbar."
data-confirm-title="Neustarten" data-confirm-ok="Neu starten">
{{ icon('refresh', size=15) }} Neustarten
</button>
{% endif %}
</div>
<div class="table-wrap">
<table class="table" id="maintenance-table" data-behavior="sortable">
<thead>
<tr>
<th style="width:44px"></th>
<th class="sortable">Geraet</th>
<th class="sortable">IP</th>
<th class="sortable">Zustand</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable">Host-Schluessel</th>
<th></th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr data-filter-text="{{ device.name }} {{ device.ip }} {{ device.credential_name }}">
<td>
<label class="check">
<input type="checkbox" name="device_ids" value="{{ device.id }}"
class="device-select" data-category="{{ device.category }}"
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
<span class="sr-only">{{ device.name }} auswaehlen</span>
</label>
</td>
<td class="fw-600">{{ device.name }}</td>
<td class="mono fs-sm">{{ device.ip }}</td>
<td>
{% if device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
</td>
<td class="fs-sm">
{{ device.credential_name }}
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
</td>
<td>
{% if not device.ssh_managed %}
<span class="faint fs-xs" title="Windows wird ueber RPC verwaltet, nicht ueber SSH">
nicht noetig
</span>
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
{% else %}
<a href="{{ url_for('devices.detail', device_id=device.id) }}"
title="Ohne freigegebenen Schluessel bricht die Wartung ab">
{{ pill('offen', 'danger') }}
</a>
{% endif %}
</td>
<td class="nowrap">
{% if terminal_enabled and device.ssh_managed
and current_user.has('maintenance.execute') %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
{{ icon('terminal', size=14) }} Konsole
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not devices %}
{{ empty_state('Keine passenden Geraete',
'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie
sie einem Geraet zu.', 'wrench') }}
{% endif %}
</section>
</form>
{# Kein data-refresh-url: die Schleife in app.js schreibt die Antwort in
innerHTML. Hier zeigte sie auf die Seite selbst -- die komplette
Wartungsseite landete damit in dieser Karte, mitsamt Seitenleiste und
Suchfeld. Aktualisiert wird ueber die Abfrage unten, die bei laufenden
Auftraegen die ganze Seite neu laedt. #}
<section class="card" id="jobs">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Auftraege</div>
<div class="card__sub">
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
</div>
</div>
</div>
{% if jobs %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Gestartet</th><th>Geraet</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
<tbody>
{% for job in jobs %}
<tr>
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
<td class="fs-sm fw-600">
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
</td>
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td>
<td>
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('laeuft', 'info') }}
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
{% else %}{{ pill('wartet', '') }}{% endif %}
</td>
<td style="min-width:120px">
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
</div>
</td>
<td class="fs-xs muted">{{ job.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Auftraege', 'Es wurde noch keine Wartung gestartet.', 'clock') }}
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
// Auswahl beim Laden bewusst zuruecksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
// versehentlich das falsche Geraet erwischt.
const boxes = Array.from(document.querySelectorAll(".device-select"));
boxes.forEach((box) => (box.checked = false));
const all = document.getElementById("select-all");
all?.addEventListener("change", () => {
boxes.filter((box) => !box.disabled && !box.closest("tr").hidden)
.forEach((box) => (box.checked = all.checked));
});
// Auftragsliste nachladen, solange etwas laeuft.
async function poll() {
try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
} catch {
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */
}
}
poll();
</script>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill %}
{% block page_title %}{{ job.subject }}{% endblock %}
{% block page_sub %}{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }} &middot; {{ job.created_at | dt }}{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('maintenance.overview') }}">Wartung</a></span><span>{{ job.subject }}</span>
</nav>
{% endblock %}
{% block content %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('wrench', size=17) }} Auftrag
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('laeuft', 'info') }}
{% elif job.state == 'unknown' %}{{ pill('unklar', 'warning') }}
{% else %}{{ pill('wartet', '') }}{% endif %}
</div>
<div class="card__sub">{{ job.message }}</div>
</div>
<span class="fs-xs faint">gestartet von {{ job.started_by }}</span>
</div>
<div class="card__body">
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
</div>
</div>
<div id="job-output"
{% if job.state in ('pending', 'running') %}
data-refresh-url="{{ url_for('maintenance.job_output', job_id=job.id) }}"
data-refresh-interval="3000"
{% endif %}>
{% include "tesm/_maintenance_output.html" %}
</div>
</section>
<div class="row row--end">
<a class="btn" href="{{ url_for('maintenance.overview') }}">Zurueck</a>
</div>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
// Beim Oeffnen ans Ende springen: bei einem laufenden Auftrag ist die
// juengste Zeile die interessante, nicht die erste. Das Nachfuehren beim
// Aktualisieren erledigt die Schleife in app.js.
const view = document.querySelector("#job-output .log-view");
if (view) view.scrollTop = view.scrollHeight;
</script>
{% endblock %}
@@ -0,0 +1,169 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Ueberwachung, Protokolle, Host, Navigation{% endblock %}
{% block content %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
nicht geaendert werden. In einer Entwicklungsumgebung ist das der erwartete Zustand.',
'info') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('activity', size=17) }} Ueberwachung</div>
<div class="card__sub">Wie oft geprueft und wann automatisch geschaltet wird</div>
</div>
{% if current_user.has('settings_system.execute') %}
<form method="post" action="{{ url_for('settings.run_check_now') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Jetzt pruefen</button>
</form>
{% endif %}
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
<div class="form-grid">
{{ field('monitor_interval_seconds', 'Pruefintervall (Sekunden)',
value=values.monitor_interval_seconds, type='number',
disabled=bool(license_hint),
hint_text=specs['monitor_interval_seconds'].hint) }}
{{ field('monitor_failures_before_restart', 'Fehlversuche bis zum PoE-Neustart',
value=values.monitor_failures_before_restart, type='number',
hint_text=specs['monitor_failures_before_restart'].hint) }}
</div>
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
checked=values.monitor_auto_restart,
hint_text='Zusaetzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
funktioniert unabhaengig davon.') }}
{{ checkbox('dashboard_public', 'Statusuebersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public,
hint_text=specs['dashboard_public'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
<div class="card__sub">
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestaetigung,
vollstaendige Mitschrift
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('terminal_enabled', 'Browser-Konsole fuer Switche zulassen',
checked=values.terminal_enabled) }}
{{ field('terminal_idle_timeout', 'Trennen bei Untaetigkeit (Sekunden)',
value=values.terminal_idle_timeout, type='number') }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Protokolle und Papierkorb</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ select_field('log_rotate_interval', 'Rotationsintervall',
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number',
hint_text=specs['trash_retention_days'].hint) }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
</div>
<div class="card__body stack">
<form method="post" action="{{ url_for('settings.set_hostname') }}" class="stack stack--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
{{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }}
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
Hostname setzen
</button>
</form>
<div class="divider"></div>
<form method="post" action="{{ url_for('settings.set_timezone') }}" class="stack stack--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Zeitzone</span>
<select class="select" name="timezone" {% if not sysops_available %}disabled{% endif %}>
{% for zone in timezones %}
<option value="{{ zone }}" {% if zone == timezone %}selected{% endif %}>{{ zone }}</option>
{% endfor %}
</select>
<span class="field__hint">Wirkt sofort auf die laufende Anwendung.</span>
</label>
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
Zeitzone setzen
</button>
</form>
</div>
</section>
{% if current_user.is_admin %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt fuer alle Benutzer; jeder sieht weiter nur, was er darf</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<ol class="stack stack--tight">
{% for key in nav_order %}
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
<li class="row row--tight">
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
min="1" style="width:70px" disabled>
<input type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Die Reihenfolge folgt der Liste. Zum Umsortieren die Eintraege in der gewuenschten
Reihenfolge speichern (Drag-and-drop folgt in einer spaeteren Version).
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "tesm_core/base.html" %}
{% block page_title %}{{ switch.hostname }}{% endblock %}
{% block page_sub %}{{ command }}{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('switches.overview') }}">Switche</a></span>
<span><a href="{{ url_for('switches.detail', switch_id=switch.id) }}">{{ switch.hostname }}</a></span>
<span>Kommando</span>
</nav>
{% endblock %}
{% block content %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('terminal', size=17) }} Ausgabe</div>
<div class="card__sub mono">{{ command }}</div></div>
<button class="btn btn--sm" type="button" data-copy="#cmd-output">{{ icon('copy', size=14) }} Kopieren</button>
</div>
<pre class="log-view" id="cmd-output">{{ output }}</pre>
</section>
<div class="row row--end">
<a class="btn" href="{{ url_for('switches.detail', switch_id=switch.id) }}">Zurueck</a>
</div>
{% endblock %}
@@ -0,0 +1,184 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, select_field %}
{% block page_title %}{{ switch.hostname }}{% endblock %}
{% block page_sub %}{{ switch.ip }}:{{ switch.ssh_port }}{% if switch.model %} &middot; {{ switch.model }}{% endif %}{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('switches.overview') }}">Switche</a></span><span>{{ switch.hostname }}</span>
</nav>
{% endblock %}
{% block topbar_extra %}
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %}
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }} Konsole
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="grid grid--split">
<div class="stack">
<section class="card {{ '' if host_keys else 'card--danger' }}">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('lock', size=17) }} SSH-Host-Schluessel
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
</div>
<div class="card__sub">
Ohne freigegebenen Schluessel verweigert die Anwendung jeden Zugriff auf diesen Switch.
</div>
</div>
{% if current_user.has('switches.edit') %}
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Auslesen</button>
</form>
{% endif %}
</div>
<div class="card__body stack">
{% if request.args.get('probed_fingerprint') %}
<div class="alert alert--warning">
{{ icon('alert', size=18) }}
<div class="alert__body">
<div class="alert__title">Gelesener Schluessel</div>
<div class="mono fs-sm break-all">
{{ request.args.get('probed_type') }} {{ request.args.get('probed_fingerprint') }}
</div>
<p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhaengigen Quelle (Konsolenzugang am
Switch, Inventardokumentation), bevor Sie ihn freigeben.
</p>
{% if current_user.has('switches.edit') %}
<form method="post" class="mt-2"
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="key_type" value="{{ request.args.get('probed_type') }}">
<input type="hidden" name="fingerprint" value="{{ request.args.get('probed_fingerprint') }}">
<button class="btn btn--sm btn--primary" type="submit"
data-confirm="Diesen Fingerprint dauerhaft als vertrauenswuerdig hinterlegen?"
data-confirm-title="Host-Schluessel freigeben" data-confirm-ok="Freigeben"
data-confirm-style="primary">Freigeben</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
{% if host_keys %}
<dl class="kv fs-sm">
{% for key in host_keys %}
<dt>{{ key.key_type }}</dt>
<dd class="mono break-all">{{ key.fingerprint }}
<div class="fs-xs faint">freigegeben {{ key.added_at | dt }} von {{ key.added_by }}</div>
</dd>
{% endfor %}
</dl>
{% if current_user.has('switches.edit') %}
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Gespeicherte Host-Schluessel entfernen? Danach ist kein Zugriff mehr moeglich, bis erneut freigegeben wird."
data-confirm-title="Schluessel verwerfen" data-confirm-ok="Verwerfen">
Schluessel verwerfen
</button>
</form>
{% endif %}
{% else %}
<p class="muted fs-sm">Noch kein Schluessel hinterlegt. Bitte auslesen und pruefen.</p>
{% endif %}
</div>
</section>
{% if current_user.has('switches.execute') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Kommando ausfuehren</div>
<div class="card__sub">
Positivliste lesender Kommandos &ndash; verlangt eine frische Passwortbestaetigung
</div>
</div>
</div>
<form method="post" action="{{ url_for('switches.run_command', switch_id=switch.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<label class="field">
<span class="field__label">Kommando</span>
<select class="select" name="command">
{% for command, label in safe_commands %}
<option value="{{ command }}">{{ label }} &ndash; {{ command }}</option>
{% endfor %}
</select>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Fuehre aus ...">
Ausfuehren
</button>
</div>
</form>
</section>
{% endif %}
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Hostname</dt><dd>{{ switch.hostname }}</dd>
<dt>IP</dt><dd class="mono">{{ switch.ip }}</dd>
<dt>SSH-Port</dt><dd>{{ switch.ssh_port }}</dd>
<dt>Zugangsdaten</dt><dd>{{ switch.credential_name or 'keine' }}</dd>
<dt>Standort</dt><dd>{{ switch.location or '&ndash;' | safe }}</dd>
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd>
<dt>Geaendert</dt><dd>{{ switch.updated_at | dt }} <span class="faint">von {{ switch.updated_by or '-' }}</span></dd>
</dl>
{% if switch.notes %}<p class="fs-sm muted mt-3">{{ switch.notes }}</p>{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('cpu', size=17) }} Angeschlossene Geraete</div>
<div class="card__sub">{{ devices | length }} zugeordnet</div>
</div>
</div>
{% if devices %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Port</th><th>Geraet</th><th>Zustand</th></tr></thead>
<tbody>
{% for device in devices %}
<tr>
<td class="mono fs-sm">{{ device.port or '&ndash;' | safe }}</td>
<td class="fs-sm">
<a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a>
<div class="fs-xs faint mono">{{ device.ip }}</div>
</td>
<td>
{% if device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Geraete', 'Diesem Switch ist noch kein Geraet zugeordnet.', 'cpu') }}
{% endif %}
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,174 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
empty_state, alert %}
{% macro switch_form(switch=None) %}
<div class="form-grid">
{{ field('hostname', 'Hostname', value=switch.hostname if switch else '', required=True) }}
{{ field('ip', 'IP-Adresse', value=switch.ip if switch else '', required=True) }}
{{ field('ssh_port', 'SSH-Port', value=switch.ssh_port if switch else 22, type='number') }}
<label class="field">
<span class="field__label">Zugangsdaten</span>
<select class="select" name="credential_id">
<option value="">keine</option>
{% for credential in credentials %}
<option value="{{ credential.id }}"
{% if switch and switch.credential_id == credential.id %}selected{% endif %}>
{{ credential.name }} ({{ credential.username }})
</option>
{% endfor %}
</select>
</label>
{{ field('model', 'Modell', value=switch.model if switch else '', placeholder='z. B. Aruba 2930M') }}
{{ field('location', 'Standort', value=switch.location if switch else '') }}
{{ textarea_field('notes', 'Notizen', value=switch.notes if switch else '', rows=2,
extra_class='field--full') }}
</div>
{% endmacro %}
{% block page_title %}Switche{% endblock %}
{% block page_sub %}{{ switches | length }} Switche{% endblock %}
{% block topbar_extra %}
{{ search_box('switches-table', 'Hostname, IP, Standort ...') }}
{% if current_user.has('switches.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="switch-new">
{{ icon('plus', size=15) }} Switch anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
{{ alert('Vor dem ersten Zugriff muss der SSH-Host-Schluessel eines Switches ausdruecklich
freigegeben werden. Unbekannte oder geaenderte Schluessel werden abgewiesen -- nicht
stillschweigend uebernommen.', 'info', 'Host-Schluessel') }}
<section class="card">
<div class="table-wrap">
<table class="table" id="switches-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Hostname</th>
<th class="sortable">IP</th>
<th class="sortable numeric">Port</th>
<th class="sortable">Zugangsdaten</th>
<th class="sortable numeric">Geraete</th>
<th class="sortable">Host-Schluessel</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for switch in switches %}
<tr data-filter-text="{{ switch.hostname }} {{ switch.ip }} {{ switch.location }} {{ switch.model }}">
<td>
<a class="fw-600" href="{{ url_for('switches.detail', switch_id=switch.id) }}">
{{ switch.hostname }}
</a>
{% if switch.model %}<div class="fs-xs faint">{{ switch.model }}</div>{% endif %}
</td>
<td class="mono fs-sm">{{ switch.ip }}</td>
<td class="numeric fs-sm">{{ switch.ssh_port }}</td>
<td class="fs-sm">
{% if switch.credential_name %}{{ switch.credential_name }}
{% else %}<span class="faint">keine</span>{% endif %}
</td>
<td class="numeric">{{ switch.device_count }}</td>
<td data-sort-value="{{ '1' if switch.host_key_known else '0' }}">
{% if switch.host_key_known %}{{ pill('freigegeben', 'online') }}
{% else %}
<a href="{{ url_for('hostkeys.overview') }}"
title="Ohne Freigabe verweigern Konsole und PoE-Schaltung die Verbindung">
{{ pill('offen', 'warning') }}
</a>
{% endif %}
</td>
<td class="actions">
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %}
<a class="btn btn--sm btn--ghost" title="Konsole"
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
{{ icon('terminal', size=15) }}
</a>
{% endif %}
{% if current_user.has('switches.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
</button>
{% endif %}
{% if current_user.has('switches.delete') %}
<form method="post" action="{{ url_for('switches.delete', switch_id=switch.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit" aria-label="Loeschen"
data-confirm="{{ switch.hostname }} in den Papierkorb verschieben?"
data-confirm-title="Switch loeschen" data-confirm-ok="Loeschen">
{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not switches %}
{{ empty_state('Keine Switche', 'Legen Sie den ersten Switch an.', 'switch') }}
{% endif %}
</section>
{% if host_keys %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('lock', size=17) }} Freigegebene Host-Schluessel</div>
<div class="card__sub">{{ host_keys | length }} Eintraege</div>
</div>
</div>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Host</th><th>Port</th><th>Typ</th><th>Fingerprint</th><th>Freigegeben</th></tr></thead>
<tbody>
{% for key in host_keys %}
<tr>
<td class="mono fs-sm">{{ key.host }}</td>
<td class="fs-sm">{{ key.port }}</td>
<td class="fs-sm">{{ key.key_type }}</td>
<td class="mono fs-xs break-all">{{ key.fingerprint }}</td>
<td class="fs-sm">{{ key.added_at | dt }} <span class="faint">von {{ key.added_by }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% endblock %}
{% block dialogs %}
{% if current_user.has('switches.create') %}
{% call modal('switch-new', 'Switch anlegen', 'Host-Schluessel wird danach freigegeben') %}
<form method="post" action="{{ url_for('switches.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ switch_form() }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% if current_user.has('switches.edit') %}
{% for switch in switches %}
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ switch_form(switch) }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}
+137
View File
@@ -0,0 +1,137 @@
"""Ueberwachungsprozess: prueft Geraete, schaltet PoE, raeumt auf.
Laeuft als eigene systemd-Unit (``tesm-monitor.service``), getrennt vom
Webprozess. Ein Absturz der Weboberflaeche stoppt die Ueberwachung damit nicht --
und umgekehrt.
Ersetzt ``poe.sh`` + ``poe_wrapper.py`` + ``generate_ips.py`` + den
``tesm-check-restart``-Timer des Vorgaengers. Den Timer braucht es nicht mehr:
das Intervall wird bei **jedem** Durchlauf frisch aus der Datenbank gelesen,
eine Aenderung greift also beim naechsten Lauf statt erst nach einem
naechtlichen Neustart des Dienstes.
"""
from __future__ import annotations
import logging
import signal
import sys
import time
from types import FrameType
from tesm_core.extension import core
from . import create_app
from .services import fileshare, inventory, monitor
logger = logging.getLogger("tesm.monitor")
MIN_INTERVAL = 30
FILESHARE_SWEEP_EVERY = 30 * 60
class MonitorDaemon:
def __init__(self) -> None:
self.app = create_app()
self.extension = core(self.app)
self.running = True
self._last_fileshare_sweep = 0.0
def stop(self, signum: int, _frame: FrameType | None) -> None:
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
self.running = False
# ------------------------------------------------------------------ #
def _interval(self) -> int:
with self.extension.database.session() as conn:
value = int(
self.extension.settings.get(conn, "monitor_interval_seconds") or 300
)
return max(MIN_INTERVAL, value)
def _auto_restart_allowed(self) -> bool:
settings_on = True
with self.extension.database.session() as conn:
settings_on = bool(self.extension.settings.get(conn, "monitor_auto_restart"))
if self.extension.license is not None:
self.extension.license.reload(conn)
if not settings_on:
return False
if self.extension.license is None:
return True
return self.extension.license.feature_allowed("auto_poe_restart")
def _failures_before_restart(self) -> int:
with self.extension.database.session() as conn:
return int(
self.extension.settings.get(conn, "monitor_failures_before_restart") or 2
)
def run(self) -> int:
live = self.extension.registry["live_log"]
live.info("monitor", "Ueberwachungsprozess gestartet")
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path)
while self.running:
interval = self._interval()
started = time.monotonic()
try:
with self.app.app_context():
report = monitor.run_sweep(
database=self.extension.database,
live=live,
keystore=self.extension.keystore,
license_allows_auto_restart=self._auto_restart_allowed(),
failures_before_restart=self._failures_before_restart(),
)
logger.info(
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
"%s ohne Lizenz uebersprungen, %sms",
report.checked,
report.online,
report.offline,
report.restarts_triggered,
report.restarts_ok,
report.skipped_unlicensed,
report.duration_ms,
)
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
logger.exception("Durchlauf fehlgeschlagen")
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
if time.monotonic() - self._last_fileshare_sweep > FILESHARE_SWEEP_EVERY:
self._last_fileshare_sweep = time.monotonic()
try:
with self.app.app_context():
released = fileshare.sweep_expired(self.extension.database)
if released:
live.info("fileshare", f"{released} abgelaufene Einbindungen geloest")
except Exception: # noqa: BLE001
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
elapsed = time.monotonic() - started
remaining = max(1.0, interval - elapsed)
# In Sekundenschritten warten, damit ein Stoppsignal schnell greift.
while remaining > 0 and self.running:
time.sleep(min(1.0, remaining))
remaining -= 1.0
live.info("monitor", "Ueberwachungsprozess beendet")
return 0
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
stream=sys.stderr,
)
daemon = MonitorDaemon()
signal.signal(signal.SIGTERM, daemon.stop)
signal.signal(signal.SIGINT, daemon.stop)
return daemon.run()
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+7
View File
@@ -0,0 +1,7 @@
"""WSGI-Einstiegspunkt fuer gunicorn: ``gunicorn tesm.wsgi:app``."""
from __future__ import annotations
from . import create_app
app = create_app()
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env bash
# =============================================================================
# Installation und Aktualisierung von TESM und TESM-Lizenzserver.
#
# Aufruf (aus dem entpackten Release-Verzeichnis):
# sudo bash deploy/install.sh --app tesm
# sudo bash deploy/install.sh --app tesm-license
# sudo bash deploy/install.sh --app tesm --instance test --port 5100
#
# Die im Vorgaengerprojekt real aufgetretenen Fallen sind hier von vornherein
# vermieden -- jeweils mit Kommentar an der Stelle, wo es zaehlt:
#
# * Die nginx-Site-Datei wird bei jedem Lauf **inhaltlich verglichen** und bei
# Abweichung erneuert. Der Vorgaenger schrieb sie nur, wenn sie fehlte -- ein
# einmal falscher alias-Pfad blieb dadurch ueber Jahre stehen und lieferte
# alle statischen Dateien als 404.
# * pip laeuft immer ueber "python3 -m pip", nie ueber den venv-Shim: dessen
# Shebang baeckt einen absoluten Pfad ein, der bei einer Verzeichnis-
# umbenennung bricht (live auf POETEST reproduziert).
# * Vor jedem Update ein vollstaendiges Backup -- unabhaengig davon, ob sich
# das Schema geaendert hat. Ein reiner Code-Fehler soll durch dasselbe Netz
# laufen.
# * Nach dem Update ein Health-Check mit automatischem Rollback.
# * Instanzdaten (Datenbank, Schluessel, Lizenz) sind vom Abgleich
# ausgeschlossen und werden nie ueberschrieben.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
PORT=""
HTTP_PORT=""
SKIP_PACKAGES=0
ASSUME_YES=0
HTTPS=0
HTTPS_PORT=""
DOMAIN=""
ACME_EMAIL=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung installiert wird (Pflicht)
--instance <name> Zusaetzliche Instanz neben der Hauptinstallation.
Erzeugt /srv/<app>-<name>, <app>-<name>.service usw.
Damit laesst sich eine neue Version testen, ohne die
laufende anzufassen.
--port <nummer> Port des Anwendungsservers (Standard: 5000 bzw. 5001)
--http-port <nr> Port, auf dem nginx diese Instanz anbietet (Standard 80).
Fuer eine Testinstanz neben einer laufenden Produktion:
auf Port 80 kann nur eine Site die Vorgabe sein.
--https HTTPS einrichten: Port 443 mit TLS, Port 80 leitet
dorthin um (ausser dem ACME-Pfad), HSTS aktiv,
Sitzungscookie mit Secure-Flag. Ohne --domain wird ein
selbstsigniertes Zertifikat erzeugt (geschlossene Netze);
mit --domain wird zusaetzlich versucht, ein
Let's-Encrypt-Zertifikat zu holen.
--https-port <nr> Wie --https, aber auf abweichendem Port (Testinstanz).
--domain <name> Oeffentlicher Name dieser Installation.
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--port) PORT="${2:?}"; shift 2 ;;
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
--https) HTTPS=1; shift ;;
--https-port) HTTPS_PORT="${2:?}"; HTTPS=1; shift 2 ;;
--domain) DOMAIN="${2:?}"; shift 2 ;;
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;;
esac
done
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
# Seit die beiden Anwendungen in getrennten Repositorien liegen, bringt ein
# Auscheckout nur die eigene mit. Ohne diese Pruefung waere die Meldung ein
# Folgefehler tief im Installationslauf statt eines klaren Satzes hier.
[[ -d "$REPO_DIR/apps/$APP" ]] || fail "In diesem Verzeichnis liegt '$APP' nicht ($REPO_DIR/apps/$APP fehlt). Vorhanden: $(
ls -1 "$REPO_DIR/apps" 2>/dev/null | tr '
' ' ' || echo 'nichts')"
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
# -- Namensraum --------------------------------------------------------------
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ROOT="/srv/$NAME"
LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP"
UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service"
NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
if [[ -z "$PORT" ]]; then
PORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
fi
if [[ -z "$HTTP_PORT" ]]; then
HTTP_PORT=80
[[ -n "$INSTANCE" ]] && HTTP_PORT=8080
fi
if [[ -z "$HTTPS_PORT" ]]; then
HTTPS_PORT=443
[[ -n "$INSTANCE" ]] && HTTPS_PORT=8443
fi
# Das Secure-Flag am Sitzungscookie darf erst gesetzt werden, wenn es
# tatsaechlich HTTPS gibt -- sonst kommt das Cookie nie beim Browser an und die
# Anmeldung schlaegt ohne erkennbaren Grund fehl.
# Bei einer Aktualisierung zaehlt, was schon gilt: wer HTTPS eingerichtet hat,
# darf es durch ein Update ohne --https nicht verlieren. Ein zurueckgesetztes
# Secure-Flag wuerde die Anmeldung stillschweigend unmoeglich machen -- der
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
# aber ohne das Flag neu, und beide reden aneinander vorbei.
COOKIE_SECURE=0
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
COOKIE_SECURE=1
fi
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
[[ -z "$DOMAIN" || "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain: $DOMAIN"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="/srv/$NAME-backup-$TIMESTAMP"
FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
# Einziges Signal fuer "schon installiert": die Datenbank ist da.
IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT"
info "Dienst : $UNIT auf 127.0.0.1:$PORT"
if [[ $HTTPS -eq 1 ]]; then
info "nginx : Port $HTTPS_PORT (HTTPS), Port $HTTP_PORT leitet um"
elif [[ "$COOKIE_SECURE" == "1" ]]; then
info "nginx : HTTPS ist bereits eingerichtet und bleibt unveraendert"
if [[ -n "$DOMAIN" ]]; then
info "Zertifikat : Lets Encrypt fuer $DOMAIN, sonst selbstsigniert"
else
info "Zertifikat : selbstsigniert (keine Domain angegeben)"
fi
else
info "nginx : Port $HTTP_PORT (nur HTTP)"
fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || fail "abgebrochen"
fi
# -- Systempakete ------------------------------------------------------------
if [[ $SKIP_PACKAGES -eq 0 ]]; then
step "Systempakete"
PACKAGES=(python3 python3-venv python3-pip nginx sqlite3 rsync logrotate ca-certificates curl)
if [[ "$APP" == "tesm" ]]; then
PACKAGES+=(iputils-ping openssh-client cifs-utils)
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "${PACKAGES[@]}"
info "${#PACKAGES[@]} Pakete geprueft"
else
info "Systempakete uebersprungen"
fi
# -- Dienstbenutzer ----------------------------------------------------------
step "Dienstbenutzer"
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
info "Benutzer $SERVICE_USER angelegt"
else
info "Benutzer $SERVICE_USER existiert"
fi
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
# gibt es ausschliesslich ueber den Helfer.
# -- Backup ------------------------------------------------------------------
if [[ $IS_UPDATE -eq 1 ]]; then
step "Sicherung vor der Aktualisierung"
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
cp -a "$ROOT" "$BACKUP_DIR"
info "Sicherung: $BACKUP_DIR"
fi
# -- Dateien abgleichen ------------------------------------------------------
step "Anwendungsdateien"
mkdir -p "$ROOT"
rsync -a --delete \
--exclude 'venv' \
--exclude 'data' \
--exclude '*.pyc' \
--exclude '__pycache__' \
--exclude '.git' \
--exclude 'tests' \
"$REPO_DIR/" "$ROOT/"
mkdir -p "$ROOT/data" "$LOG_DIR"
info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv --------------------------------------------------------------------
step "Python-Umgebung"
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
python3 -m venv "$ROOT/venv"
info "venv erzeugt"
fi
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
PAKETE+=("$ROOT/packages/tesm-licensing-server")
fi
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
if [[ "$APP" == "tesm" ]]; then
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
fi
info "Abhaengigkeiten installiert"
# -- Statische Dateien fuer nginx --------------------------------------------
step "Statische Dateien"
rm -rf "$ROOT/static" "$ROOT/static-core"
CORE_STATIC="$("$ROOT/venv/bin/python3" -c \
'import tesm_core, pathlib; print(pathlib.Path(tesm_core.__file__).parent / "static" / "tesm_core")')"
APP_MODULE="$APP"
[[ "$APP" == "tesm-license" ]] && APP_MODULE="tesm_license"
APP_STATIC="$("$ROOT/venv/bin/python3" -c \
"import $APP_MODULE, pathlib; print(pathlib.Path($APP_MODULE.__file__).parent / 'static')")"
cp -a "$CORE_STATIC" "$ROOT/static-core"
[[ -d "$APP_STATIC" ]] && cp -a "$APP_STATIC" "$ROOT/static" || mkdir -p "$ROOT/static"
info "nginx liefert /core-assets/ und /assets/ direkt aus"
# -- Privilegierter Helfer ---------------------------------------------------
step "Privilegierter Helfer und sudoers"
install -d -o root -g root -m 0755 /usr/local/lib/tesm
install_if_changed() {
local src="$1" dst="$2" mode="$3" owner="${4:-root:root}"
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
return 1
fi
install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" "$src" "$dst"
return 0
}
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
info "Helfer aktualisiert" || info "Helfer unveraendert"
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
# waere sonst ein Totalausfall der Administration.
TMP_SUDO="$(mktemp)"
cp "$ROOT/deploy/sudoers/$APP" "$TMP_SUDO"
visudo -c -f "$TMP_SUDO" >/dev/null || fail "sudoers-Datei ist fehlerhaft"
install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
# -- Rechte ------------------------------------------------------------------
step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
chown -R root:root "$ROOT/packages" "$ROOT/apps" "$ROOT/deploy" 2>/dev/null || true
chmod 0750 "$ROOT/data"
chmod 0755 "$LOG_DIR"
find "$ROOT/data" -maxdepth 1 -name '*.key*' -o -name 'data.keys' -o -name '*signing_key*' \
| xargs -r chmod 0600
info "Datenverzeichnis 0750, Schluesseldateien 0600"
# -- Umgebungsdatei ----------------------------------------------------------
step "Umgebung"
# Eine Quelle fuer beide Seiten: die systemd-Unit liest sie ueber
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
# Datenbank im Arbeitsverzeichnis anlegen.
install -d -o root -g root -m 0755 /etc/tesm
ENV_FILE="/etc/tesm/$NAME.env"
TMP_ENV="$(mktemp)"
{
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
echo "${ENV_PREFIX}BASE_DIR=$ROOT"
echo "${ENV_PREFIX}DATA_DIR=$ROOT/data"
echo "${ENV_PREFIX}LOG_DIR=$LOG_DIR"
# Name dieser Installation auf dem Host. Alles ausserhalb von /srv/<name>
# -- nginx-Site, netplan-Datei, logrotate-Regel, Zertifikatsverzeichnis,
# Sitzungscookie -- laeuft darueber. Ohne das wuerde eine Testinstanz beim
# Speichern der Webserver-Einstellungen die Site der Produktion
# ueberschreiben und beide teilten sich dasselbe Cookie.
echo "${ENV_PREFIX}SITE_KEY=$NAME"
# Genau die Adresse, auf die gunicorn hoert. nginx nimmt sie von hier als
# proxy_pass-Ziel -- so koennen Dienst und Reverse Proxy nicht auseinander
# laufen. Vorher stand die 5000 fest im Code: eine Instanz auf 5100 erzeugte
# damit eine Site, die auf die andere Installation zeigte.
echo "${ENV_PREFIX}BIND=127.0.0.1:$PORT"
echo "${ENV_PREFIX}COOKIE_SECURE=$COOKIE_SECURE"
echo "${ENV_PREFIX}TRUSTED_PROXY_HOPS=1"
echo "PYTHONUNBUFFERED=1"
} > "$TMP_ENV"
install_if_changed "$TMP_ENV" "$ENV_FILE" 0640 "root:$SERVICE_USER" >/dev/null && info "Umgebungsdatei $ENV_FILE geschrieben" || info "Umgebungsdatei unveraendert"
rm -f "$TMP_ENV"
if [[ "$COOKIE_SECURE" == "1" ]]; then
info "COOKIE_SECURE=1 -- die Anmeldung funktioniert damit nur ueber HTTPS."
else
info "Hinweis: COOKIE_SECURE=0 bis HTTPS eingerichtet ist. Danach: install.sh --https"
info " erneut aufrufen oder in $ENV_FILE auf 1 setzen und Dienst neu starten."
fi
# Wrapper, damit die Werkzeuge dieselbe Umgebung sehen wie der Dienst.
ADMIN_BIN="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_BIN="tesm-license-admin"
TMP_WRAPPER="$(mktemp)"
cat > "$TMP_WRAPPER" <<WRAPPER
#!/bin/sh
# Von install.sh erzeugt: laedt die Instanzumgebung und ruft das Werkzeug auf.
set -eu
set -a
. "$ENV_FILE"
set +a
exec "$ROOT/venv/bin/$ADMIN_BIN" "\$@"
WRAPPER
install_if_changed "$TMP_WRAPPER" "/usr/local/bin/$NAME-admin" 0755 >/dev/null && info "Werkzeug: $NAME-admin" || true
rm -f "$TMP_WRAPPER"
# -- systemd -----------------------------------------------------------------
step "systemd"
UNITS_CHANGED=0
render_unit() {
local src="$1" dst="$2"
local tmp; tmp="$(mktemp)"
sed -e "s#/srv/$APP#$ROOT#g" \
-e "s#/var/log/$APP#$LOG_DIR#g" \
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#" \
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#" \
-e "/^WorkingDirectory=/a EnvironmentFile=$ENV_FILE" \
"$src" > "$tmp"
install_if_changed "$tmp" "$dst" 0644 && UNITS_CHANGED=1
rm -f "$tmp"
}
render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert"
# -- logrotate ---------------------------------------------------------------
TMP_ROTATE="$(mktemp)"
sed -e "s#/var/log/$APP/#$LOG_DIR/#" "$ROOT/deploy/logrotate/$APP" > "$TMP_ROTATE"
install_if_changed "$TMP_ROTATE" "/etc/logrotate.d/$NAME" 0644 >/dev/null && info "logrotate aktualisiert"
rm -f "$TMP_ROTATE"
# -- nginx -------------------------------------------------------------------
step "nginx"
TMP_SITE="$(mktemp)"
sed -e "s#/srv/$APP/#$ROOT/#g" \
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#g" \
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#g" \
-e "s#listen 80;#listen $HTTP_PORT;#" \
-e "s#listen \[::\]:80;#listen [::]:$HTTP_PORT;#" \
-e "s#/var/www/$APP-acme#/var/www/$NAME-acme#g" \
"$ROOT/deploy/nginx/$APP.conf" > "$TMP_SITE"
# Auf Port 80 kann nur eine Site die Vorgabe sein. Eine Instanz neben der
# Produktion bekommt darum ueber --http-port einen eigenen Port -- sonst
# verdeckt sie die laufende Installation (nginx meldet dann
# "conflicting server name" und ignoriert eine der beiden).
if [[ -n "$INSTANCE" && "$HTTP_PORT" == "80" ]]; then
warn "Instanz $INSTANCE auf Port 80: sie verdeckt moeglicherweise die Hauptinstallation."
fi
# Inhaltsvergleich statt reiner Existenzpruefung -- genau der Punkt, an dem der
# Vorgaenger einen falschen alias-Pfad jahrelang mitschleppte. Die Anwendung
# schreibt diese Datei spaeter ohnehin selbst neu (Verwaltung -> Webserver);
# hier geht es nur um einen funktionierenden Erststart.
if [[ -f "$NGINX_SITE" ]] && ! cmp -s "$TMP_SITE" "$NGINX_SITE"; then
if grep -qE "alias\s+$ROOT/(static|static-core)/;" "$NGINX_SITE"; then
info "Vorhandene Site-Konfiguration behalten (alias-Pfade stimmen)"
else
warn "Vorhandene Site-Konfiguration zeigt auf falsche Pfade -- wird erneuert"
cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
fi
elif [[ ! -f "$NGINX_SITE" ]]; then
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
info "Site-Konfiguration angelegt"
fi
TMP_SITE_KEEP="$TMP_SITE"
ln -sfn "$NGINX_SITE" "/etc/nginx/sites-enabled/$NAME"
rm -f /etc/nginx/sites-enabled/default
mkdir -p "/var/www/$NAME-acme"
chown -R www-data:www-data "/var/www/$NAME-acme"
# "alias-Pfade stimmen" heisst noch nicht "nginx akzeptiert die Datei". Eine
# von Hand nachgebesserte Site kann syntaktisch kaputt sein -- und dann
# scheitert nicht nur diese Installation, sondern jeder Reload des Webservers,
# auch fuer alle anderen Sites. Darum: pruefen, und im Zweifel die frisch
# erzeugte Vorlage nehmen.
if ! nginx -t >/dev/null 2>&1; then
warn "nginx lehnt die vorhandene Site-Konfiguration ab -- sie wird durch die Vorlage ersetzt."
[[ -f "$NGINX_SITE" ]] && cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
install -m 0644 "$TMP_SITE_KEEP" "$NGINX_SITE"
nginx -t >/dev/null 2>&1 || fail "nginx lehnt auch die Vorlage ab -- bitte 'nginx -t' pruefen. Sicherung: $NGINX_SITE.bak-$TIMESTAMP"
fi
rm -f "$TMP_SITE_KEEP"
systemctl reload nginx
info "nginx neu geladen"
# -- Datenbank und Start -----------------------------------------------------
step "Anwendung starten"
# Das Schema entsteht beim ersten Start durch die Migrationen -- es gibt kein
# separates Init-Skript mehr.
systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi
# -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung"
HEALTHY=0
for attempt in $(seq 1 20); do
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
HEALTHY=1
info "Antwort nach ${attempt}x"
break
fi
sleep 2
done
if [[ $HEALTHY -eq 0 ]]; then
warn "Die Anwendung antwortet nicht."
if [[ $IS_UPDATE -eq 1 ]]; then
warn "Rollback wird ausgefuehrt."
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
mv "$ROOT" "$FAILED_DIR"
cp -a "$BACKUP_DIR" "$ROOT"
systemctl start "$UNIT" || true
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
Fehlgeschlagene Version : $FAILED_DIR
Sicherung : $BACKUP_DIR
Protokoll : journalctl -u $UNIT -n 100"
fi
fail "Neuinstallation fehlgeschlagen. Protokoll: journalctl -u $UNIT -n 100"
fi
URL_HINT="http://<host>:$HTTP_PORT/"
[[ "$HTTP_PORT" == "80" ]] && URL_HINT="http://<host>/"
# HTTPS war schon eingerichtet und wird durch dieses Update nicht angefasst.
[[ $HTTPS -eq 0 && "$COOKIE_SECURE" == "1" ]] && URL_HINT="https://<host>/ (unveraendert)"
# -- HTTPS -------------------------------------------------------------------
# Bewusst erst hier: die Anwendung muss laufen, denn sie erzeugt die
# TLS-Konfiguration selbst (tesm-admin web-setup). Das Installationsskript
# baut sich keine zweite, konkurrierende Vorlage -- genau daran ist der
# Vorgaenger gescheitert.
if [[ $HTTPS -eq 1 ]]; then
step "HTTPS einrichten"
WEB_ARGS=(web-setup --https --apply --self-signed
--http-port "$HTTP_PORT" --https-port "$HTTPS_PORT")
if [[ -n "$DOMAIN" ]]; then
# HSTS nur mit einem Zertifikat, dem Browser auch trauen. Bei einem
# selbstsignierten waere es eine Falle: der Browser merkt sich fuer Monate,
# dass er die Seite nur ueber HTTPS aufrufen darf -- und zeigt bei jedem
# Aufruf eine Warnung, die sich nicht mehr wegklicken laesst.
WEB_ARGS+=(--domain "$DOMAIN")
if [[ -n "$ACME_EMAIL" ]]; then
# Let's Encrypt verlangt ohnehin eine Kontaktadresse. Sie ist damit das
# eindeutige Signal "hol ein oeffentliches Zertifikat" -- ohne sie bleibt
# es bei einem selbstsignierten, auch wenn eine Domain gesetzt ist
# (interne Namen wie lizenz.firma.local kann keine CA bestaetigen).
WEB_ARGS+=(--acme --acme-email "$ACME_EMAIL" --hsts)
info "Versuche Lets Encrypt fuer $DOMAIN; bei Misserfolg selbstsigniert."
else
info "Domain $DOMAIN, aber keine --acme-email: selbstsigniert, ohne HSTS."
fi
else
info "Ohne Domain: selbstsigniertes Zertifikat, ohne HSTS."
fi
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" "${WEB_ARGS[@]}"; then
info "HTTPS aktiv."
URL_HINT="https://<host>/"
[[ "$HTTPS_PORT" == "443" ]] || URL_HINT="https://<host>:$HTTPS_PORT/"
else
warn "HTTPS-Einrichtung fehlgeschlagen -- die Installation laeuft weiter ueber HTTP."
warn "Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin web-setup --https --self-signed --hsts --apply"
# Ein Secure-Cookie ohne HTTPS macht die Anmeldung unmoeglich. Lieber
# zurueckdrehen als eine Installation hinterlassen, an der niemand mehr
# ankommt.
if grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
sed -i "s/^${ENV_PREFIX}COOKIE_SECURE=1/${ENV_PREFIX}COOKIE_SECURE=0/" "$ENV_FILE"
systemctl restart "$UNIT"
warn "COOKIE_SECURE auf 0 zurueckgesetzt, damit die Anmeldung weiter funktioniert."
fi
fi
fi
if [[ $IS_UPDATE -eq 1 ]]; then
rm -rf "$BACKUP_DIR"
info "Sicherung entfernt (Aktualisierung erfolgreich)"
fi
# -- Abschluss ---------------------------------------------------------------
step "Fertig"
ADMIN_CMD="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
cat <<SUMMARY
Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}
Verzeichnis : $ROOT
Protokolle : $LOG_DIR
Dienst : systemctl status $UNIT
Erreichbar : $URL_HINT
Naechste Schritte:
SUMMARY
if [[ "$APP" == "tesm-license" ]]; then
cat <<SUMMARY
1. Signaturschluessel erzeugen und sofort sichern:
sudo -u $SERVICE_USER $NAME-admin init-key
2. Eigene Master-Lizenz ausstellen:
sudo -u $SERVICE_USER $NAME-admin bootstrap
3. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
SUMMARY
else
cat <<SUMMARY
1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
2. Lizenzdatei einspielen unter Verwaltung -> Lizenz
SUMMARY
fi
cat <<'SUMMARY'
Danach in der Anwendung unter "Verwaltung -> Webserver und TLS" die Domain
eintragen, ein Zertifikat anfordern und die Konfiguration uebernehmen --
damit laeuft der Zugriff ueber HTTPS auf Port 443 mit Umleitung von 80.
SUMMARY
+14
View File
@@ -0,0 +1,14 @@
# Von TESM installiert.
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
# ein Reopen-Signal verarbeitet.
/var/log/tesm/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
su tesm tesm
create 0640 tesm tesm
}
+14
View File
@@ -0,0 +1,14 @@
# Von TESM installiert.
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
# ein Reopen-Signal verarbeitet.
/var/log/tesm-license/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
su tesm-license tesm-license
create 0640 tesm-license tesm-license
}
+43
View File
@@ -0,0 +1,43 @@
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
alias /var/www/tesm-license-acme/.well-known/acme-challenge/;
default_type "text/plain";
}
client_max_body_size 160m;
location /assets/ {
alias /srv/tesm-license/static/;
expires 7d;
access_log off;
}
location /core-assets/ {
alias /srv/tesm-license/static-core/;
expires 7d;
access_log off;
}
location / {
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_read_timeout 120s;
}
}
+61
View File
@@ -0,0 +1,61 @@
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
alias /var/www/tesm-acme/.well-known/acme-challenge/;
default_type "text/plain";
}
client_max_body_size 160m;
location /assets/ {
alias /srv/tesm/static/;
expires 7d;
access_log off;
}
location /core-assets/ {
alias /srv/tesm/static-core/;
expires 7d;
access_log off;
}
location /ws/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_read_timeout 120s;
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Paketiert ein Release aus einem vorhandenen Tag.
#
# Auf Windows-Buildmaschinen ist -c core.autocrlf=false zwingend -- sonst landen
# CRLF-Zeilenenden im Tarball und die Shell-Skripte scheitern auf dem Ziel mit
# "bad interpreter". Das ist real passiert.
#
# Der Name kommt aus dem Repository, nicht aus einer festen Zeichenkette: die
# beiden Anwendungen liegen in getrennten Repositorien, und ein
# Lizenzserver-Release, das "tesm-..." heisst, verwechselt man genau einmal.
set -euo pipefail
TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
if [[ -n "${2:-}" ]]; then
NAME="$2"
else
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
fi
OUT="$NAME-$TAG.tar.gz"
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
echo "Erzeugt: $OUT"
file "$OUT"
echo "Pruefsumme:"; sha256sum "$OUT"
+9
View File
@@ -0,0 +1,9 @@
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
# root (Modus 0755).
#
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm !requiretty
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+9
View File
@@ -0,0 +1,9 @@
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
# root (Modus 0755).
#
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm-license !requiretty
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+78
View File
@@ -0,0 +1,78 @@
[Unit]
Description=TESM-Lizenzserver
Documentation=file:/srv/tesm-license/docs/BETRIEB.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify-reload
User=tesm-license
Group=tesm-license
WorkingDirectory=/srv/tesm-license
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=TESM_LICENSE_WEB_PROCESS=1
Environment=TESM_LICENSE_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 8 \
--bind 127.0.0.1:5001 \
--access-logfile - --error-logfile - \
tesm_license.wsgi:app
Restart=always
RestartSec=5
# -- Haertung ---------------------------------------------------------------
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
PrivateDevices=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license /etc/nginx /etc/netplan /etc/logrotate.d /etc/tesm /run
[Install]
WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
[Unit]
Description=TESM -- Ueberwachung der PoE-Endgeraete
After=network-online.target tesm.service
Wants=network-online.target
[Service]
Type=simple
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/tesm-monitor
Restart=always
RestartSec=10
# Das Pruefintervall liest der Prozess bei jedem Durchlauf frisch aus der
# Datenbank -- der naechtliche Neustart-Timer des Vorgaengers entfaellt damit.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm/data /var/log/tesm /etc/tesm /run
# ping braucht CAP_NET_RAW -- als einzige Ausnahme, und nur diese.
AmbientCapabilities=CAP_NET_RAW
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
[Install]
WantedBy=multi-user.target
+78
View File
@@ -0,0 +1,78 @@
[Unit]
Description=TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten
Documentation=file:/srv/tesm/docs/BETRIEB.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify-reload
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=TESM_WEB_PROCESS=1
Environment=TESM_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
--bind 127.0.0.1:5000 \
--access-logfile - --error-logfile - \
tesm.wsgi:app
Restart=always
RestartSec=5
# -- Haertung ---------------------------------------------------------------
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
PrivateDevices=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm/data /var/log/tesm /mnt/tesm-shares /etc/nginx /etc/netplan /etc/kea /etc/logrotate.d /etc/tesm /var/lib/kea /var/log/kea /run
[Install]
WantedBy=multi-user.target
+351
View File
@@ -0,0 +1,351 @@
#!/bin/bash
# =============================================================================
# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt.
#
# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch
# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau
# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste.
#
# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt
# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die
# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem
# Pfad haette den Host bedeutet.
#
# Regeln fuer Aenderungen an dieser Datei:
# * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb.
# * Nie "$@" ungeprueft an eine Shell weiterreichen.
# * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist.
# =============================================================================
set -euo pipefail
umask 022
APP_KEYS_ALLOWED=("tesm" "tesm-license")
SERVICES_ALLOWED=(
"tesm.service" "tesm-monitor.service"
"tesm-license.service"
"kea-dhcp4-server" "kea-dhcp4-server.service"
"nginx" "nginx.service"
)
PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect"
"samba-common-bin")
fail() { echo "tesm-helper: $*" >&2; exit 2; }
require_root() {
[[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)"
}
# -- Argumentpruefungen ------------------------------------------------------
check_in_list() {
local needle="$1"; shift
local item
for item in "$@"; do
[[ "$needle" == "$item" ]] && return 0
done
return 1
}
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
check_app_key() {
local key="$1" app
for app in "${APP_KEYS_ALLOWED[@]}"; do
[[ "$key" == "$app" ]] && return 0
[[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0
done
fail "unbekannter App-Schluessel: $key"
}
check_service() {
check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1"
}
check_hostname() {
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname"
}
check_timezone() {
[[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone"
[[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone"
}
check_domain() {
[[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain"
}
check_email() {
[[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail"
}
check_unc() {
[[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \
|| fail "ungueltiger Freigabepfad"
}
check_mount_target() {
# Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad.
[[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares"
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
}
check_readable_path() {
# Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr
# nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie
# /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der
# Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm.
[[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
case "$1" in
/var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;;
/etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*)
check_app_key "$(basename "$1")" ;;
*) fail "Datei nicht in der Leseliste: $1" ;;
esac
}
read_stdin_limited() {
# Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt.
head -c 1048576
}
write_atomic() {
local target="$1" mode="$2" owner="$3"
local tmp
tmp="$(mktemp "${target}.XXXXXX")"
read_stdin_limited > "$tmp"
chmod "$mode" "$tmp"
[[ -n "$owner" ]] && chown "$owner" "$tmp"
mv -f "$tmp" "$target"
}
# -- Verben ------------------------------------------------------------------
verb="${1:-}"; shift || true
require_root
case "$verb" in
service-status)
check_service "${1:?Dienst fehlt}"
systemctl is-active "$1" || true
systemctl is-enabled "$1" || true
;;
service-start|service-stop|service-restart|service-enable|service-disable)
check_service "${1:?Dienst fehlt}"
systemctl "${verb#service-}" "$1"
;;
nginx-test)
nginx -t
;;
nginx-reload)
nginx -t && systemctl reload nginx
;;
nginx-write)
check_app_key "${1:?App-Schluessel fehlt}"
target="/etc/nginx/sites-available/$1"
write_atomic "$target" 0644 "root:root"
ln -sfn "$target" "/etc/nginx/sites-enabled/$1"
rm -f /etc/nginx/sites-enabled/default
;;
nginx-apply)
# Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme.
#
# Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die
# Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t"
# fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende
# nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch
# irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem
# abgelehnten Formular wurde so ein Ausfall des ganzen Webservers.
check_app_key "${1:?App-Schluessel fehlt}"
target="/etc/nginx/sites-available/$1"
link="/etc/nginx/sites-enabled/$1"
backup="$(mktemp /run/tesm-nginx.XXXXXX)"
had_file=0; had_link=0
[[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; }
[[ -L "$link" || -e "$link" ]] && had_link=1
write_atomic "$target" 0644 "root:root"
ln -sfn "$target" "$link"
if nginx -t 2>&1; then
rm -f /etc/nginx/sites-enabled/default
systemctl reload nginx
rm -f "$backup"
echo "uebernommen"
else
# Genau in den Zustand zurueck, der vorher galt.
if [[ $had_file -eq 1 ]]; then
cp -a "$backup" "$target"
else
rm -f "$target"
fi
[[ $had_link -eq 1 ]] || rm -f "$link"
rm -f "$backup"
# Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie
# es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll.
nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2
fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt"
fi
;;
netplan-write)
check_app_key "${1:?App-Schluessel fehlt}"
# Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach
# /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig.
write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root"
;;
netplan-apply)
netplan apply
;;
hostname-set)
check_hostname "${1:?Hostname fehlt}"
hostnamectl set-hostname "$1"
;;
timezone-set)
check_timezone "${1:?Zeitzone fehlt}"
timedatectl set-timezone "$1"
;;
kea-write)
install -d -o root -g root -m 0755 /etc/kea
# Kea laeuft als eigener, unprivilegierter Benutzer und muss seine
# Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen,
# sonst beanstandet Kea die fehlenden Verzeichnisse.
install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true
# Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration
# ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start
# auf, und dann lag der Dienst, bis jemand ins Journal sah.
tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)"
chmod 0640 "$tmp"; chown root:_kea "$tmp"
read_stdin_limited > "$tmp"
if command -v kea-dhcp4 >/dev/null 2>&1; then
if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then
rm -f "$tmp"
# Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll.
fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)"
fi
fi
mv -f "$tmp" /etc/kea/kea-dhcp4.conf
;;
logrotate-write)
check_app_key "${1:?App-Schluessel fehlt}"
write_atomic "/etc/logrotate.d/$1" 0644 "root:root"
;;
mount-cifs)
check_unc "${1:?Freigabe fehlt}"
check_mount_target "${2:?Ziel fehlt}"
share="${1//\\//}"
target="$2"
creds="$(mktemp /run/tesm-cifs.XXXXXX)"
chmod 600 "$creds"
# Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
# (/proc/<pid>/environ).
read_stdin_limited > "$creds"
mkdir -p "$target"
if mount -t cifs "$share" "$target" \
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
then
shred -u "$creds" 2>/dev/null || rm -f "$creds"
else
shred -u "$creds" 2>/dev/null || rm -f "$creds"
fail "Einbindung fehlgeschlagen"
fi
;;
umount)
check_mount_target "${1:?Ziel fehlt}"
umount -l "$1" || true
rmdir "$1" 2>/dev/null || true
;;
apt-install)
[[ $# -gt 0 ]] || fail "kein Paket angegeben"
for package in "$@"; do
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
done
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "$@"
;;
certbot-issue)
check_domain "${1:?Domain fehlt}"
check_email "${2:?E-Mail fehlt}"
webroot="${3:?Webroot fehlt}"
[[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot"
mkdir -p "$webroot"
# Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der
# Anwendung erzeugte Site-Datei selbst umschreiben.
certbot certonly --webroot -w "$webroot" -d "$1" \
--email "$2" --agree-tos --non-interactive --keep-until-expiring
;;
selfsigned-issue)
# Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes
# Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst
# getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen.
check_app_key "${1:?App-Schluessel fehlt}"
subject="${2:?Common Name fehlt}"
check_domain "$subject"
dir="/etc/tesm/certs/$1"
install -d -m 0755 /etc/tesm/certs
# 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat
# lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der
# private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich
# vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei
# Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar.
install -d -m 0755 "$dir"
# Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und
# nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter.
alt="DNS:$subject"
shift 2
for name in "$@"; do
if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
alt="$alt,IP:$name"
elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then
[[ "$name" == "$subject" ]] || alt="$alt,DNS:$name"
else
fail "ungueltiger alternativer Name: $name"
fi
done
openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem" -subj "/CN=$subject" -addext "subjectAltName=$alt" -addext "basicConstraints=critical,CA:FALSE" -addext "keyUsage=critical,digitalSignature,keyEncipherment" -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1 || fail "openssl ist fehlgeschlagen"
chown root:root "$dir/privkey.pem" "$dir/fullchain.pem"
chmod 0640 "$dir/privkey.pem"
# Das Zertifikat selbst ist oeffentlich -- es geht bei jedem
# TLS-Handschlag an jeden Client.
chmod 0644 "$dir/fullchain.pem"
# nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht
# den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben.
echo "$dir/fullchain.pem"
echo "$dir/privkey.pem"
;;
file-read)
check_readable_path "${1:?Pfad fehlt}"
[[ -f "$1" ]] || exit 0
cat "$1"
;;
*)
fail "unbekanntes Verb: ${verb:-<leer>}"
;;
esac
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# =============================================================================
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
# Enthaelt selbst keinerlei Installationslogik.
#
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
# denselben Fehler ein zweites Mal ab.
# =============================================================================
set -euo pipefail
APP="${1:-}"
TAG="${2:-latest}"
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
OWNER="${TESM_RELEASE_OWNER:-wis}"
REPO="${TESM_RELEASE_REPO:-tesm}"
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
exit 2
}
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
echo " $TAG"
fi
ASSET="$REPO-$TAG.tar.gz"
echo "==> Lade $ASSET ..."
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
echo "==> Entpacke ..."
tar -xzf "$WORK/$ASSET" -C "$WORK"
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if file "$SRC/deploy/install.sh" | grep -q CRLF; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
exit 1
fi
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
+295
View File
@@ -0,0 +1,295 @@
# Betrieb
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
---
## 1. Was wo liegt
| Zweck | Pfad |
|---|---|
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
| Protokolle | `/var/log/<name>/` |
| Umgebungsdatei | `/etc/tesm/<name>.env` |
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
| nginx-Site | `/etc/nginx/sites-available/<name>` |
| systemd-Unit | `/etc/systemd/system/<name>.service` |
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
wird von `install.sh` nie angefasst.
---
## 2. Installation
```bash
sudo bash deploy/install.sh --app tesm
sudo bash deploy/install.sh --app tesm-license
```
Wichtige Optionen:
| Option | Wirkung |
|---|---|
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
| `--skip-packages` | Keine Systempakete installieren. |
Danach:
```bash
sudo -u tesm tesm-admin create-admin
```
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
```bash
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern!
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
sudo -u tesm-license tesm-license-admin create-admin
```
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
> pruefbar. Sichern Sie ihn getrennt vom Server.
### Zwei Anwendungen auf einem Host
Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen:
```bash
sudo bash deploy/install.sh --app tesm --https
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local
```
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
sichtbar.
---
## 3. Aktualisierung
```bash
sudo bash deploy/install.sh --app tesm
```
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
Aktualisierung ist, und tut dann zusaetzlich:
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
unabhaengig davon, ob sich das Schema geaendert hat.
2. Dateiabgleich; `data/` bleibt unangetastet.
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
`--https`.
---
## 4. HTTPS
Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem
ACME-Pfad.**
Drei Wege dorthin:
**a) Bei der Installation** -- siehe oben, `--https`.
**b) Nachtraeglich ueber die Kommandozeile:**
```bash
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
--acme --acme-email it@firma.de --hsts --apply
```
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*.
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
Wege erledigen das selbst) und der Dienst neu gestartet werden.
### Die Site-Datei wird immer neu erzeugt
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
Anwendung.
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
### Selbstsigniert oder Let's Encrypt?
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
sinnvoll.
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure`
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
monatelang und die Warnung laesst sich dann nicht mehr wegklicken.
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot`
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
---
## 5. Dienste
```bash
systemctl status tesm.service # Weboberflaeche (gunicorn)
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
systemctl status tesm-license.service
journalctl -u tesm -n 100 -f
```
Die Units laufen unprivilegiert (`User=tesm`) mit
`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen
`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv/<name>/data`
und `/var/log/<name>`.
---
## 6. Root-Rechte
Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
```
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
```
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
`sh -c`).
Verben ansehen:
```bash
sudo /usr/local/lib/tesm/tesm-helper
grep -n ')$' /usr/local/lib/tesm/tesm-helper
```
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
---
## 7. Sicherung und Wiederherstellung
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
Vorschau, bevor irgendetwas geschrieben wird.
Auf dem Host reicht:
```bash
sudo systemctl stop tesm
sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data
sudo systemctl start tesm
```
Unverzichtbar:
* `data/app.db` -- alle Daten
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
* `data/license.json` und `data/license_key.json`
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
---
## 8. Wenn etwas nicht geht
**Die Anwendung antwortet nicht.**
```bash
journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit
```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
**nginx laesst sich nicht neu laden.**
```bash
sudo nginx -t
```
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
das er ueber HTTP nie zuruecksendet.
```bash
grep COOKIE_SECURE /etc/tesm/tesm.env
```
**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."**
Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
den Port.
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
```bash
grep alias /etc/nginx/sites-available/tesm
ls /srv/tesm/static /srv/tesm/static-core
```
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen.
**Zwei Instanzen stoeren sich.**
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".**
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
Freigabe auf der Switch-Detailseite.
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
eines Geraets, ist der Schluessel erneut freizugeben.
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
nicht mehr stimmt, wird genannt.
---
## 9. Zurueck auf eine aeltere Version
```bash
sudo systemctl stop tesm
sudo mv /srv/tesm /srv/tesm-kaputt
sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
sudo systemctl start tesm
```
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
genau das liegt im Backup-Verzeichnis.
+183
View File
@@ -0,0 +1,183 @@
# Sicherheit
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
---
## 1. Anmeldung
| | |
|---|---|
| Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
Fixation) und das CSRF-Token gewechselt.
### Erneute Bestaetigung ("sudo-Modus")
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
2FA-Aenderungen, Deaktivierung einer Lizenz.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
---
## 2. Sitzungen
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
Zufallskennung.
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
* Leerlaufzeit **und** absolute Hoechstdauer
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
unterscheiden keine Ports)
* Aktive Sitzungen sind einsehbar und einzeln beendbar
---
## 3. CSRF
Drei voneinander unabhaengige Schichten:
1. `SameSite=Lax` am Sitzungscookie.
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
demselben Rechner ist ein anderer Ursprung.
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
CSRF-Schutz waere eine Luecke.
---
## 4. Header
Bei jeder Antwort:
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
Inline-Script ohne Nonce, und JavaScript haengt ueber
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
werden; Style-Attribute fuehren keinen Code aus.
* `X-Content-Type-Options: nosniff`
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
* `Referrer-Policy: same-origin`
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
---
## 5. Geheimnisse im Ruhezustand
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
und nie ueber die Umgebung (`/proc/<pid>/environ`).
---
## 6. Rechtetrennung auf dem Host
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
Regeln, die im Repository durch Tests erzwungen werden:
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
* Kein `eval`, kein `bash -c`, kein `sh -c`.
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
den zulaessigen Basisnamen `x`).
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
---
## 7. Aenderungsprotokoll
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
---
## 8. Lizenzprotokoll
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
Aussteller-Pinning.
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
Aenderungsprotokoll.
---
## 9. Ein- und Ausfuhr
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
braucht der Dienst kein `--workers 1` mehr.
---
## 10. Bewusst nicht getan
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
einmal setzt, setzt ihn dauerhaft.
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
---
## 11. Wenn Sie eine Schwachstelle finden
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
Lizenzservers hinterlegte Adresse.
-43
View File
@@ -1,43 +0,0 @@
# Für den WebSocket-Upgrade des SSH-Terminals (/ws/ssh_terminal) nötig —
# ohne diesen Map-Trick würde nginx den "Connection: Upgrade"-Header nicht
# durchreichen und Flask/Werkzeug lehnt den Handshake dann mit 400 ab
# (live an einer echten Ubuntu-Installation reproduziert: direkt gegen
# Flask:5000 funktionierte der Handshake, durch nginx auf Port 80 nicht).
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
# Eigener Block vor "location /", da WebSockets lang offene Verbindungen
# sind — die kurzen Standard-Timeouts von nginx (60s) würden ein länger
# inaktives SSH-Terminal sonst hart trennen.
location /ws/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
alias /srv/tesm/static/;
expires 7d;
}
}
@@ -1,6 +0,0 @@
[Unit]
Description=Restart TESM Check
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart tesm-check.service
@@ -1,10 +0,0 @@
[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
-14
View File
@@ -1,14 +0,0 @@
[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
-23
View File
@@ -1,23 +0,0 @@
[Unit]
Description=TESM Web App
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/srv/tesm
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
# gthread" (blockierende WebSocket-Threads für das Browser-SSH-Terminal).
# "--timeout 0" deaktiviert Gunicorns Worker-Timeout: der greift sonst
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
# da nur ein Prozess läuft) killen.
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
--timeout 0 --bind 0.0.0.0:5000 app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# TESM -- Live-Flotten-Status im SSH-Login-Banner (dynamisches MOTD, siehe
# /etc/pam.d/sshd: "session optional pam_motd.so motd=/run/motd.dynamic").
# Ruft motd_banner.py im TESM-Quellverzeichnis über dessen venv auf, da
# app.py (die eigentliche Datenquelle) Flask u.a. importiert, die nur dort
# installiert sind -- gleiches Muster wie generate_ips.py/poe.sh.
# "timeout" verhindert, dass ein hängender DB-/Dateizugriff den Login
# blockiert; "|| true" sorgt dafür, dass ein Fehler hier nie den SSH-Login
# selbst stört -- im schlimmsten Fall bleibt der Banner einfach leer.
timeout 3 /srv/tesm/venv/bin/python3 /srv/tesm/motd_banner.py 2>/dev/null || true
-220
View File
@@ -1,220 +0,0 @@
#!/bin/bash
# ============================================================================
# TESM — Installer/Updater
# Richtet System-Pakete, die Flask-App (systemd) sowie den Erreichbarkeits-
# Check-Dienst (systemd + Timer) ein. Auszuführen als root, nachdem dieses
# Repo/Release-Paket z.B. nach /root/tesm entpackt wurde.
#
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
# existiert (an sqlite.db), und verhält sich entsprechend:
#
# - Keine vorhandene Installation -> normale Frischinstallation, kein
# Backup nötig (nichts, worauf zurückgerollt werden könnte).
# - Vorhanden (egal ob SCHEMA_VERSION gleich geblieben oder sich
# geändert hat) -> Update MIT Sicherheitsnetz: die komplette bisherige
# Installation (Code, venv, Datenbank, Schlüssel) wird vorher 1:1 nach
# /srv/tesm-backup-pre-update-<timestamp> kopiert. Eine eigentliche
# Schema-Migration übernimmt danach die App selbst beim Start (siehe
# _ensure_schema() in app.py). Kommt der Dienst anschließend
# nachweislich NICHT gesund hoch (HTTP-Check auf /login), wird
# automatisch die komplette Sicherung zurückgespielt (alter Code +
# alte Datenbank zusammen -- ein reines DB-Rollback allein würde
# denselben Fehler beim nächsten Start mit dem neuen Code sofort
# wiederholen) und das Backup bleibt zur manuellen Prüfung erhalten.
# Kommt er gesund hoch, gilt das Update als erfolgreich und das
# Backup wird gelöscht.
#
# Das Sicherheitsnetz greift bewusst bei JEDEM Update einer bestehenden
# Installation, nicht nur bei einer erkannten Schema-Änderung -- ein
# rein code-seitiger Fehler im neuen Paket hat mit dem Datenbank-Schema
# nichts zu tun, hätte den Dienst bei einem "Schema unverändert -> kein
# Backup"-Kurzschluss aber trotzdem ungeschützt lahmgelegt (live
# reproduziert). SCHEMA_VERSION bestimmt nur noch den Wortlaut der
# Log-Meldung (In-Place-Update vs. Migration), nicht mehr, OB
# abgesichert wird.
# ============================================================================
set -e
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung
# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft)
# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
# jede Fehlermeldung an dieser Stelle ab, noch bevor auch nur ein Paket
# installiert wurde (live so reproduziert). Rein kosmetisch, daher bewusst
# nie fatal.
clear 2>/dev/null || true
# ---- Bestehende Installation erkennen ----
# WICHTIG: das Sicherheitsnetz (Backup + Health-Check + ggf. Rückroll)
# greift bei JEDEM Update einer bestehenden Installation, nicht nur bei
# einer erkannten Schema-Änderung -- live so gefunden: ein rein
# code-seitiger Fehler im neuen Paket (z.B. ein kaputtes app.py, mit dem
# Schema selbst hat das nichts zu tun) hätte bei einem reinen "Schema
# unverändert -> kein Backup"-Kurzschluss ungeschützt den Dienst
# lahmgelegt, ganz ohne automatische Wiederherstellung. Die
# SCHEMA_VERSION-Auswertung dient jetzt nur noch der Log-Meldung (WARUM
# aktualisiert wird), nicht mehr der Entscheidung OB abgesichert wird.
EXISTING_INSTALL=0
[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1
NEEDS_MIGRATION_GUARD=0
BACKUP_DIR=""
if [ "$EXISTING_INSTALL" -eq 1 ]; then
OLD_SCHEMA_VERSION=""
[ -f /srv/tesm/SCHEMA_VERSION ] && OLD_SCHEMA_VERSION="$(tr -d '[:space:]' < /srv/tesm/SCHEMA_VERSION)"
NEW_SCHEMA_VERSION=""
[ -f "$REPO_DIR/srv/tesm/SCHEMA_VERSION" ] && NEW_SCHEMA_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/srv/tesm/SCHEMA_VERSION")"
step "Stopping tesm.service for update"
systemctl stop tesm.service tesm-check.service tesm-check-restart.timer 2>/dev/null || true
print_status "Service stopped"
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
else
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})"
else
reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt"
fi
echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}"
echo "Update mit automatischer Migration."
fi
NEEDS_MIGRATION_GUARD=1
BACKUP_DIR="/srv/tesm-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
step "Backing up current installation to $BACKUP_DIR before update"
cp -a /srv/tesm "$BACKUP_DIR"
print_status "Backup created"
fi
# ---- Pakete ----
step "Installing system packages"
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 (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/tesm) schreibt die App
# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs.
sudo mkdir -p /var/log/tesm
sudo chmod 777 /var/log/tesm
# ---- App-Verzeichnis ----
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/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
print_status "Application files copied"
# ---- Python venv ----
# "python3 -m pip" statt des "pip"-Skripts im venv selbst: dessen Shebang
# enthält einen zur Erstellungszeit FEST einprogrammierten, absoluten Pfad
# zum venv-Python. Wird das venv-Verzeichnis später umbenannt/verschoben
# (wie hier historisch bei der Umbenennung "poe_manager" -> "tesm"
# passiert), bleibt die alte, nicht mehr existierende Pfadangabe stehen --
# "./venv/bin/pip install ..." schlägt dann mit "cannot execute: required
# file not found" fehl (live auf POETEST reproduziert), obwohl das venv
# selbst voll funktionsfähig ist. "python3 -m pip" braucht dagegen nur den
# (korrekt referenzierten) venv-Python-Interpreter selbst.
step "Setting up Python virtual environment"
cd /srv/tesm
sudo python3 -m venv venv
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-install.log 2>&1
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1
print_status "Virtual environment ready"
# ---- Datenbank ----
if [ ! -f /srv/tesm/sqlite.db ]; then
step "Initializing database"
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/tesm/venv/bin/python3 /srv/tesm/create_admin.py"
fi
# ---- Custom-Skripte + systemd ----
step "Copying custom scripts and systemd units"
sudo mkdir -p /usr/local/bin/custom
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh
sudo chmod +x /usr/local/bin/custom/poe.sh
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/
print_status "Scripts and units copied"
# ---- SSH-Login-Banner (MOTD) ----
# Live-Flotten-Status (Online/Offline, DHCP) statt der Standard-Ubuntu-
# Willkommensmeldung -- siehe srv/tesm/motd_banner.py für die Datenquellen.
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
step "Setting up TESM SSH login banner"
sudo cp "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm
sudo chmod 755 /etc/update-motd.d/50-tesm
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
/etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
2>/dev/null || true
print_status "SSH login banner configured"
# ---- nginx ----
step "Configuring nginx reverse proxy"
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/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 tesm.service
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started"
# ---- Sicherheitsnetz prüfen ----
# Läuft bei JEDEM Update einer bestehenden Installation (NEEDS_MIGRATION_
# GUARD=1, siehe oben) -- nur bei einer Frischinstallation ist BACKUP_DIR
# leer und dieser Block wird komplett übersprungen.
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
step "Verifying migrated installation"
HEALTHY=0
for i in $(seq 1 15); do
sleep 2
if curl -fsS -o /dev/null http://127.0.0.1:5000/login 2>/dev/null; then
HEALTHY=1
break
fi
done
if [ "$HEALTHY" -eq 1 ]; then
print_status "Migration verified healthy"
rm -rf "$BACKUP_DIR"
echo -e "${GREEN}${NC} Update erfolgreich -- Backup wieder entfernt."
else
echo -e "${RED}✖ Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
sudo systemctl stop tesm.service 2>/dev/null || true
# Fehlgeschlagene Installation NICHT einfach löschen, sondern separat
# beiseite legen (zur Fehlersuche) -- und das Backup per "cp" statt
# "mv" zurückspielen, damit BEIDES danach getrennt erhalten bleibt
# (sonst würde das Backup beim Zurückspielen selbst zum neuen
# /srv/tesm werden und als eigenständige Sicherung verschwinden).
FAILED_DIR="/srv/tesm-failed-update-$(date +%Y%m%d-%H%M%S)"
sudo mv /srv/tesm "$FAILED_DIR"
sudo cp -a "$BACKUP_DIR" /srv/tesm
sudo systemctl start tesm.service
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}"
echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
echo "Bitte /var/log/tesm/app.log dort prüfen, bevor erneut aktualisiert wird."
fi
fi
echo -e "${GREEN}${NC} Installation abgeschlossen. Web-App erreichbar auf Port 80 (nginx) bzw. 5000 (Flask direkt)."
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.0.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
"Flask>=3.1",
"cryptography>=42",
"argon2-cffi>=23",
"tesm-licensing>=2.0.0",
]
[project.optional-dependencies]
ldap = ["ldap3>=2.9"]
legacy-hashes = ["bcrypt>=4"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
tesm_core = ["templates/**/*.html", "static/**/*"]
@@ -0,0 +1,38 @@
"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver.
Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff,
Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation,
Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das
Design-System.
"""
from __future__ import annotations
__version__ = "2.0.0"
from .app_factory import create_base_app
from .config import CoreConfig, SecurityConfig, load_core_config
from .db import Database, NestedConnectionError
from .extension import TesmCore, core, db, transaction
from .migrations import Migration
from .nav import NavItem, NavTree
from .settings_store import SettingSpec, SettingsStore
__all__ = [
"CoreConfig",
"Database",
"Migration",
"NavItem",
"NavTree",
"NestedConnectionError",
"SecurityConfig",
"SettingSpec",
"SettingsStore",
"TesmCore",
"core",
"create_base_app",
"db",
"load_core_config",
"transaction",
"__version__",
]
@@ -0,0 +1,374 @@
"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App.
Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur
noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank,
Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem,
Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier
genau einmal.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone
from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request
from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui
from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS
from .db import Database
from .extension import TesmCore, close_db, core, db
from .keystore import Keystore, load_or_create_secret_key
from .licensing_client import LicenseManager
from .migrations import Migration
from .nav import NavTree
from .rbac import service as rbac
from .rbac.model import PermissionTree, RolePreset
from .security import csrf
from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore
from .sysops import SysOps
from .auth import directory
from .auth import routes as auth_routes
from .auth import service as auth_service
from .auth.models import ANONYMOUS, current_user
from .views import account as account_views
from .views import admin as admin_views
from .views import diagnostics as diagnostics_views
from .views import hostsettings as hostsettings_views
from .views import logs as logs_views
from .views import trash as trash_views
CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
SettingSpec(
"trash_retention_days",
30,
"int",
"Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
section="system",
minimum=1,
maximum=3650,
),
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
SettingSpec("hostname", "", "str", "Hostname", section="system"),
SettingSpec(
"log_rotate_interval",
"weekly",
"str",
"Rotationsintervall der Protokolle",
section="system",
choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")),
),
SettingSpec(
"log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52
),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"license_server_ca",
"",
"str",
"Zertifikat des Lizenzservers (PEM)",
section="license",
),
SettingSpec("web_domain", "", "str", "Domain", section="web"),
SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", section="web"),
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443.
SettingSpec(
"web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
minimum=1, maximum=8192,
),
*directory.SETTINGS,
)
def create_base_app(
*,
config: CoreConfig,
permissions: PermissionTree,
nav: NavTree,
role_presets: Sequence[RolePreset],
app_migrations: dict[str, Sequence[Migration]] | None = None,
settings_specs: Sequence[SettingSpec] = (),
import_name: str = "tesm_core",
app_short: str = "TESM",
app_edition: str = "",
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
#: Funktionsliste von TESM waere dort schlicht falsch.
auth_tagline: str = "",
app_version: str = "2.0.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
static_folder: str | None = None,
) -> Flask:
app = Flask(
import_name,
template_folder=template_folder or "templates",
static_folder=static_folder or "static",
static_url_path="/assets",
)
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
# Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass
# sich Pfade in die Quere kommen.
core_bp = Blueprint(
"tesm_core",
"tesm_core",
template_folder="templates",
static_folder="static/tesm_core",
static_url_path="/core-assets",
)
app.register_blueprint(core_bp)
config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_dir.mkdir(parents=True, exist_ok=True)
app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
# Instanzen derselben Anwendung auf einem Host wuerden sich sonst
# gegenseitig die Sitzung ueberschreiben.
SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False,
PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http",
TEMPLATES_AUTO_RELOAD=config.debug,
EXPLAIN_TEMPLATE_LOADING=False,
)
app.debug = config.debug
logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug)
database = Database(config.db_path)
settings = SettingsStore([*CORE_SETTINGS, *settings_specs])
keystore = Keystore(config.data_key_file)
extension = TesmCore(
config=config,
database=database,
keystore=keystore,
permissions=permissions,
nav=nav,
settings=settings,
role_presets=tuple(role_presets),
migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})},
sysops=SysOps(app_key=config.app_key, site_key=config.site_key),
)
if enable_license:
extension.license = LicenseManager(
database=database,
settings=settings,
license_path=config.license_path,
key_path=config.license_key_path,
app_key=config.app_key,
built_in_trusted_keys=built_in_trusted_keys,
)
app.extensions["tesm"] = extension
app.session_interface = SqliteSessionInterface(
database,
cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours,
)
# -- Schema und Startzustand ------------------------------------------ #
applied = extension.run_migrations()
with database.session() as conn, database.transaction(conn):
created = rbac.ensure_presets(conn, permissions, extension.role_presets)
if applied or created:
audit.log(
conn,
actor="system",
action="system.startup_migration",
detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]),
severity="notice",
)
session_store.prune(conn)
if extension.license is not None:
with database.session() as conn:
extension.license.reload(conn)
# -- Request-Lebenszyklus --------------------------------------------- #
csrf.init_app(app)
ui.init_app(app)
app.teardown_appcontext(close_db)
@app.before_request
def _prepare_request() -> None:
g.csp_nonce = secrets.token_urlsafe(16)
auth_service.load_request_user()
@app.after_request
def _finish_request(response): # type: ignore[no-untyped-def]
hsts = ""
if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
return csrf.apply_security_headers(
response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts
)
# -- Template-Kontext -------------------------------------------------- #
from .rbac.model import ACTION_META
@app.context_processor
def _inject_context() -> dict[str, Any]:
user = getattr(g, "user", ANONYMOUS)
conn = db()
order = settings.get(conn, "nav_order") or nav.default_order()
child_order = settings.get(conn, "nav_child_order") or {}
pills: list[dict[str, str]] = []
if extension.license is not None and user.is_authenticated:
pill = extension.license.topbar_pill()
if pill:
pills.append(pill)
pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user))
nav_items = nav.resolve(user, order=order, child_order=child_order)
palette = [
{**entry, "url": _safe_url(entry["endpoint"])}
for entry in nav.searchable(user)
if entry["endpoint"]
]
return {
"app_name": config.app_name,
"app_short": app_short,
"app_edition": app_edition,
"auth_tagline": auth_tagline,
"app_version": app_version,
"current_year": datetime.now(timezone.utc).year,
"current_user": current_user,
"nav_items": nav_items,
"palette_entries": palette,
"status_pills": pills,
"csp_nonce": getattr(g, "csp_nonce", ""),
"permission_tree": permissions,
"action_meta": ACTION_META,
"license_state": extension.license.state if extension.license else None,
"license_active": extension.license.active() if extension.license else True,
"module_active": (
extension.license.module_active if extension.license else (lambda _n: True)
),
"feature_allowed": (
extension.license.feature_allowed if extension.license else (lambda _n: True)
),
"migration_warning": bool(extension.registry.get("migration_warning")),
}
# -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if _wants_json():
return {
"error": error.name,
"message": error.description,
"request_id": getattr(g, "request_id", ""),
}, error.code or 500
return (
render_template(
"tesm_core/error.html",
code=error.code or 500,
name=error.name,
description=error.description,
request_id=getattr(g, "request_id", ""),
back_url=request.referrer or "/",
),
error.code or 500,
)
@app.errorhandler(Exception)
def _unhandled(error: Exception): # type: ignore[no-untyped-def]
app.logger.exception("Unbehandelter Fehler: %s", error)
if _wants_json():
return {
"error": "Interner Fehler",
"message": "Der Vorgang konnte nicht abgeschlossen werden.",
"request_id": getattr(g, "request_id", ""),
}, 500
return (
render_template(
"tesm_core/error.html",
code=500,
name="Interner Fehler",
description=(
"Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde "
"protokolliert."
),
request_id=getattr(g, "request_id", ""),
back_url=request.referrer or "/",
),
500,
)
# -- Gemeinsame Blueprints -------------------------------------------- #
app.register_blueprint(auth_routes.bp)
app.register_blueprint(admin_views.bp)
app.register_blueprint(account_views.bp)
app.register_blueprint(diagnostics_views.bp)
app.register_blueprint(trash_views.bp)
app.register_blueprint(hostsettings_views.bp)
app.register_blueprint(logs_views.bp)
with app.app_context():
trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
return app
def _safe_url(endpoint: str) -> str:
from flask import url_for
try:
return url_for(endpoint)
except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht
return ""
def _wants_json() -> bool:
if request.path.startswith("/api/"):
return True
accept = request.accept_mimetypes
return bool(accept.accept_json and not accept.accept_html)
def _health() -> tuple[dict[str, Any], int]:
"""Gesundheitsendpunkt fuer install.sh und Monitoring.
Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur
"laeuft die App und antwortet die Datenbank".
"""
extension = core()
try:
with extension.database.session() as conn:
conn.execute("SELECT 1").fetchone()
except Exception: # noqa: BLE001
return {"status": "degraded"}, 503
return {"status": "ok", "version": extension.config.app_key}, 200
+283
View File
@@ -0,0 +1,283 @@
"""Aenderungsprotokoll mit Hash-Kette.
Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
die App lief als root), konnte Eintraege spurlos entfernen.
Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:
hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)
Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
geht es bei einem Protokoll.
Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from .db import Database
logger = logging.getLogger(__name__)
GENESIS = "0" * 64
ARCHIVE_THRESHOLD = 20000
ARCHIVE_TARGET = 15000
SEVERITIES = ("info", "notice", "warning", "critical")
def _canonical(row: dict[str, Any]) -> bytes:
payload = {
"ts": row.get("ts", ""),
"actor": row.get("actor", ""),
"actor_id": row.get("actor_id"),
"action": row.get("action", ""),
"target": row.get("target", ""),
"detail": row.get("detail", ""),
"ip": row.get("ip", ""),
"request_id": row.get("request_id", ""),
"severity": row.get("severity", "info"),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()
def log(
conn: sqlite3.Connection,
*,
actor: str,
action: str,
target: str = "",
detail: str = "",
actor_id: int | None = None,
ip: str = "",
request_id: str = "",
severity: str = "info",
ts: datetime | None = None,
) -> int:
"""Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.
Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
das war im Vorgaenger eine reale Deadlock-Ursache.
"""
if severity not in SEVERITIES:
severity = "info"
row = {
"ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
"actor": actor or "system",
"actor_id": actor_id,
"action": action,
"target": target,
"detail": detail,
"ip": ip,
"request_id": request_id,
"severity": severity,
}
prev_hash = (
Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
)
row_hash = compute_hash(str(prev_hash), row)
cursor = conn.execute(
"INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
"severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
row["ts"],
row["actor"],
row["actor_id"],
row["action"],
row["target"],
row["detail"],
row["ip"],
row["request_id"],
row["severity"],
prev_hash,
row_hash,
),
)
return int(cursor.lastrowid or 0)
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id"
params: tuple[Any, ...] = ()
if limit:
sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
params = (limit,)
checked = 0
expected_prev: str | None = None
for row in conn.execute(sql, params):
data = {key: row[key] for key in row.keys()}
if expected_prev is not None and data["prev_hash"] != expected_prev:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
}
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
}
expected_prev = str(data["hash"])
checked += 1
return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}
def search(
conn: sqlite3.Connection,
*,
query: str = "",
action: str = "",
actor: str = "",
severity: str = "",
limit: int = 200,
offset: int = 0,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if query:
clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
params.extend([f"%{query}%"] * 4)
if action:
clauses.append("action = ?")
params.append(action)
if actor:
clauses.append("actor = ?")
params.append(actor)
if severity:
clauses.append("severity = ?")
params.append(severity)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.extend([limit, offset])
return Database.all(
conn,
f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
tuple(params),
)
def count(conn: sqlite3.Connection) -> int:
return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)
def distinct_actions(conn: sqlite3.Connection) -> list[str]:
return [
str(row["action"])
for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
]
def archive_old_rows(
conn: sqlite3.Connection,
archive_dir: Path,
*,
threshold: int = ARCHIVE_THRESHOLD,
target: int = ARCHIVE_TARGET,
) -> list[Path]:
"""Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.
Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
"""
total = count(conn)
if total <= threshold:
return []
archive_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).date().isoformat()
written: list[Path] = []
while total > target:
# Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
# nachgereichten ts geschrieben worden sein (Import, Migration).
oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
if not oldest_day or str(oldest_day) >= today:
break
rows = Database.all(
conn,
"SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
(str(oldest_day),),
)
if not rows:
break
path = archive_dir / f"audit-{oldest_day}.jsonl"
with path.open("a", encoding="utf-8", newline="\n") as stream:
for row in rows:
stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
written.append(path)
total -= len(rows)
logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)
return written
def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
"""Liste der Archivdateien fuer die Verlaufsseite."""
if not archive_dir.exists():
return []
entries: list[dict[str, Any]] = []
for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
try:
stat = path.stat()
except OSError:
continue
entries.append(
{
"day": path.stem.removeprefix("audit-"),
"path": path,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
timespec="seconds"
),
}
)
return entries
def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
path = archive_dir / f"audit-{day}.jsonl"
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as stream:
for line in stream:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except ValueError:
continue
return rows
def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
buckets: dict[str, int] = defaultdict(int)
for row in rows:
buckets[str(row.get("ts", ""))[:10]] += 1
return sorted(buckets.items(), reverse=True)
@@ -0,0 +1,27 @@
"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung."""
from __future__ import annotations
from .models import (
ANONYMOUS,
AnonymousUser,
Group,
User,
admin_required,
current_user,
login_required,
permission_required,
reauth_required,
)
__all__ = [
"ANONYMOUS",
"AnonymousUser",
"Group",
"User",
"admin_required",
"current_user",
"login_required",
"permission_required",
"reauth_required",
]
@@ -0,0 +1,581 @@
"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).
"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.
Sicherheitsaenderungen gegenueber dem Vorgaenger:
* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
protokolliert.
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
einen Man-in-the-Middle.
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
ohne Kontext.
"""
from __future__ import annotations
import logging
import re
import socket
import sqlite3
import ssl
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
from .. import audit
from ..db import Database
from ..extension import core
from ..settings_store import SettingSpec
logger = logging.getLogger(__name__)
SERVICE_PURPOSE = "ldap_bind"
AAD_LDAP_BIND = "service_accounts.ldap_bind"
#: userAccountControl-Bit fuer "Konto deaktiviert".
ACCOUNTDISABLE = 0x0002
#: OID fuer rekursive Gruppenauflösung in AD.
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
Status = Literal["ok", "invalid", "unavailable", "disabled"]
SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
SettingSpec(
"ldap_security",
"ldaps",
"str",
"Transport",
section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec(
"ldap_allow_insecure",
False,
"bool",
"Unverschluesselte Verbindung ausdruecklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
section="ldap",
),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
)
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")
@dataclass(frozen=True, slots=True)
class DirectoryProfile:
username: str
dn: str
first_name: str = ""
last_name: str = ""
email: str = ""
group_dns: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class DirectoryResult:
status: Status
profile: DirectoryProfile | None = None
message: str = ""
warnings: tuple[str, ...] = field(default=())
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def is_enabled(conn: sqlite3.Connection) -> bool:
settings = core().settings
if not settings.get(conn, "ldap_enabled"):
return False
return bool(settings.get(conn, "ldap_host"))
def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
"""Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
row = Database.one(
conn,
"SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
(SERVICE_PURPOSE,),
)
if row is None:
return "", ""
encrypted = str(row.get("password_encrypted") or "")
if not encrypted:
return str(row.get("username") or ""), ""
try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret
def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
"""Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
existing = Database.one(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
if password:
encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
conn.execute(
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
"VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
(SERVICE_PURPOSE, username.strip(), encrypted, _now()),
)
# --------------------------------------------------------------------------- #
# Verbindung
# --------------------------------------------------------------------------- #
def _tls_object(config: dict[str, Any]):
from ldap3 import Tls
verify = bool(config["ldap_verify_cert"])
ca_file = str(config["ldap_ca_file"] or "") or None
return Tls(
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
version=ssl.PROTOCOL_TLS_CLIENT,
ca_certs_file=ca_file,
)
def _server(config: dict[str, Any]):
from ldap3 import Server
security = str(config["ldap_security"])
return Server(
str(config["ldap_host"]),
port=int(config["ldap_port"]),
use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL",
connect_timeout=int(config["ldap_timeout"]),
)
def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection
security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS."
)
connection = Connection(
_server(config),
user=user or None,
password=password or None,
auto_bind=False,
raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]),
)
if security == "starttls":
if not connection.open():
raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
if not connection.start_tls():
raise ConnectionError(
f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
)
if not connection.bind():
return connection, False
return connection, True
def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
settings = core().settings
return settings.get_many(conn, [spec.key for spec in SETTINGS])
def _base_dn(connection, config: dict[str, Any]) -> str:
configured = str(config["ldap_base_dn"] or "").strip()
if configured:
return configured
info = getattr(connection.server, "info", None)
naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
if isinstance(naming, list) and naming:
return str(naming[0])
if isinstance(naming, str):
return naming
contexts = getattr(info, "naming_contexts", None) if info else None
if contexts:
return str(contexts[0])
return ""
def _escape(value: str) -> str:
"""RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
return "".join(replacements.get(char, char) for char in value)
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
"""Suchen-und-Binden gegen das Verzeichnis."""
try:
import ldap3 # noqa: F401
except ImportError:
return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")
if not _USERNAME_RE.match(username or ""):
return DirectoryResult("invalid")
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return DirectoryResult("disabled")
bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = []
if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
try:
service_conn, bound = _connect(config, bind_user, bind_password)
except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
except (socket.error, ssl.SSLError, OSError) as exc:
logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
if not bound:
detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
return DirectoryResult(
"unavailable",
message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
warnings=tuple(warnings),
)
try:
base = _base_dn(service_conn, config)
if not base:
return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
)
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]
service_conn.search(
search_base=base,
search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
attributes=list(dict.fromkeys(wanted)),
size_limit=2,
)
entries = list(service_conn.entries)
if len(entries) != 1:
return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0]
dn = str(entry.entry_dn)
control = _attr_int(entry, "userAccountControl")
if control is not None and control & ACCOUNTDISABLE:
return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password)
try:
if not user_bound:
return DirectoryResult("invalid", warnings=tuple(warnings))
finally:
try:
user_conn.unbind()
except Exception: # noqa: BLE001
pass
group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn)
profile = DirectoryProfile(
username=_attr(entry, attribute) or username,
dn=dn,
first_name=_attr(entry, "givenName"),
last_name=_attr(entry, "sn"),
email=_attr(entry, email_attribute),
group_dns=group_dns,
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally:
try:
service_conn.unbind()
except Exception: # noqa: BLE001
pass
def _attr(entry: Any, name: str) -> str:
try:
value = entry[name].value
except Exception: # noqa: BLE001
return ""
if isinstance(value, (list, tuple)):
return str(value[0]) if value else ""
return "" if value is None else str(value)
def _attr_int(entry: Any, name: str) -> int | None:
raw = _attr(entry, name)
try:
return int(raw)
except (TypeError, ValueError):
return None
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
try:
connection.search(
search_base=base,
search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
attributes=["distinguishedName", "cn"],
size_limit=500,
)
return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
return ()
# --------------------------------------------------------------------------- #
# Schattenkonto
# --------------------------------------------------------------------------- #
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
from ..rbac import service as rbac
row = rbac.find_user_by_username(conn, profile.username)
with core().database.transaction(conn):
if row is None:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
(
profile.username,
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
profile.first_name,
profile.last_name,
profile.email,
_now(),
_now(),
),
)
user_id = int(cursor.lastrowid or 0)
audit.log(
conn,
actor=profile.username,
actor_id=user_id,
action="auth.directory_account_created",
target=profile.username,
detail=profile.dn,
severity="notice",
)
else:
user_id = int(row["id"])
conn.execute(
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
"updated_at=? WHERE id=?",
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
)
_apply_group_mappings(conn, user_id, profile.group_dns)
result = rbac.get_user(conn, user_id)
assert result is not None
return result
def _apply_group_mappings(
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
) -> None:
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
"""
if not group_dns:
return
mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
)
if not mappings:
return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns}
wanted = {
int(row["app_group_id"])
for row in mappings
if str(row["ad_group_dn"]).lower() in lowered
}
current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
# Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
keep = {gid for gid in current if gid not in mapped_group_ids}
final = keep | wanted
if final == current:
return
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[(user_id, gid) for gid in sorted(final)],
)
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
def search_directory_users(
conn: sqlite3.Connection, query: str, *, limit: int = 20
) -> list[dict[str, str]]:
"""Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
if len(query.strip()) < 2:
return []
try:
import ldap3 # noqa: F401
except ImportError:
return []
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return []
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception: # noqa: BLE001
return []
if not bound:
return []
try:
base = _base_dn(connection, config)
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
escaped = _escape(query.strip())
connection.search(
search_base=base,
search_filter=(
f"(&(objectClass=user)(|({attribute}={escaped}*)"
f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
),
attributes=[attribute, "givenName", "sn", email_attribute],
size_limit=limit,
)
return [
{
"username": _attr(entry, attribute),
"first_name": _attr(entry, "givenName"),
"last_name": _attr(entry, "sn"),
"email": _attr(entry, email_attribute),
"dn": str(entry.entry_dn),
}
for entry in connection.entries
if _attr(entry, attribute)
]
except Exception: # noqa: BLE001
return []
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
"""Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
if len(query.strip()) < 2:
return []
try:
import ldap3 # noqa: F401
except ImportError:
return []
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return []
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception: # noqa: BLE001
return []
if not bound:
return []
try:
base = _base_dn(connection, config)
escaped = _escape(query.strip())
connection.search(
search_base=base,
search_filter=f"(&(objectClass=group)(cn={escaped}*))",
attributes=["cn", "distinguishedName"],
size_limit=limit,
)
return [
{"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
]
except Exception: # noqa: BLE001
return []
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
try:
import ldap3 # noqa: F401
except ImportError:
return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
config = _read_config(conn)
if not config.get("ldap_host"):
return {"ok": False, "message": "Es ist kein Server konfiguriert."}
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception as exc: # noqa: BLE001
return {"ok": False, "message": str(exc)}
if not bound:
detail = connection.result.get("description", "unbekannt") if connection.result else ""
return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
try:
base = _base_dn(connection, config)
return {
"ok": True,
"message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
"base_dn": base,
}
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
@@ -0,0 +1,297 @@
"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren."""
from __future__ import annotations
import functools
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Sequence
from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree
AUTH_SOURCES = ("local", "ldap")
@dataclass(frozen=True, slots=True)
class Group:
id: int
name: str
is_system: bool = False
is_default: bool = False
@dataclass(slots=True)
class User:
"""Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests."""
id: int
username: str
is_admin: bool = False
is_locked: bool = False
auth_source: str = "local"
first_name: str = ""
last_name: str = ""
email: str = ""
avatar_filename: str = ""
totp_enabled: bool = False
must_change_password: bool = False
groups: tuple[Group, ...] = ()
granted: frozenset[str] = frozenset()
_tree: PermissionTree | None = field(default=None, repr=False, compare=False)
_effective: frozenset[str] | None = field(default=None, repr=False, compare=False)
# -- Anzeige ------------------------------------------------------- #
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
full = f"{self.first_name} {self.last_name}".strip()
return full or self.username
@property
def initials(self) -> str:
parts = [p for p in (self.first_name, self.last_name) if p]
if parts:
return "".join(p[0] for p in parts[:2]).upper()
return (self.username[:2] or "?").upper()
@property
def role_label(self) -> str:
if self.is_admin:
return "Administrator"
if self.groups:
return ", ".join(group.name for group in self.groups)
return "Ohne Gruppe"
@property
def is_directory_account(self) -> bool:
return self.auth_source == "ldap"
# -- Rechte -------------------------------------------------------- #
@property
def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
if self._effective is None:
tree = self._tree
self._effective = (
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
)
return self._effective
def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
if self.is_admin:
return True
if not permissions:
return True
return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions
return any(permission in effective for permission in permissions)
def can(self, resource: str, action: Action | str) -> bool:
value = action.value if isinstance(action, Action) else str(action)
return self.has(f"{resource}.{value}")
def can_any(self, resource: str, *actions: Action | str) -> bool:
return self.has_any(
f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions
)
def as_audit_actor(self) -> str:
return self.username
class AnonymousUser:
"""Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``."""
id = 0
username = ""
is_admin = False
is_locked = False
auth_source = "anonymous"
first_name = ""
last_name = ""
email = ""
avatar_filename = ""
totp_enabled = False
must_change_password = False
groups: tuple[Group, ...] = ()
granted: frozenset[str] = frozenset()
@property
def is_authenticated(self) -> bool:
return False
@property
def display_name(self) -> str:
return "Gast"
@property
def initials(self) -> str:
return "?"
@property
def role_label(self) -> str:
return "Nicht angemeldet"
@property
def is_directory_account(self) -> bool:
return False
@property
def effective_permissions(self) -> frozenset[str]:
return frozenset()
def has(self, *permissions: str) -> bool:
return False
def has_any(self, permissions: Iterable[str]) -> bool:
return False
def can(self, resource: str, action: Action | str) -> bool:
return False
def can_any(self, resource: str, *actions: Action | str) -> bool:
return False
def as_audit_actor(self) -> str:
return "anonym"
ANONYMOUS = AnonymousUser()
def _lookup_user() -> User | AnonymousUser:
return getattr(g, "user", ANONYMOUS)
#: Aktueller Benutzer -- in Templates und Views gleich benutzbar.
current_user: Any = LocalProxy(_lookup_user)
# --------------------------------------------------------------------------- #
# Dekoratoren
# --------------------------------------------------------------------------- #
def _deny(message: str) -> Any:
"""Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403."""
if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"):
session["_after_login"] = request.full_path if request.query_string else request.path
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
abort(401, description="Anmeldung erforderlich.")
flash("Bitte melden Sie sich an.", "info")
return redirect(url_for("auth.login"))
abort(403, description=message)
def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not current_user.is_authenticated:
return _deny("Anmeldung erforderlich.")
return view(*args, **kwargs)
return wrapper
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig.
``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren --
wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht
erreichbar war, obwohl das Recht vergeben war.
"""
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
ok = (
current_user.has_any(permissions)
if mode == "any"
else current_user.has(*permissions)
)
if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined]
return wrapper
return decorator
def admin_required(view: Callable[..., Any]) -> Callable[..., Any]:
"""Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar.
Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch
nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst,
Navigationsreihenfolge, Schluesselrotation).
"""
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not current_user.is_authenticated or not current_user.is_admin:
return _deny("Diese Aktion ist Administratoren vorbehalten.")
return view(*args, **kwargs)
return wrapper
def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
"""Verlangt eine frische Passworteingabe ("sudo-Modus").
Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System
heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus.
"""
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
from .service import reauth_is_fresh # zyklischen Import vermeiden
if not current_user.is_authenticated:
return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
return view(*args, **kwargs)
return wrapper
def build_user(
row: dict[str, Any],
*,
groups: Sequence[Group],
granted: Iterable[str],
tree: PermissionTree,
) -> User:
return User(
id=int(row["id"]),
username=str(row["username"]),
is_admin=bool(row.get("is_admin")),
is_locked=bool(row.get("is_locked")),
auth_source=str(row.get("auth_source") or "local"),
first_name=str(row.get("first_name") or ""),
last_name=str(row.get("last_name") or ""),
email=str(row.get("email") or ""),
avatar_filename=str(row.get("avatar_filename") or ""),
totp_enabled=bool(row.get("totp_secret")),
must_change_password=bool(row.get("must_change_password")),
groups=tuple(groups),
granted=frozenset(granted),
_tree=tree,
)
@@ -0,0 +1,135 @@
"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung."""
from __future__ import annotations
from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from ..extension import core, db
from . import directory, service
from .models import current_user, login_required
bp = Blueprint("auth", __name__)
def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str:
"""Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung."""
default = url_for(fallback_endpoint)
if not candidate:
return default
parsed = urlparse(candidate)
if parsed.scheme or parsed.netloc or not candidate.startswith("/"):
return default
return candidate
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(_safe_next(session.pop("_after_login", None)))
conn = db()
username = ""
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
outcome = service.authenticate(
conn, username, password, ip=service.client_ip()
)
if outcome.status == "needs_totp" and outcome.user_row:
service.start_totp_challenge(outcome.user_row)
return redirect(url_for("auth.second_factor"))
if outcome.ok and outcome.user_row:
user = service.complete_login(
conn,
outcome.user_row,
ip=service.client_ip(),
secret=password,
group_dns=outcome.group_dns,
)
flash(f"Willkommen, {user.display_name}.", "success")
if user.must_change_password:
flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning")
return redirect(url_for("account.security"))
return redirect(_safe_next(session.pop("_after_login", None)))
flash(outcome.message or service.GENERIC_FAILURE, "danger")
return render_template(
"tesm_core/login.html",
username=username,
directory_enabled=directory.is_enabled(conn),
public_dashboard_url=core().registry.get("public_dashboard_url", ""),
)
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
def second_factor():
conn = db()
user_row = service.pending_totp_user(conn)
if user_row is None:
flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info")
return redirect(url_for("auth.login"))
if request.method == "POST":
outcome = service.verify_second_factor(
conn, user_row, request.form.get("code") or "", ip=service.client_ip()
)
if outcome.ok:
# Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht
# mehr vor, und es fuer den zweiten Schritt in der Sitzung
# zwischenzulagern waere ein schlechter Tausch. Freigaben, die die
# Anmeldung des Benutzers weiterreichen, binden sich bei aktivem
# zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem
# Konto schon.
user = service.complete_login(conn, user_row, ip=service.client_ip())
flash(f"Willkommen, {user.display_name}.", "success")
return redirect(_safe_next(session.pop("_after_login", None)))
flash(outcome.message, "danger")
return render_template("tesm_core/second_factor.html")
@bp.post("/logout")
@login_required
def logout():
service.logout(db())
flash("Sie wurden abgemeldet.", "info")
return redirect(url_for("auth.login"))
@bp.get("/logout")
def logout_get():
"""Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts.
Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang
mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft
trotzdem ueber POST mit Token.
"""
if current_user.is_authenticated:
service.logout(db())
else:
session.clear()
return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"])
@login_required
def reauth():
target = _safe_next(session.get("_after_reauth"))
if request.method == "POST":
password = request.form.get("password") or ""
if service.confirm_password(db(), current_user, password):
service.mark_reauth()
session.pop("_after_reauth", None)
return redirect(target)
flash("Das Passwort ist nicht korrekt.", "danger")
return render_template(
"tesm_core/reauth.html",
cancel_url=url_for("index"),
reauth_minutes=core().config.security.reauth_valid_minutes,
)
@@ -0,0 +1,499 @@
"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.
Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):
1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
Hash berechnet wird.
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.
Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
"""
from __future__ import annotations
import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from flask import current_app, g, request, session
from .. import audit
from ..db import Database
from ..extension import core, db
from ..rbac import service as rbac
from ..security import passwords, ratelimit
from ..security import sessions as session_store
from .models import ANONYMOUS, Group, User, build_user
logger = logging.getLogger(__name__)
SESSION_USER_KEY = "user_id"
SESSION_PENDING_KEY = "_pending_user_id"
SESSION_REAUTH_KEY = "_reauth_at"
BUCKET_LOGIN_USER = "login:user"
BUCKET_LOGIN_IP = "login:ip"
BUCKET_TOTP = "totp:user"
GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."
Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]
@dataclass(frozen=True, slots=True)
class AuthOutcome:
status: Status
message: str = ""
user_row: dict[str, Any] | None = None
retry_after: int = 0
#: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
#: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
#: Mal koennen es andere sein.
group_dns: tuple[str, ...] = ()
@property
def ok(self) -> bool:
return self.status == "ok"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def client_ip() -> str:
"""Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
hops = core().config.security.trusted_proxy_hops
if hops > 0:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
# Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
index = max(0, len(parts) - hops)
return parts[index]
return request.remote_addr or ""
# --------------------------------------------------------------------------- #
# Benutzer laden
# --------------------------------------------------------------------------- #
def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
row = Database.one(
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
)
if row is None or row.get("is_locked"):
return None
groups = [
Group(
id=int(entry["id"]),
name=str(entry["name"]),
is_system=bool(entry["is_system"]),
is_default=bool(entry["is_default"]),
)
for entry in rbac.user_groups(conn, user_id)
]
return build_user(
row,
groups=groups,
granted=rbac.granted_permissions(conn, user_id),
tree=core().permissions,
)
def load_request_user() -> None:
"""``before_request``-Hook: setzt ``g.user``."""
g.user = ANONYMOUS
user_id = session.get(SESSION_USER_KEY)
if not user_id:
return
user = load_user(db(), int(user_id))
if user is None:
# Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
session.clear()
return
g.user = user
# --------------------------------------------------------------------------- #
# Anmeldung
# --------------------------------------------------------------------------- #
def authenticate(
conn: sqlite3.Connection, username: str, password: str, *, ip: str
) -> AuthOutcome:
security = core().config.security
name = (username or "").strip()
if not name or not password:
return AuthOutcome("invalid", GENERIC_FAILURE)
ip_verdict = ratelimit.check(
conn,
BUCKET_LOGIN_IP,
ip or "unbekannt",
limit=security.login_max_attempts_per_ip,
window_minutes=security.login_lockout_minutes,
)
if ip_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds,
)
user_verdict = ratelimit.check(
conn,
BUCKET_LOGIN_USER,
name.lower(),
limit=security.login_max_attempts,
window_minutes=security.login_lockout_minutes,
)
if user_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds,
)
row = rbac.find_user_by_username(conn, name)
outcome = _verify_credentials(conn, row, name, password)
if not outcome.ok:
with core().database.transaction(conn):
ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
audit.log(
conn,
actor=name,
action="auth.login_failed",
target=name,
detail=outcome.status,
ip=ip,
severity="warning",
request_id=getattr(g, "request_id", ""),
)
return outcome
user_row = outcome.user_row or {}
if bool(user_row.get("is_locked")):
with core().database.transaction(conn):
audit.log(
conn,
actor=str(user_row.get("username") or name),
action="auth.login_locked",
target=str(user_row.get("username") or name),
ip=ip,
severity="warning",
)
return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")
with core().database.transaction(conn):
ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())
if str(user_row.get("totp_secret") or ""):
return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)
if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
# Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
# einrichten (die Kontoseite fuehrt ihn dorthin).
logger.warning(
"Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
)
# ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
# laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
# erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
# haengt.
return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)
def _verify_credentials(
conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
) -> AuthOutcome:
from . import directory # lokaler Import: ldap3 ist optional
directory_enabled = directory.is_enabled(conn)
if row is not None and str(row.get("auth_source")) == "local":
if not passwords.verify_password(str(row.get("password_hash") or ""), password):
return AuthOutcome("invalid", GENERIC_FAILURE)
if passwords.needs_rehash(str(row.get("password_hash") or "")):
with core().database.transaction(conn):
conn.execute(
"UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
(passwords.hash_password(password), _iso(_now()), row["id"]),
)
return AuthOutcome("ok", user_row=row)
if not directory_enabled:
passwords.dummy_verify()
return AuthOutcome("invalid", GENERIC_FAILURE)
result = directory.authenticate(conn, name, password)
if result.status == "unavailable":
return AuthOutcome(
"directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.",
)
if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE)
user_row = directory.sync_account(conn, result.profile)
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def complete_login(
conn: sqlite3.Connection,
user_row: dict[str, Any],
*,
ip: str,
secret: str = "",
group_dns: tuple[str, ...] = (),
) -> User:
"""Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).
``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
genau von einem: der Einbindung von Dateifreigaben, die sich mit der
Anmeldung des Benutzers am Fileserver anmeldet.
"""
from ..security import csrf
with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.login",
target=str(user_row["username"]),
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip,
request_id=getattr(g, "request_id", ""),
)
session.clear()
session[SESSION_USER_KEY] = int(user_row["id"])
session[SESSION_REAUTH_KEY] = _iso(_now())
session["_ip"] = ip
session["_ua"] = (request.user_agent.string or "")[:200]
csrf.rotate_token()
user = load_user(conn, int(user_row["id"]))
if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
g.user = user
_run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
return user
def _run_login_hooks(
conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
) -> None:
"""Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.
Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
wird jeder Fehler protokolliert und geschluckt.
"""
for hook in core().login_hooks:
try:
hook(conn, user, secret, group_dns)
except Exception: # pragma: no cover - haengt am Fileserver
current_app.logger.exception(
"Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
)
def logout(conn: sqlite3.Connection) -> None:
user = getattr(g, "user", ANONYMOUS)
if user.is_authenticated:
# Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
# raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
for hook in core().logout_hooks:
try:
hook(conn, user)
except Exception: # pragma: no cover - haengt am Fileserver
current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
)
with core().database.transaction(conn):
audit.log(
conn,
actor=user.username,
actor_id=user.id,
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS
# --------------------------------------------------------------------------- #
# Zweiter Faktor
# --------------------------------------------------------------------------- #
def start_totp_challenge(user_row: dict[str, Any]) -> None:
session[SESSION_PENDING_KEY] = int(user_row["id"])
session["_pending_since"] = _iso(_now())
def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
user_id = session.get(SESSION_PENDING_KEY)
if not user_id:
return None
started = session.get("_pending_since")
if started:
try:
if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
session.pop(SESSION_PENDING_KEY, None)
return None
except ValueError:
pass
return Database.one(
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
)
def verify_second_factor(
conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
) -> AuthOutcome:
from ..security import totp
security = core().config.security
subject = str(user_row["id"])
verdict = ratelimit.check(
conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
)
if verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
retry_after=verdict.retry_after_seconds,
)
cleaned = (code or "").strip()
if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
with core().database.transaction(conn):
ratelimit.clear(conn, BUCKET_TOTP, subject)
return AuthOutcome("ok", user_row=user_row)
if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
return AuthOutcome("ok", user_row=user_row)
with core().database.transaction(conn):
ratelimit.record(conn, BUCKET_TOTP, subject)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.totp_failed",
target=str(user_row["username"]),
ip=ip,
severity="warning",
)
return AuthOutcome("invalid", "Der Code ist nicht korrekt.")
def _consume_recovery_code(
conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
) -> bool:
candidates = Database.all(
conn,
"SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
(user_id,),
)
normalized = code.strip().lower()
for entry in candidates:
if passwords.verify_password(str(entry["code_hash"]), normalized):
with core().database.transaction(conn):
conn.execute(
"UPDATE user_recovery_codes SET used_at=? WHERE id=?",
(_iso(_now()), entry["id"]),
)
remaining = int(
Database.value(
conn,
"SELECT COUNT(*) FROM user_recovery_codes "
"WHERE user_id=? AND used_at IS NULL",
(user_id,),
default=0,
)
or 0
)
audit.log(
conn,
actor=str(
Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
or ""
),
actor_id=user_id,
action="auth.recovery_code_used",
detail=f"Verbleibende Codes: {remaining}",
ip=ip,
severity="notice",
)
return True
return False
# --------------------------------------------------------------------------- #
# Erneute Bestaetigung ("sudo-Modus")
# --------------------------------------------------------------------------- #
def reauth_is_fresh() -> bool:
stamp = session.get(SESSION_REAUTH_KEY)
if not stamp:
return False
try:
moment = datetime.fromisoformat(str(stamp))
except ValueError:
return False
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
window = timedelta(minutes=core().config.security.reauth_valid_minutes)
return _now() - moment <= window
def mark_reauth() -> None:
session[SESSION_REAUTH_KEY] = _iso(_now())
def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
"""Prueft das Passwort des angemeldeten Benutzers erneut.
Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
koennten LDAP-Nutzer den sudo-Modus nie erreichen.
"""
from . import directory
row = rbac.get_user(conn, user.id)
if row is None:
return False
if str(row.get("auth_source")) == "local":
return passwords.verify_password(str(row.get("password_hash") or ""), password)
if not directory.is_enabled(conn):
return False
return directory.authenticate(conn, user.username, password).status == "ok"
+230
View File
@@ -0,0 +1,230 @@
"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen.
Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier.
Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus
der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die
Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil
irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript,
das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in
neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung
schreiben -- genau wie die Oberflaeche es tut.
Aufruf aus ``install.sh``:
tesm-admin web-setup --https --self-signed --hsts --apply
tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply
"""
from __future__ import annotations
import argparse
import sys
from typing import Any
from .system import webserver
def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
values = extension.settings.get_many(
conn,
[
"web_domain",
"web_https_enabled",
"web_hsts_enabled",
"web_redirect_http",
"web_cert_path",
"web_key_path",
"web_acme_webroot",
"web_max_body_mb",
"web_http_port",
"web_https_port",
],
)
return webserver.WebConfig(
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(
extension.registry.get("static_root", extension.config.base_dir / "static")
),
domain=str(values["web_domain"] or ""),
https_enabled=bool(values["web_https_enabled"]),
hsts_enabled=bool(values["web_hsts_enabled"]),
redirect_http=bool(values["web_redirect_http"]),
cert_path=str(values["web_cert_path"] or ""),
key_path=str(values["web_key_path"] or ""),
acme_webroot=str(
values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"
),
websocket_path=str(extension.registry.get("websocket_path", "")),
max_body_mb=int(values["web_max_body_mb"] or 128),
http_port=int(values["web_http_port"] or 80),
https_port=int(values["web_https_port"] or 443),
)
def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
"""Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei."""
sysops = extension.sysops
with app.app_context(), extension.database.session() as conn:
values: dict[str, Any] = {}
if args.domain is not None:
try:
values["web_domain"] = webserver.validate_domain(args.domain)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
if args.acme_email:
values["web_acme_email"] = args.acme_email.strip()
if args.max_body_mb:
values["web_max_body_mb"] = int(args.max_body_mb)
for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")):
port = getattr(args, flag, None)
if port:
if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr)
return 2
values[key] = int(port)
with extension.database.transaction(conn):
if values:
extension.settings.set_many(conn, values, actor="install")
# -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
if args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
cert = webserver.request_certificate(
sysops,
domain=config.domain,
email=str(extension.settings.get(conn, "web_acme_email") or ""),
webroot=config.acme_webroot,
)
if not cert["ok"] and args.self_signed:
# Kein oeffentliches Zertifikat bekommen -- lieber
# selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt
# beides.
print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}")
print("Weiche auf ein selbstsigniertes Zertifikat aus.")
args.acme = False
elif not cert["ok"]:
print(cert.get("detail") or cert["message"], file=sys.stderr)
return 1
if args.self_signed and not args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
suggested = webserver.local_names()
cert = webserver.create_self_signed(
sysops,
common_name=config.domain or (suggested[0] if suggested else "localhost"),
alt_names=[*suggested, "localhost"],
)
if not cert["ok"]:
print(cert.get("detail") or cert["message"], file=sys.stderr)
return 1
if cert.get("cert_path"):
with extension.database.transaction(conn):
extension.settings.set_many(
conn,
{"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]},
actor="install",
)
print(cert["message"])
# -- HTTPS-Schalter ------------------------------------------------ #
config = _config_from_settings(extension, conn)
if args.https:
if not (config.cert_path and config.key_path):
print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
"oder --acme angeben.",
file=sys.stderr,
)
return 2
with extension.database.transaction(conn):
extension.settings.set_many(
conn,
{
"web_https_enabled": True,
"web_hsts_enabled": bool(args.hsts),
"web_redirect_http": not args.no_redirect,
},
actor="install",
)
config = _config_from_settings(extension, conn)
rendered = webserver.render(config)
if args.print_config:
print(rendered)
return 0
if not args.apply:
print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.")
return 0
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
result = webserver.apply_config(sysops, rendered)
print(result["message"])
if not result["ok"]:
print(result.get("detail", ""), file=sys.stderr)
return 1
if config.https_enabled:
detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um"
else:
detail = f"HTTP auf {config.http_port}"
print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}")
return 0
def add_web_setup(sub: Any, runner: Any) -> None:
"""Haengt ``web-setup`` an einen ``argparse``-Subparser."""
parser = sub.add_parser(
"web-setup",
help="nginx und TLS einrichten (Port 80/443)",
description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
),
)
parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren")
parser.add_argument(
"--self-signed",
action="store_true",
help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)",
)
parser.add_argument(
"--acme",
action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
)
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument(
"--no-redirect",
action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
)
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
)
parser.set_defaults(func=runner)
__all__ = ["add_web_setup", "run_web_setup"]
+205
View File
@@ -0,0 +1,205 @@
"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle.
Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere
tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das
beim Start gebaut, validiert und danach nicht mehr veraendert wird.
Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw.
``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen
laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten
(netplan-Override!).
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field, replace
from pathlib import Path
def _env(prefix: str, name: str, default: str = "") -> str:
return os.environ.get(f"{prefix}{name}", default).strip()
def _env_bool(prefix: str, name: str, default: bool) -> bool:
raw = _env(prefix, name)
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on"}
def _env_int(prefix: str, name: str, default: int) -> int:
raw = _env(prefix, name)
try:
return int(raw) if raw else default
except ValueError:
return default
@dataclass(frozen=True, slots=True)
class SecurityConfig:
"""Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl."""
#: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS).
cookie_secure: bool = True
cookie_samesite: str = "Lax"
#: Leerlauf-Zeitfenster einer Sitzung.
session_idle_minutes: int = 60
#: Harte Obergrenze unabhaengig von Aktivitaet.
session_absolute_hours: int = 12
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
reauth_valid_minutes: int = 15
#: Fehlversuche bis zur temporaeren Sperre eines Kontos.
login_max_attempts: int = 8
login_lockout_minutes: int = 15
#: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto).
login_max_attempts_per_ip: int = 30
#: Mindestlaenge lokaler Passwoerter.
password_min_length: int = 12
#: 2FA fuer Administratoren erzwingen.
require_totp_for_admins: bool = False
#: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird).
hsts_enabled: bool = False
hsts_max_age: int = 15552000
#: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren).
trusted_proxy_hops: int = 1
#: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt).
max_content_length: int | None = 64 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class CoreConfig:
"""Basiskonfiguration, die beide Anwendungen teilen."""
app_key: str
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
site_key: str
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei,
logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird
ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``.
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und
beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie
teilen (Cookies unterscheiden keine Ports).
"""
app_name: str
"""Anzeigename in der Oberflaeche."""
env_prefix: str
base_dir: Path
data_dir: Path
log_dir: Path
db_path: Path
secret_key_file: Path
data_key_file: Path
license_path: Path
license_key_path: Path
#: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx
#: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie
#: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen.
#: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite
#: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die
#: *andere* Installation zeigte.
bind: str = "127.0.0.1:5000"
debug: bool = False
#: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit).
web_process: bool = False
security: SecurityConfig = field(default_factory=SecurityConfig)
#: Zusaetzliche, app-spezifische Werte.
extra: dict[str, str] = field(default_factory=dict)
@property
def is_production(self) -> bool:
return not self.debug
def path_in_data(self, name: str) -> Path:
return self.data_dir / name
def load_core_config(
*,
app_key: str,
app_name: str,
env_prefix: str,
default_base: Path | None = None,
default_bind: str = "127.0.0.1:5000",
) -> CoreConfig:
"""Liest die Konfiguration aus der Umgebung.
Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis
``instance/<app_key>`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist
die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach
``/srv``/``/var/log`` zu greifen.
"""
base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key))
base = base.expanduser().resolve()
data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser()
log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser()
security = SecurityConfig(
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15),
login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30),
password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12),
require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False),
hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False),
hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000),
trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1),
max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None,
)
debug = _env_bool(env_prefix, "DEBUG", False)
if debug:
# Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie
# wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren.
# dataclasses.replace, nicht __dict__: SecurityConfig hat slots und
# damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen
# AttributeError, und zwar genau im Entwicklungsmodus, den das README
# empfiehlt.
security = replace(
security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False)
)
bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
return CoreConfig(
app_key=app_key,
site_key=site_key,
bind=bind,
app_name=_env(env_prefix, "APP_NAME") or app_name,
env_prefix=env_prefix,
base_dir=base,
data_dir=data_dir,
log_dir=log_dir,
db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(),
secret_key_file=Path(
_env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key"
).expanduser(),
data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(),
license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(),
license_key_path=Path(
_env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json"
).expanduser(),
debug=debug,
web_process=_env(env_prefix, "WEB_PROCESS") == "1",
security=security,
)
@@ -0,0 +1,193 @@
"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings.
Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen
als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen
werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin).
"""
from __future__ import annotations
from .migrations import Migration
V1_BASELINE = """
-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem
-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert).
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
is_locked INTEGER NOT NULL DEFAULT 0,
auth_source TEXT NOT NULL DEFAULT 'local',
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
avatar_filename TEXT NOT NULL DEFAULT '',
totp_secret TEXT NOT NULL DEFAULT '',
totp_confirmed_at TEXT,
must_change_password INTEGER NOT NULL DEFAULT 0,
password_changed_at TEXT,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im
-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung
-- den Konflikt bewusst behandelt.
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live
ON users(username) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
description TEXT NOT NULL DEFAULT '',
is_system INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
preset_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live
ON groups(name) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS group_permissions (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission)
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein
-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her.
CREATE TABLE IF NOT EXISTS user_recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id);
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
idle_expires_at TEXT NOT NULL,
absolute_expires_at TEXT NOT NULL,
reauth_at TEXT,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{}',
revoked_at TEXT,
revoked_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at);
-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API).
CREATE TABLE IF NOT EXISTS rate_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket TEXT NOT NULL,
subject TEXT NOT NULL,
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT ''
);
-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer
-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht
-- die Kette und wird von der Pruefroutine gefunden.
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
actor TEXT NOT NULL,
actor_id INTEGER,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
prev_hash TEXT NOT NULL DEFAULT '',
hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor,
-- gebunden an den Verwendungszweck als AAD.
CREATE TABLE IF NOT EXISTS service_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purpose TEXT NOT NULL UNIQUE,
username TEXT NOT NULL DEFAULT '',
password_encrypted TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
"""
V2_TRUSTED_KEYS = """
-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw.
-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der
-- Lizenzdatei eingebetteten Schluessel.
CREATE TABLE IF NOT EXISTS trusted_issuer_keys (
key_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'tofu',
added_at TEXT NOT NULL,
added_by TEXT NOT NULL DEFAULT ''
);
"""
V3_JOBS = """
-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import,
-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im
-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der
-- Information, ob ein Lauf ueberhaupt stattgefunden hat.
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
output TEXT NOT NULL DEFAULT '',
started_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
"""
CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS),
)
+213
View File
@@ -0,0 +1,213 @@
"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell.
Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war
``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete
still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene
Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern
strukturell verhindert:
* Pro Request existiert genau **eine** Verbindung (in ``flask.g``).
* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend
einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt
sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst
nach 15 Sekunden Busy-Timeout einen Deadlock.
* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind
erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints),
damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen.
Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers),
``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren
Dekoration) und ``busy_timeout=15000``.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from pathlib import Path
from typing import Any
class NestedConnectionError(RuntimeError):
"""Zweite Verbindung waehrend einer offenen Schreibtransaktion."""
class Row(sqlite3.Row):
"""``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung."""
def get(self, key: str, default: Any = None) -> Any:
try:
return self[key]
except (IndexError, KeyError):
return default
def as_dict(self) -> dict[str, Any]:
return {key: self[key] for key in self.keys()}
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
return {key: row[key] for key in row.keys()}
def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]:
return [{key: row[key] for key in row.keys()} for row in rows]
class Database:
"""Zugriffsobjekt fuer eine SQLite-Datei."""
def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None:
self.path = Path(path)
self.timeout = timeout
self._state = threading.local()
# ------------------------------------------------------------------ #
# Verbindungen
# ------------------------------------------------------------------ #
def _depth(self) -> int:
return int(getattr(self._state, "depth", 0))
def _set_depth(self, value: int) -> None:
self._state.depth = value
def connect(self) -> sqlite3.Connection:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0:
raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
"(sonst blockiert SQLite sich selbst)."
)
return self._raw_connect()
def _raw_connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
fresh = not self.path.exists()
conn = sqlite3.connect(
self.path,
timeout=self.timeout,
isolation_level=None, # Transaktionen steuern wir selbst, explizit.
check_same_thread=False,
)
conn.row_factory = Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=15000")
conn.execute("PRAGMA secure_delete=ON")
if fresh:
# Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und
# Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer.
try:
os.chmod(self.path, 0o600)
except OSError: # pragma: no cover - z. B. Windows/NTFS
pass
return conn
@contextmanager
def session(self) -> Iterator[sqlite3.Connection]:
"""Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads."""
conn = self.connect()
try:
yield conn
finally:
conn.close()
# ------------------------------------------------------------------ #
# Transaktionen
# ------------------------------------------------------------------ #
@contextmanager
def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]:
"""Schreibtransaktion mit ``BEGIN IMMEDIATE``.
``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten
Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am
Busy-Timeout, statt mitten in der Transaktion in einen
``SQLITE_BUSY_SNAPSHOT`` zu laufen.
"""
own_connection = conn is None
if own_connection:
conn = self._raw_connect()
assert conn is not None
depth = self._depth()
savepoint = f"sp_{depth}"
self._set_depth(depth + 1)
try:
if depth == 0:
conn.execute("BEGIN IMMEDIATE")
else:
conn.execute(f"SAVEPOINT {savepoint}")
yield conn
except BaseException:
try:
if depth == 0:
conn.execute("ROLLBACK")
else:
conn.execute(f"ROLLBACK TO {savepoint}")
except sqlite3.Error: # pragma: no cover - Verbindung schon tot
pass
raise
else:
if depth == 0:
conn.execute("COMMIT")
else:
conn.execute(f"RELEASE {savepoint}")
finally:
self._set_depth(depth)
if own_connection:
conn.close()
@property
def in_transaction(self) -> bool:
return self._depth() > 0
# ------------------------------------------------------------------ #
# Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln)
# ------------------------------------------------------------------ #
@staticmethod
def one(
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
) -> dict[str, Any] | None:
return row_to_dict(conn.execute(sql, params).fetchone())
@staticmethod
def all(
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
) -> list[dict[str, Any]]:
return rows_to_dicts(conn.execute(sql, params).fetchall())
@staticmethod
def value(
conn: sqlite3.Connection,
sql: str,
params: Sequence[Any] | Mapping[str, Any] = (),
default: Any = None,
) -> Any:
row = conn.execute(sql, params).fetchone()
if row is None:
return default
return row[0]
@staticmethod
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
@staticmethod
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
return (
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?",
(table,),
).fetchone()
is not None
)
@@ -0,0 +1,120 @@
"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht.
Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a.
``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an
einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Sequence
from flask import Flask, current_app, g
from .config import CoreConfig
from .db import Database
from .keystore import Keystore
from .migrations import Migration, MigrationRunner
from .nav import NavTree
from .rbac.model import PermissionTree, RolePreset
from .security.passwords import PasswordPolicy
from .settings_store import SettingsStore
if TYPE_CHECKING: # pragma: no cover
from .licensing_client import LicenseManager
from .sysops import SysOps
@dataclass
class TesmCore:
"""Anwendungsweiter Zustand. Nach ``init_app`` unveraendert."""
config: CoreConfig
database: Database
keystore: Keystore
permissions: PermissionTree
nav: NavTree
settings: SettingsStore
role_presets: tuple[RolePreset, ...] = ()
migrations: dict[str, Sequence[Migration]] = field(default_factory=dict)
license: "LicenseManager | None" = None
sysops: "SysOps | None" = None
#: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry).
registry: dict[str, Any] = field(default_factory=dict)
#: Wird nach einer erfolgreichen Anmeldung aufgerufen:
#: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser
#: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg.
#: Ein Haken, der scheitert, sperrt niemanden aus (siehe
#: ``auth.service._run_login_hooks``).
login_hooks: list[Callable[..., None]] = field(default_factory=list)
#: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die
#: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es
#: bestehen, bis eine Zeitgrenze greift.
logout_hooks: list[Callable[..., None]] = field(default_factory=list)
@property
def password_policy(self) -> PasswordPolicy:
return PasswordPolicy(min_length=self.config.security.password_min_length)
@property
def audit_archive_dir(self) -> Path:
return self.config.log_dir / "audit"
def run_migrations(self) -> list[str]:
runner = MigrationRunner(self.database)
applied: list[str] = []
for component, migrations in self.migrations.items():
applied.extend(runner.run(component, migrations))
return applied
def migration_status(self) -> list[dict[str, object]]:
return MigrationRunner(self.database).status(list(self.migrations))
def core(app: Flask | None = None) -> TesmCore:
target = app or current_app
try:
return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection:
"""Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite.
Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine
Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft,
riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing.
"""
connection = getattr(g, "_tesm_conn", None)
if connection is None:
connection = core().database.connect()
g._tesm_conn = connection
return connection
def close_db(_exception: BaseException | None = None) -> None:
connection = getattr(g, "_tesm_conn", None)
if connection is not None:
g._tesm_conn = None
connection.close()
def transaction() -> Any:
"""Kurzform fuer ``core().database.transaction(db())``."""
return core().database.transaction(db())
def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus."""
def wrapper(*args: Any, **kwargs: Any) -> Any:
with transaction() as conn:
return fn(conn, *args, **kwargs)
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
@@ -0,0 +1,212 @@
"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar).
Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in
``fernet.key``. Fernet ist in Ordnung, aber:
* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel
haette bedeutet, alle Zugangsdaten von Hand neu einzugeben.
* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung
eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess
sich in Tabelle B einspielen.
Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit
AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit
alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein
Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``).
Format: ``v1.<key_id>.<base64url(nonce)>.<base64url(ciphertext)>``
"""
from __future__ import annotations
import base64
import json
import os
import secrets
import stat
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
_PREFIX = "v1"
_NONCE_BYTES = 12
_KEY_BYTES = 32
class DecryptionError(Exception):
"""Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert."""
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64d(text: str) -> bytes:
padded = text + "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def write_private_file(path: Path, content: str) -> None:
"""Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen)."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
handle = os.open(tmp, flags, 0o600)
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError: # pragma: no cover - Windows
pass
def check_private_file(path: Path) -> list[str]:
"""Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an."""
problems: list[str] = []
try:
mode = path.stat().st_mode
except OSError:
return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems
@dataclass(frozen=True, slots=True)
class _Key:
key_id: str
material: bytes
created_at: str
class Keystore:
"""Rotierbarer Schluesselbund fuer Feldverschluesselung."""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._keys: dict[str, _Key] = {}
self._active: str = ""
self._load_or_create()
# ------------------------------------------------------------------ #
def _load_or_create(self) -> None:
if self.path.exists():
raw = json.loads(self.path.read_text(encoding="utf-8"))
for entry in raw.get("keys", []):
key = _Key(
key_id=str(entry["id"]),
material=_b64d(str(entry["key"])),
created_at=str(entry.get("created_at", "")),
)
if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
self._keys[key.key_id] = key
self._active = str(raw.get("active") or "")
if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
return
self._active = "1"
self._keys = {
"1": _Key(
key_id="1",
material=secrets.token_bytes(_KEY_BYTES),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
}
self._persist()
def _persist(self) -> None:
payload = {
"active": self._active,
"keys": [
{"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at}
for key in sorted(self._keys.values(), key=lambda k: int(k.key_id))
],
}
write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
# ------------------------------------------------------------------ #
@property
def active_key_id(self) -> str:
return self._active
def key_ids(self) -> list[str]:
return sorted(self._keys, key=int)
def rotate(self) -> str:
"""Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten."""
new_id = str(max(int(k) for k in self._keys) + 1)
self._keys[new_id] = _Key(
key_id=new_id,
material=secrets.token_bytes(_KEY_BYTES),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
self._active = new_id
self._persist()
return new_id
def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
self._keys.pop(key_id, None)
self._persist()
# ------------------------------------------------------------------ #
def encrypt(self, plaintext: str, *, aad: str) -> str:
key = self._keys[self._active]
nonce = secrets.token_bytes(_NONCE_BYTES)
ciphertext = AESGCM(key.material).encrypt(
nonce, plaintext.encode("utf-8"), aad.encode("utf-8")
)
return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}"
def decrypt(self, token: str, *, aad: str) -> str:
try:
prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3)
except ValueError as exc:
raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc
if prefix != _PREFIX:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id)
if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
try:
plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
)
except (InvalidTag, ValueError) as exc:
raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
"manipulierte Daten oder falscher Verwendungszweck)"
) from exc
return plain.decode("utf-8")
def needs_reencrypt(self, token: str) -> bool:
"""True, wenn das Chiffrat noch unter einem alten Schluessel liegt."""
parts = token.split(".", 3)
return len(parts) == 4 and parts[1] != self._active
def load_or_create_secret_key(path: Path) -> bytes:
"""Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt."""
if path.exists():
raw = path.read_text(encoding="utf-8").strip()
if raw:
return _b64d(raw)
material = secrets.token_bytes(64)
write_private_file(path, _b64e(material) + "\n")
return material
@@ -0,0 +1,778 @@
"""Lizenzverwaltung auf Client-Seite (TESM) und fuer die eigene Lizenz des Servers.
Zustaendig fuer: Lizenzdatei laden und verifizieren, Aussteller pinnen,
Aktivierung/Deaktivierung/Heartbeat (online und offline), Modul-Freischaltung
und die Anzeige in der Topbar.
Wesentliche Aenderungen gegenueber dem Vorgaenger:
* **Vertrauensanker.** Die Signatur wird gegen gepinnte Aussteller-Schluessel
geprueft (Tabelle ``trusted_issuer_keys``). Beim ersten Import wird der
Schluessel per Trust-on-First-Use uebernommen und dabei protokolliert; jede
spaetere Lizenz eines *anderen* Ausstellers wird abgelehnt, bis ein
Administrator den neuen Schluessel ausdruecklich freigibt. Der Vorgaenger
pruefte ausschliesslich gegen den Schluessel *in derselben Datei* -- damit war
jede selbst signierte Datei "gueltig".
* **Trennung von Dokument und Client-Schluessel** (siehe ``tesm_licensing``):
``license.json`` (0640) und ``license_key.json`` (0600).
* **Ein Gate, eine Wahrheit.** Der Vorgaenger hatte eine Docstring, die mehr
behauptete als der Code durchsetzte (LDAP/NGINX waren nur im Template
gegated). Hier gibt es genau eine Funktion ``require_license()`` und eine
Tabelle in ``LICENSED_FEATURES`` -- was dort steht, wird serverseitig
durchgesetzt, ohne Ausnahme.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
import tesm_licensing as lic
from . import audit
from .db import Database
from .keystore import write_private_file
logger = logging.getLogger(__name__)
SETTING_STATUS = "license_activation_status"
SETTING_PENDING = "license_pending_request"
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
SETTING_EXPORT_GRACE = "license_export_grace"
STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
API_PATHS = {
"activate": "/api/v1/licenses/activate",
"deactivate": "/api/v1/licenses/deactivate",
"heartbeat": "/api/v1/licenses/heartbeat",
}
#: Funktionen, die eine gueltige, aktivierte Lizenz brauchen. Diese Tabelle ist
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern",
"export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
}
def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"""Uebersetzt einen Netzfehler in etwas, mit dem man arbeiten kann.
"War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch --
der Server war sehr wohl erreichbar, nur nicht vertrauenswuerdig. Wer das
nicht weiss, sucht am falschen Ende.
"""
text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden"
if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return (
f"{detail} ({endpoint}). Hinterlegen Sie sein Zertifikat unter "
"Einstellungen -> Lizenz, oder geben Sie dem Lizenzserver ein Zertifikat "
"einer anerkannten Stelle."
)
if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower():
return f"{endpoint} hat nicht rechtzeitig geantwortet."
return f"Der Lizenzserver ({endpoint}) war nicht erreichbar: {text[:160]}"
class LicenseError(Exception):
"""Fehler beim Import oder bei der Aktivierung -- Text ist fuer Anwender gedacht."""
@dataclass(slots=True)
class LicenseState:
"""Zwischengespeicherter Lizenzzustand. Wird bei jeder Aenderung neu gebaut."""
present: bool = False
valid: bool = False
issuer_pinned: bool = False
activated: bool = False
document: dict[str, Any] = field(default_factory=dict)
status: lic.LicenseStatus | None = None
error: str = ""
warnings: tuple[str, ...] = ()
last_heartbeat_at: str = ""
heartbeat_error: str = ""
pending_action: str = ""
@property
def role(self) -> str:
return self.status.role if self.status else "customer"
@property
def modules(self) -> frozenset[str]:
return self.status.modules if self.status else frozenset()
@property
def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "")
@property
def vendor(self) -> dict[str, Any]:
return dict(self.status.vendor) if self.status else {}
class LicenseManager:
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
def __init__(
self,
*,
database: Database,
settings: Any,
license_path: Path,
key_path: Path,
app_key: str,
built_in_trusted_keys: tuple[str, ...] = (),
) -> None:
self.database = database
self.settings = settings
self.license_path = Path(license_path)
self.key_path = Path(key_path)
self.app_key = app_key
self.built_in_trusted_keys = tuple(built_in_trusted_keys)
self._state = LicenseState()
self._lock = threading.RLock()
self._heartbeat_thread: threading.Thread | None = None
self._report_builder: Callable[[sqlite3.Connection], dict[str, Any]] | None = None
# ------------------------------------------------------------------ #
# Zustand
# ------------------------------------------------------------------ #
@property
def state(self) -> LicenseState:
return self._state
def trusted_keys(self, conn: sqlite3.Connection) -> list[str]:
rows = Database.all(conn, "SELECT public_key FROM trusted_issuer_keys")
return [*self.built_in_trusted_keys, *(str(row["public_key"]) for row in rows)]
def reload(self, conn: sqlite3.Connection) -> LicenseState:
"""Liest Lizenzdatei und Status neu ein."""
with self._lock:
state = LicenseState()
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn)
state.pending_action = str(pending.get("action") or "") if pending else ""
if not self.license_path.is_file():
state.error = "Es ist keine Lizenzdatei installiert."
self._state = state
return state
try:
document = json.loads(self.license_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
state.error = f"Die Lizenzdatei ist unlesbar: {exc}"
self._state = state
return state
if isinstance(document, dict) and "license" in document:
# Jemand hat das Auslieferungs-Bundle direkt hierher kopiert.
document, _ = lic.split_bundle(document)
state.present = True
state.document = document
trusted = self.trusted_keys(conn)
try:
lic.verify_license(document, trusted_master_keys=trusted or None)
state.valid = True
state.issuer_pinned = bool(trusted)
except lic.UntrustedIssuer as exc:
state.error = str(exc)
except lic.LicensingError as exc:
state.error = str(exc)
if state.valid:
state.status = lic.license_status(
document, issuer_pinned=state.issuer_pinned
)
state.warnings = state.status.warnings
state.activated = (
str(self.settings.get(conn, SETTING_STATUS) or STATUS_INACTIVE) == STATUS_ACTIVE
)
self._state = state
return state
# ------------------------------------------------------------------ #
# Gates
# ------------------------------------------------------------------ #
def active(self) -> bool:
"""Grundvoraussetzung fuer alles Lizenzpflichtige."""
state = self._state
return bool(
state.valid
and state.activated
and state.status is not None
and state.status.modules_active
)
def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
return self.active() and name in self._state.modules
def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion.
Ausnahme ``export``: bleibt erlaubt, wenn der lizenzlose Zustand durch
eine vom Kunden selbst ausgeloeste Deaktivierung entstanden ist
(Systemwechsel) -- sonst waeren die eigenen Daten eingeschlossen.
"""
if feature == "export" and not self.active():
with self.database.session() as conn:
return str(self.settings.get(conn, SETTING_EXPORT_GRACE) or "") == "1"
return self.active()
def require(self, feature: str) -> None:
if not self.feature_allowed(feature):
raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
"aktivierte Lizenz nicht moeglich."
)
# ------------------------------------------------------------------ #
# Import
# ------------------------------------------------------------------ #
def import_bundle(
self,
conn: sqlite3.Connection,
raw: bytes | str,
*,
actor: str,
allow_new_issuer: bool = False,
) -> LicenseState:
"""Nimmt eine hochgeladene Lizenzdatei an.
``allow_new_issuer`` ist nur fuer den ausdruecklichen Fall gedacht, dass
ein Administrator einen Aussteller-Wechsel bestaetigt (z. B. Wechsel des
Lizenzservers). Ohne dieses Kennzeichen wird eine Datei mit unbekanntem
Aussteller abgelehnt, sobald schon einer gepinnt ist.
"""
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
try:
payload = json.loads(text)
except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
try:
document, client_key = lic.split_bundle(payload)
except lic.LicensingError as exc:
raise LicenseError(str(exc)) from exc
try:
lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen."
)
if not client_key and not self.key_path.is_file():
raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
"Lizenz-Bundle des Lizenzservers verwenden."
)
# Reihenfolge: erst Schluessel pinnen, dann Dateien schreiben, dann
# Aktivierungsstatus zuruecksetzen -- ein Abbruch dazwischen hinterlaesst
# keinen halb gueltigen Zustand.
if master_pubkey not in set(known):
conn.execute(
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, added_by) "
"VALUES (?,?,?,?,?,?) ON CONFLICT(key_id) DO NOTHING",
(
lic.key_id(master_pubkey),
master_pubkey,
str((document.get("vendor") or {}).get("name") or "Lizenzserver"),
"tofu" if not known else "admin-confirmed",
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor,
),
)
audit.log(
conn,
actor=actor,
action="license.issuer_pinned",
target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
severity="notice",
)
self.license_path.parent.mkdir(parents=True, exist_ok=True)
self.license_path.write_text(
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
try:
self.license_path.chmod(0o640)
except OSError:
pass
if client_key:
write_private_file(
self.key_path,
json.dumps({"license_id": document.get("license_id"), "client_key": client_key})
+ "\n",
)
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
audit.log(
conn,
actor=actor,
action="license.imported",
target=str(document.get("license_id") or ""),
detail=f"Typ {document.get('type')}, Rolle {document.get('role')}",
severity="notice",
)
return self.reload(conn)
def client_key(self) -> str:
if not self.key_path.is_file():
return ""
try:
data = json.loads(self.key_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return ""
return str(data.get("client_key") or "")
# ------------------------------------------------------------------ #
# Handshake
# ------------------------------------------------------------------ #
def _pending(self, conn: sqlite3.Connection) -> dict[str, Any]:
raw = self.settings.get(conn, SETTING_PENDING) or ""
if not raw:
return {}
try:
return json.loads(raw) if isinstance(raw, str) else dict(raw)
except ValueError:
return {}
def _endpoint(self, action: str) -> str:
base = str(self._state.document.get("master_endpoint") or "").rstrip("/")
if not base:
return ""
return base + API_PATHS[action]
def build_request(
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]:
if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
key = self.client_key()
if not key:
raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
)
return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report
)
def handshake(
self,
conn: sqlite3.Connection,
action: str,
*,
actor: str,
hostname: str = "",
report: dict[str, Any] | None = None,
timeout: int = 12,
) -> dict[str, Any]:
"""Fuehrt eine Aktion durch. Rueckgabe beschreibt das Ergebnis fuer die UI.
``{"ok": bool, "transport": "online"|"offline", "code": str, "message": str}``
"""
request_payload = self.build_request(action, hostname=hostname, report=report)
endpoint = self._endpoint(action)
reason = ""
if endpoint:
try:
response = self._post_json(
endpoint,
request_payload,
timeout=timeout,
ca_pem=str(self.settings.get(conn, "license_server_ca") or ""),
)
except Exception as exc: # noqa: BLE001 - jeder Netzfehler fuehrt offline weiter
reason = describe_transport_error(exc, endpoint)
logger.info("Online-Handshake fehlgeschlagen (%s): %s", action, exc)
else:
return self._apply_response(
conn, action, request_payload, response, actor=actor, transport="online"
)
else:
reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
"ueber den Offline-Weg aktivieren."
)
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
# Antwort liegen in der Praxis eine E-Mail und ein Arbeitstag; der Code
# muss ein Neuladen der Seite ueberstehen.
code = lic.encode_code(request_payload)
self.settings.set(
conn,
SETTING_PENDING,
json.dumps(
{
"action": action,
"nonce": request_payload["nonce"],
"fingerprint": request_payload["fingerprint"],
"license_id": request_payload["license_id"],
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"code": code,
"reason": reason,
}
),
actor=actor,
)
if action == "activate":
self.settings.set(conn, SETTING_STATUS, STATUS_PENDING, actor=actor)
self.reload(conn)
return {
"ok": False,
"transport": "offline",
"code": code,
"message": (
(reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
),
}
def submit_offline_response(
self, conn: sqlite3.Connection, code: str, *, actor: str
) -> dict[str, Any]:
"""Nimmt den Antwortcode des Lizenzservers an (Offline-Weg)."""
pending = self._pending(conn)
if not pending:
raise LicenseError("Es liegt keine offene Anfrage vor.")
try:
response = lic.decode_code(code)
except ValueError as exc:
raise LicenseError(f"Der Code ist unlesbar: {exc}") from exc
return self._apply_response(
conn,
str(pending.get("action") or ""),
{
"license_id": str(pending.get("license_id") or ""),
"fingerprint": str(pending.get("fingerprint") or ""),
"nonce": str(pending.get("nonce") or ""),
},
response,
actor=actor,
transport="offline",
)
def _apply_response(
self,
conn: sqlite3.Connection,
action: str,
request_payload: dict[str, Any],
response: dict[str, Any],
*,
actor: str,
transport: str,
) -> dict[str, Any]:
master_pubkey = str(self._state.document.get("master_pubkey") or "")
current_seq = int(self._state.document.get("seq") or 1)
try:
lic.verify_master_response(
response,
master_pubkey,
expected_license_id=str(request_payload["license_id"]),
expected_fingerprint=str(request_payload["fingerprint"]),
expected_action=action,
expected_nonce=str(request_payload["nonce"]),
current_seq=current_seq,
)
except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
status = str(response.get("status") or "")
detail = str(response.get("detail") or "")
update = response.get("license_update")
if isinstance(update, dict):
try:
lic.verify_license(update, trusted_master_keys=self.trusted_keys(conn) or None)
except lic.LicensingError as exc:
logger.warning("Mitgeliefertes Lizenz-Update verworfen: %s", exc)
else:
self.license_path.write_text(
json.dumps(update, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
audit.log(
conn,
actor=actor,
action="license.updated_by_master",
target=str(update.get("license_id") or ""),
detail=f"Revision {update.get('seq')}",
severity="notice",
)
if status == "revoked":
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log(
conn,
actor=actor,
action="license.revoked_by_master",
target=str(request_payload["license_id"]),
severity="warning",
)
self.reload(conn)
return {
"ok": False,
"transport": transport,
"code": "",
"message": "Diese Lizenz wurde vom Lizenzserver widerrufen.",
}
if status != "ok":
self.reload(conn)
return {
"ok": False,
"transport": transport,
"code": "",
"message": detail or f"Der Lizenzserver hat die Anfrage abgelehnt ({status}).",
}
if action == "activate":
self.settings.set(conn, SETTING_STATUS, STATUS_ACTIVE, actor=actor)
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
message = "Die Lizenz ist jetzt aktiv."
elif action == "deactivate":
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
# Der Kunde hat selbst deaktiviert (Systemwechsel): Export bleibt
# erlaubt, damit er seine Daten mitnehmen kann.
self.settings.set(conn, SETTING_EXPORT_GRACE, "1", actor=actor)
message = "Die Lizenz wurde deaktiviert und kann auf einem anderen System aktiviert werden."
else:
self.settings.set(
conn,
SETTING_LAST_HEARTBEAT,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor=actor,
)
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
message = "Verbindung zum Lizenzserver bestaetigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log(
conn,
actor=actor,
action=f"license.{action}",
target=str(request_payload["license_id"]),
detail=f"Transport: {transport}",
severity="notice",
)
self.reload(conn)
return {"ok": True, "transport": transport, "code": "", "message": message}
@staticmethod
def _tls_context(ca_pem: str) -> Any:
"""Prueft gegen ein hinterlegtes Zertifikat -- oder gegen die Systemanker.
Nie ohne Pruefung: das Lizenzprotokoll ist zwar selbst signiert und
gegen Wiedereinspielung geschuetzt, aber das ist kein Grund, den
Transport offen zu lassen.
"""
import ssl
context = ssl.create_default_context()
pem = (ca_pem or "").strip()
if not pem:
return context
try:
# cadata nimmt PEM direkt entgegen -- keine temporaere Datei, die
# liegen bleiben oder mitgelesen werden koennte.
context.load_verify_locations(cadata=pem)
except (ssl.SSLError, ValueError) as exc:
raise ValueError(
"Das hinterlegte Zertifikat des Lizenzservers ist unlesbar: " + str(exc)
) from exc
return context
@staticmethod
def _post_json(
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]:
if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt")
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url,
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
context = LicenseManager._tls_context(ca_pem) if url.startswith("https://") else None
try:
with urllib.request.urlopen( # noqa: S310
request, timeout=timeout, context=context
) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(body)
except ValueError:
raise
if isinstance(parsed, dict) and "signature" in parsed:
return parsed # Auch abweisende Antworten sind signiert.
raise
return json.loads(body)
# ------------------------------------------------------------------ #
# Heartbeat im Hintergrund
# ------------------------------------------------------------------ #
def set_report_builder(self, builder: Callable[[sqlite3.Connection], dict[str, Any]]) -> None:
"""Nur der Lizenzserver setzt das: Bericht an den eigenen Master."""
self._report_builder = builder
def start_heartbeat(self, *, hostname: str = "") -> None:
if self._heartbeat_thread is not None:
return
def loop() -> None:
# Erster Lauf verzoegert: der Dienst soll erst sauber hochkommen.
time.sleep(90)
while True:
try:
self.run_heartbeat(hostname=hostname)
except Exception as exc: # noqa: BLE001
logger.warning("Heartbeat fehlgeschlagen: %s", exc)
time.sleep(lic.HEARTBEAT_INTERVAL_SECONDS)
thread = threading.Thread(target=loop, name="license-heartbeat", daemon=True)
thread.start()
self._heartbeat_thread = thread
logger.info("Lizenz-Heartbeat gestartet (Intervall %ss)", lic.HEARTBEAT_INTERVAL_SECONDS)
def run_heartbeat(self, *, hostname: str = "") -> dict[str, Any]:
with self.database.session() as conn:
self.reload(conn)
if not self._state.valid or not self._state.activated:
return {"ok": False, "message": "Keine aktive Lizenz."}
report = None
if self._report_builder is not None and self._state.role == "license_server":
report = self._report_builder(conn)
try:
with self.database.transaction(conn):
return self.handshake(
conn, "heartbeat", actor="system", hostname=hostname, report=report
)
except LicenseError as exc:
with self.database.transaction(conn):
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
return {"ok": False, "message": str(exc)}
# ------------------------------------------------------------------ #
# Anzeige
# ------------------------------------------------------------------ #
def topbar_pill(self) -> dict[str, str] | None:
"""Zustandsanzeige fuer die Topbar. Reihenfolge = Dringlichkeit."""
state = self._state
if not state.present:
return {
"label": "Keine Lizenz",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Es ist keine Lizenzdatei installiert.",
}
if not state.valid:
return {
"label": "Lizenz ungueltig",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": state.error,
}
if not state.activated:
return {
"label": "Lizenz nicht aktiviert",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Die Lizenz muss noch aktiviert werden.",
}
status = state.status
if status is None:
return None
if status.expired:
days = status.days_since_expiry or 0
return {
"label": f"Lizenz abgelaufen (seit {days} Tagen)",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet."
),
}
if status.expiring_soon:
return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.",
}
if state.last_heartbeat_at:
try:
last = lic.parse_iso(state.last_heartbeat_at)
days = (lic.utcnow() - last).days
except ValueError:
days = 0
if days >= lic.HEARTBEAT_WARNING_DAYS:
return {
"label": f"Lizenzserver seit {days} Tagen nicht erreichbar",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.",
}
return None

Some files were not shown because too many files have changed in this diff Show More