Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbddd7d29 | ||
|
|
7407f4bc00 | ||
|
|
635722089e | ||
|
|
6dff1ed137 | ||
|
|
b4529a0e31 | ||
|
|
313eb4bbf9 | ||
|
|
51ae39dd53 | ||
|
|
a8bd5763fb | ||
|
|
d6ba98782e | ||
|
|
47ab97a0ac | ||
|
|
fbbf8eb87b | ||
|
|
9a760274f8 | ||
|
|
cf16809ee2 | ||
|
|
d8368fc200 | ||
|
|
e343aee026 | ||
|
|
4065b3586b | ||
|
|
5bb0809009 | ||
|
|
64e25bb9ae | ||
|
|
7354ff352b |
@@ -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
|
||||||
+27
-13
@@ -1,16 +1,30 @@
|
|||||||
# 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/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
# Secrets — niemals committen
|
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||||
/srv/tesm/sqlite.db
|
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||||
/srv/tesm/fernet.key
|
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||||
/srv/tesm/secret.key
|
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||||
/srv/tesm/known_hosts
|
*.tar.gz
|
||||||
|
|
||||||
# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt
|
|
||||||
/srv/tesm/static/uploads/
|
|
||||||
|
|||||||
@@ -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).
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tesm"
|
||||||
|
version = "2.0.8"
|
||||||
|
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/**/*"]
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""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 aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||||
|
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||||
|
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||||
|
#:
|
||||||
|
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||||
|
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||||
|
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||||
|
#: tatsaechlich installiert ist.
|
||||||
|
try: # pragma: no cover - haengt an der Installationsart
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||||
|
|
||||||
|
__version__ = _paketversion("tesm")
|
||||||
|
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||||
|
__version__ = "unbekannt"
|
||||||
|
|
||||||
|
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.
|
||||||
|
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
|
||||||
|
#: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
|
||||||
|
#: es noch nicht gibt -- das Anmeldebanner hat es aus demselben Grund nie
|
||||||
|
#: gezeigt. Wortgleich in ``deploy/tesm-motd``; ein Hygienetest haelt beides
|
||||||
|
#: zusammen.
|
||||||
|
AUTH_TAGLINE = "ÜBERWACHEN · BOOTEN · ANBINDEN"
|
||||||
|
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]] = []
|
||||||
|
|
||||||
|
verbindung = db()
|
||||||
|
|
||||||
|
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
||||||
|
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
||||||
|
if user.has_any(("devices.view", "monitoring.view")):
|
||||||
|
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||||
|
faellig = monitor.next_check_at(verbindung, intervall)
|
||||||
|
if faellig is not None:
|
||||||
|
eintrag = {
|
||||||
|
"label": "Naechste Pruefung in",
|
||||||
|
"countdown_to": faellig.isoformat(),
|
||||||
|
"countdown_done": "jetzt",
|
||||||
|
"css_class": "",
|
||||||
|
"dot": "1",
|
||||||
|
"dot_class": "",
|
||||||
|
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
|
||||||
|
}
|
||||||
|
if user.has("settings_system.execute"):
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
eintrag["action"] = url_for("settings.run_check_now")
|
||||||
|
eintrag["action_title"] = "Alle Clients sofort pruefen"
|
||||||
|
pills.append(eintrag)
|
||||||
|
|
||||||
|
counts = monitor.dashboard_counts(verbindung)
|
||||||
|
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 fileshare, maintenance
|
||||||
|
|
||||||
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
||||||
|
orphaned = maintenance.mark_orphaned_jobs(conn)
|
||||||
|
# Einbindungen leben im Mount-Namensraum dieses Prozesses und sind mit
|
||||||
|
# dem vorherigen verschwunden. Die Zeilen dazu muessen weg, sonst zeigt
|
||||||
|
# die Oberflaeche Freigaben, die es nicht gibt.
|
||||||
|
vergessen = fileshare.forget_all_mounts(conn)
|
||||||
|
if orphaned:
|
||||||
|
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned)
|
||||||
|
if vergessen:
|
||||||
|
logger.info(
|
||||||
|
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||||
|
"-- sie bestehen mit dessen Namensraum nicht mehr",
|
||||||
|
vergessen,
|
||||||
|
)
|
||||||
|
|
||||||
|
if extension.license is not None:
|
||||||
|
import socket
|
||||||
|
|
||||||
|
extension.license.start_heartbeat(hostname=socket.gethostname())
|
||||||
|
|
||||||
|
_start_fileshare_sweep(app)
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie oft nach abgelaufenen Einbindungen gesehen wird.
|
||||||
|
FILESHARE_SWEEP_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _start_fileshare_sweep(app: Flask) -> None:
|
||||||
|
"""Loest abgelaufene Freigabe-Einbindungen -- im Webprozess.
|
||||||
|
|
||||||
|
Muss hier laufen und nicht im Ueberwachungsdienst: eingebunden wird im
|
||||||
|
Mount-Namensraum dieses Dienstes, und ein anderer Dienst sieht diese
|
||||||
|
Einbindungen nicht einmal. Ein ``umount`` von dort waere ein Aufruf ins
|
||||||
|
Leere -- die Datenbankzeile waere als geloest markiert, die Freigabe aber
|
||||||
|
weiter eingebunden.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from .services import fileshare
|
||||||
|
|
||||||
|
def schleife() -> None:
|
||||||
|
while True:
|
||||||
|
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||||
|
try:
|
||||||
|
with app.app_context():
|
||||||
|
geloest = fileshare.sweep_expired(core(app).database)
|
||||||
|
if geloest:
|
||||||
|
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
||||||
|
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||||
|
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||||
|
|
||||||
|
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
# 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."""
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""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.security.sessions import fragment
|
||||||
|
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")
|
||||||
|
@fragment
|
||||||
|
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)
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""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",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das
|
||||||
|
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
||||||
|
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||||
|
with transaction():
|
||||||
|
monitor.mark_restarting(conn, int(device["id"]))
|
||||||
|
|
||||||
|
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,
|
||||||
|
# Der Weg ist oben schon entschieden (restart.resolve). Ihn hier
|
||||||
|
# mitzugeben ist der Unterschied zwischen "Windows-Geraet ueber RPC" und
|
||||||
|
# "Windows-Geraet ueber SSH auf Port 22, bis das Zeitlimit greift".
|
||||||
|
method=method.key,
|
||||||
|
)
|
||||||
|
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))
|
||||||
@@ -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"))
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""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",
|
||||||
|
# Die Vorschau laeuft in einem Rahmen auf der Freigabeseite. Die
|
||||||
|
# anwendungsweite CSP verbietet mit "frame-ancestors 'none'" jedes
|
||||||
|
# Einrahmen -- auch das durch die eigene Seite. Beides wird hier
|
||||||
|
# deshalb ausdruecklich auf "gleicher Ursprung" gesetzt; die Header
|
||||||
|
# der Anwendung werden per setdefault gesetzt und treten damit
|
||||||
|
# zurueck.
|
||||||
|
#
|
||||||
|
# Bewusst nur die Rahmen-Direktiven und nicht die ganze strenge CSP:
|
||||||
|
# was hier ausgeliefert wird, ist eine Datei mit engem, erlaubtem
|
||||||
|
# MIME-Typ (kein HTML, kein SVG) und "nosniff" -- Skript kann darin
|
||||||
|
# nicht ausgefuehrt werden. Eine vollstaendige CSP auf einer
|
||||||
|
# PDF-Antwort riskiert dagegen, die eingebaute Anzeige des Browsers
|
||||||
|
# zu blockieren.
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||||
|
"X-Frame-Options": "SAMEORIGIN",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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>")
|
||||||
|
@permission_required("fileshare.edit")
|
||||||
|
def mapping_update(mapping_id: int):
|
||||||
|
try:
|
||||||
|
with transaction() as conn:
|
||||||
|
service.update_mapping(
|
||||||
|
conn,
|
||||||
|
mapping_id,
|
||||||
|
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_updated", request.form.get("label", ""), severity="notice")
|
||||||
|
flash("Zuordnung geaendert.", "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"))
|
||||||
@@ -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"))
|
||||||
@@ -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"))
|
||||||
@@ -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,290 @@
|
|||||||
|
"""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.security.sessions import fragment
|
||||||
|
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, monitor
|
||||||
|
|
||||||
|
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")
|
||||||
|
@fragment
|
||||||
|
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:
|
||||||
|
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
|
||||||
|
with transaction() as verbindung:
|
||||||
|
monitor.mark_restarting(verbindung, int(device["id"]))
|
||||||
|
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,
|
||||||
|
# Hier steht in ``category`` die Kategorie der *Zugangsdaten*
|
||||||
|
# (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
|
||||||
|
# statt in run_reboot erraten: dort bedeutet derselbe
|
||||||
|
# Schluessel je Herkunft etwas anderes.
|
||||||
|
method=(
|
||||||
|
service.METHOD_RPC
|
||||||
|
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||||
|
else service.METHOD_SSH
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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"))
|
||||||
@@ -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"))
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""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),
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""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 cmd_legacy_export(args: argparse.Namespace) -> int:
|
||||||
|
"""Liest eine alte Installation und schreibt deren Daten als Exportdatei.
|
||||||
|
|
||||||
|
Der Vorgaenger kann nur ueber die Weboberflaeche exportieren: anmelden,
|
||||||
|
Kategorien anhaken, Datei herunterladen. Fuer ein Upgrade ist das der
|
||||||
|
falsche Weg -- es soll ohne Browser laufen und auch dann noch, wenn die
|
||||||
|
alte Anwendung nicht mehr startet. Gelesen wird ausschliesslich; die alte
|
||||||
|
Installation bleibt unberuehrt.
|
||||||
|
|
||||||
|
Herauskommt eine Datei im **alten** Format. Damit ist sie genau das, was
|
||||||
|
der Vorgaenger selbst exportiert haette -- und taugt deshalb auch als
|
||||||
|
Rueckweg.
|
||||||
|
"""
|
||||||
|
from getpass import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .services import legacy_transfer as legacy
|
||||||
|
|
||||||
|
quelle = Path(args.source)
|
||||||
|
ziel = Path(args.out)
|
||||||
|
if ziel.exists() and not args.force:
|
||||||
|
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
|
||||||
|
try:
|
||||||
|
nutzlast, bericht = legacy.read_installation(quelle)
|
||||||
|
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
||||||
|
except legacy.LegacyTransferError as exc:
|
||||||
|
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(bericht.as_text("Gelesen"))
|
||||||
|
if args.dry_run:
|
||||||
|
print()
|
||||||
|
print("--dry-run: keine Datei geschrieben.")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
ziel.write_text(inhalt, encoding="utf-8", newline="\n")
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Schreiben fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print()
|
||||||
|
print(f"Geschrieben: {ziel}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_legacy_import(args: argparse.Namespace) -> int:
|
||||||
|
"""Liest eine Datei im neuen Format ein -- derselbe Weg wie in der Oberflaeche.
|
||||||
|
|
||||||
|
Gebraucht wird das fuer das Upgrade: dort laeuft niemand durch einen
|
||||||
|
Browser. Geschrieben wird ueber ``transfer.apply_import`` -- dieselbe
|
||||||
|
Funktion, die die Seite "Sicherung" benutzt. Ein zweiter Schreibpfad fuer
|
||||||
|
Fremddaten waere die Stelle, die niemand pflegt.
|
||||||
|
"""
|
||||||
|
from getpass import getpass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .services import transfer
|
||||||
|
|
||||||
|
quelle = Path(args.datei)
|
||||||
|
if not quelle.is_file():
|
||||||
|
print(f"Datei nicht gefunden: {quelle}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
app, extension = _app_and_core()
|
||||||
|
passphrase = args.passphrase or getpass("Passphrase der Datei: ")
|
||||||
|
try:
|
||||||
|
nutzlast, kopf = transfer.unseal(quelle.read_bytes(), passphrase)
|
||||||
|
except transfer.TransferError as exc:
|
||||||
|
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
vorhanden = [eintrag["key"] for eintrag in transfer.summarize(nutzlast)]
|
||||||
|
gewaehlt = (
|
||||||
|
[key for key in args.categories.split(",") if key.strip()]
|
||||||
|
if args.categories
|
||||||
|
else vorhanden
|
||||||
|
)
|
||||||
|
unbekannt = sorted(set(gewaehlt) - set(transfer.CATEGORY_BY_KEY))
|
||||||
|
if unbekannt:
|
||||||
|
print(f"Unbekannte Kategorie(n): {', '.join(unbekannt)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(f"Datei vom {kopf.get('created_at') or 'unbekannt'}:")
|
||||||
|
for eintrag in transfer.summarize(nutzlast):
|
||||||
|
marke = "x" if eintrag["key"] in gewaehlt else " "
|
||||||
|
print(f" [{marke}] {eintrag['label']}: {eintrag['count']}")
|
||||||
|
if args.dry_run:
|
||||||
|
print()
|
||||||
|
print("--dry-run: nichts geschrieben.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with app.app_context(), extension.database.session() as conn:
|
||||||
|
with extension.database.transaction(conn):
|
||||||
|
bericht = transfer.apply_import(
|
||||||
|
conn,
|
||||||
|
extension.keystore,
|
||||||
|
nutzlast,
|
||||||
|
categories=gewaehlt,
|
||||||
|
set_setting=lambda key, value: extension.settings.set(
|
||||||
|
conn, key, value, actor=args.actor
|
||||||
|
),
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=args.actor,
|
||||||
|
action="backup.imported",
|
||||||
|
detail="; ".join(
|
||||||
|
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||||
|
f"{zahlen['skipped']} uebersprungen"
|
||||||
|
for key, zahlen in bericht.items()
|
||||||
|
)
|
||||||
|
+ " (Kommandozeile)",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
for key, zahlen in sorted(bericht.items()):
|
||||||
|
print(
|
||||||
|
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||||
|
f"{zahlen['skipped']} uebersprungen"
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
export_alt = sub.add_parser(
|
||||||
|
"legacy-export",
|
||||||
|
help="Daten einer alten Installation als Exportdatei sichern",
|
||||||
|
description=(
|
||||||
|
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
|
||||||
|
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
||||||
|
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
||||||
|
"moeglich, wenn die alte Anwendung nicht mehr startet."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
export_alt.add_argument(
|
||||||
|
"--source", default="/srv/tesm", help="Verzeichnis der alten Installation"
|
||||||
|
)
|
||||||
|
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
||||||
|
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
||||||
|
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
||||||
|
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
||||||
|
export_alt.set_defaults(func=cmd_legacy_export)
|
||||||
|
|
||||||
|
import_neu = sub.add_parser(
|
||||||
|
"legacy-import",
|
||||||
|
help="Datei im Format dieser Anwendung einlesen",
|
||||||
|
description=(
|
||||||
|
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
|
||||||
|
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||||
|
"Kategorien uebernommen, die in der Datei stehen."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
||||||
|
import_neu.add_argument("--passphrase", help="Passphrase der Datei (sonst wird gefragt)")
|
||||||
|
import_neu.add_argument(
|
||||||
|
"--categories", help="Kommaliste, z.B. devices,switches,credentials,dhcp"
|
||||||
|
)
|
||||||
|
import_neu.add_argument(
|
||||||
|
"--actor", default="upgrade", help="Name, der im Auditlog erscheint"
|
||||||
|
)
|
||||||
|
import_neu.add_argument("--dry-run", action="store_true", help="Nur zeigen, was drin steht")
|
||||||
|
import_neu.set_defaults(func=cmd_legacy_import)
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -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="Audit",
|
||||||
|
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",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Fachdienste von TESM -- Netzwerk, SSH, DHCP, Dateifreigaben, Wartung, Sicherung."""
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
"""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 socket
|
||||||
|
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 _resolvable(host: str) -> bool:
|
||||||
|
"""Loest dieser Name auf diesem Server auf?"""
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo(host, None)
|
||||||
|
except (socket.gaierror, UnicodeError, OSError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def search_suffixes(conn: sqlite3.Connection) -> list[str]:
|
||||||
|
"""Domaenen, mit denen ein kurzer Hostname vervollstaendigt werden kann.
|
||||||
|
|
||||||
|
Ein Windows-Client loest "S2025" ueber seine Suchdomaene auf. Dieser Server
|
||||||
|
hat oft keine (``search .`` in resolv.conf), und dann scheitert die
|
||||||
|
Einbindung mit "could not resolve address for S2025" -- obwohl der Name im
|
||||||
|
Netz voellig richtig ist. Die AD-Domaene ist aber bekannt, also wird sie
|
||||||
|
hier zum Vervollstaendigen benutzt.
|
||||||
|
"""
|
||||||
|
kandidaten: list[str] = []
|
||||||
|
einstellungen = core().settings
|
||||||
|
basis = str(einstellungen.get(conn, "ldap_base_dn") or "").strip()
|
||||||
|
if basis:
|
||||||
|
# DC=ad,DC=eertmoed,DC=net -> ad.eertmoed.net
|
||||||
|
teile = [
|
||||||
|
stueck.split("=", 1)[1]
|
||||||
|
for stueck in basis.split(",")
|
||||||
|
if stueck.strip().lower().startswith("dc=")
|
||||||
|
]
|
||||||
|
if teile:
|
||||||
|
kandidaten.append(".".join(teile))
|
||||||
|
rechner = str(einstellungen.get(conn, "ldap_host") or "").strip()
|
||||||
|
if rechner and "." in rechner:
|
||||||
|
kandidaten.append(rechner)
|
||||||
|
# Auch die Elterndomaene: ist ldap_host ein DC-Name, liegt die Domaene
|
||||||
|
# eine Ebene darueber.
|
||||||
|
kandidaten.append(rechner.split(".", 1)[1])
|
||||||
|
# Reihenfolge erhalten, Doppelte entfernen.
|
||||||
|
gesehen: set[str] = set()
|
||||||
|
return [k for k in kandidaten if k and not (k in gesehen or gesehen.add(k))]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
||||||
|
"""Ersetzt den Hostnamen einer UNC durch einen aufloesbaren.
|
||||||
|
|
||||||
|
Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert
|
||||||
|
wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert
|
||||||
|
und der Hinweis nennt das Problem.
|
||||||
|
"""
|
||||||
|
teile = unc.lstrip(chr(92)).split(chr(92), 1)
|
||||||
|
if not teile or not teile[0]:
|
||||||
|
return unc, ""
|
||||||
|
host = teile[0]
|
||||||
|
rest = teile[1] if len(teile) > 1 else ""
|
||||||
|
if _resolvable(host):
|
||||||
|
return unc, ""
|
||||||
|
for suffix in suffixes:
|
||||||
|
kandidat = f"{host}.{suffix.strip('.')}"
|
||||||
|
if _resolvable(kandidat):
|
||||||
|
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||||
|
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
||||||
|
return unc, (
|
||||||
|
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
|
||||||
|
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
||||||
|
"Netzwerk eine Suchdomaene setzen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 update_mapping(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
mapping_id: int,
|
||||||
|
*,
|
||||||
|
ad_group_dn: str,
|
||||||
|
ad_group_name: str,
|
||||||
|
label: str,
|
||||||
|
unc: str,
|
||||||
|
) -> None:
|
||||||
|
"""Aendert eine bestehende Zuordnung.
|
||||||
|
|
||||||
|
Dieselben Pruefungen wie beim Anlegen -- eine Zuordnung, die man nur neu
|
||||||
|
anlegen statt korrigieren kann, verleitet dazu, den Tippfehler stehen zu
|
||||||
|
lassen und daneben einen zweiten Eintrag zu machen.
|
||||||
|
"""
|
||||||
|
clean_unc = validate_unc(unc)
|
||||||
|
clean_label = (label or "").strip()
|
||||||
|
clean_dn = (ad_group_dn or "").strip()
|
||||||
|
if not clean_label:
|
||||||
|
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||||
|
if not clean_dn:
|
||||||
|
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
||||||
|
|
||||||
|
vorhanden = Database.one(
|
||||||
|
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
||||||
|
)
|
||||||
|
if vorhanden is None:
|
||||||
|
raise FileshareError("Diese Zuordnung existiert nicht mehr.")
|
||||||
|
|
||||||
|
# Die Eindeutigkeit (ad_group_dn, unc) gilt weiterhin -- nur nicht gegen
|
||||||
|
# sich selbst.
|
||||||
|
kollision = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? AND id<>?",
|
||||||
|
(clean_dn, clean_unc, mapping_id),
|
||||||
|
)
|
||||||
|
if kollision is not None:
|
||||||
|
raise FileshareError("Diese Zuordnung existiert bereits.")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE fileshare_mappings SET ad_group_dn=?, ad_group_name=?, label=?, unc=? "
|
||||||
|
"WHERE id=?",
|
||||||
|
(clean_dn, (ad_group_name or "").strip(), clean_label, clean_unc, mapping_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
suffixes = search_suffixes(conn)
|
||||||
|
for share in shares:
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
target = root / token
|
||||||
|
# Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert.
|
||||||
|
unc, hinweis = resolve_unc_host(str(share["unc"]), suffixes)
|
||||||
|
if unc == str(share["unc"]) and hinweis:
|
||||||
|
messages.append(f"{share['label']}: {hinweis}")
|
||||||
|
continue
|
||||||
|
if hinweis:
|
||||||
|
logger.info("%s: %s", share["label"], hinweis)
|
||||||
|
result = sysops.mount_cifs(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 forget_all_mounts(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Vergisst alle Einbindungen -- beim Start des Webprozesses.
|
||||||
|
|
||||||
|
Eingebunden wird im Mount-Namensraum dieses Dienstes. Mit dem Dienst
|
||||||
|
verschwindet der Namensraum und mit ihm jede Einbindung; die Zeilen in der
|
||||||
|
Datenbank ueberleben aber. Ohne dieses Aufraeumen zeigte die Oberflaeche
|
||||||
|
nach jedem Neustart Freigaben an, die es nicht mehr gibt -- und ein Klick
|
||||||
|
darauf fuehrte in ein leeres Verzeichnis.
|
||||||
|
|
||||||
|
Bewusst **kein** ``umount``: es gibt nichts zu loesen. Der Aufruf ginge in
|
||||||
|
einen Namensraum, der nicht mehr existiert.
|
||||||
|
"""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"UPDATE fileshare_mounts SET released_at=? WHERE released_at IS NULL", (_iso(_now()),)
|
||||||
|
)
|
||||||
|
return int(cursor.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
||||||
|
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||||
|
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
||||||
|
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||||
|
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||||
|
# eine Sitzung kann durch Aktivitaet beliebig lange leben, eine
|
||||||
|
# Einbindung soll trotzdem irgendwann neu entstehen muessen.
|
||||||
|
rows = Database.all(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
SELECT m.id FROM fileshare_mounts m
|
||||||
|
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||||
|
WHERE m.released_at IS NULL
|
||||||
|
AND (m.expires_at <= ?
|
||||||
|
OR s.id IS NULL
|
||||||
|
OR s.revoked_at IS NOT NULL
|
||||||
|
OR s.idle_expires_at <= ?
|
||||||
|
OR s.absolute_expires_at <= ?)
|
||||||
|
""",
|
||||||
|
(_iso(_now()), _iso(_now()), _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
|
||||||
@@ -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
|
||||||
|
]
|
||||||
@@ -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,686 @@
|
|||||||
|
"""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 pathlib import Path
|
||||||
|
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, titel: str = "Uebersetzt") -> str:
|
||||||
|
"""Bericht als Text. Der Titel gehoert dem Aufrufer.
|
||||||
|
|
||||||
|
Beim Lesen einer alten Installation ist noch nichts uebersetzt --
|
||||||
|
"Uebersetzt: devices 4" waere an dieser Stelle einfach falsch.
|
||||||
|
"""
|
||||||
|
zeilen = [f"{titel}:"]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _eine(conn: Any, sql: str, args: tuple[Any, ...] = ()) -> Any:
|
||||||
|
"""Eine Zeile oder ``None`` -- die alte Datenbank kennt kein Database."""
|
||||||
|
return conn.execute(sql, args).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Alte Installation lesen
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
#: Dateinamen der Vorgaengerfassung, relativ zu ihrem Verzeichnis.
|
||||||
|
LEGACY_DB_NAME = "sqlite.db"
|
||||||
|
LEGACY_FERNET_NAME = "fernet.key"
|
||||||
|
|
||||||
|
#: Genau die Abfragen des alten Exports. Sie stehen hier als Text und nicht als
|
||||||
|
#: Nachbau: was der Vorgaenger exportiert hat, ist das, was uebersetzbar ist.
|
||||||
|
_ABFRAGEN: dict[str, str] = {
|
||||||
|
"devices": """
|
||||||
|
SELECT devices.mac, devices.ip, devices.port, devices.name,
|
||||||
|
devices.switch_hostname, devices.is_active, devices.ssh_port,
|
||||||
|
credentials.name AS credential_name
|
||||||
|
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||||||
|
WHERE devices.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
"switches": """
|
||||||
|
SELECT switches.hostname, switches.ip, switches.ssh_port,
|
||||||
|
credentials.name AS credential_name
|
||||||
|
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||||
|
WHERE switches.deleted_at IS NULL
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffne_alt(pfad: Path) -> Any:
|
||||||
|
"""Oeffnet die alte Datenbank **nur lesend**.
|
||||||
|
|
||||||
|
Der alte Dienst laeuft womoeglich noch. Ein Schreibzugriff waere hier in
|
||||||
|
jedem Fall falsch, und ``mode=ro`` macht daraus einen Fehler statt einer
|
||||||
|
Moeglichkeit.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not pfad.is_file():
|
||||||
|
raise LegacyTransferError(f"Keine Datenbank gefunden: {pfad}")
|
||||||
|
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _tabellen(conn: Any) -> set[str]:
|
||||||
|
return {
|
||||||
|
str(row[0])
|
||||||
|
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entschluessler(root: Path, report: Report) -> Any:
|
||||||
|
"""Gibt eine Funktion zurueck, die ein altes Passwort entschluesselt.
|
||||||
|
|
||||||
|
Fehlt der Schluessel, bleibt der Datensatz erhalten und das Passwort leer.
|
||||||
|
Ein Zugangsdatensatz ohne Passwort ist nachtragbar; ein abgebrochenes
|
||||||
|
Upgrade ist es nicht.
|
||||||
|
"""
|
||||||
|
schluesseldatei = root / LEGACY_FERNET_NAME
|
||||||
|
if not schluesseldatei.is_file():
|
||||||
|
report.note(
|
||||||
|
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
|
||||||
|
"bleiben leer und sind nachzutragen."
|
||||||
|
)
|
||||||
|
return lambda _token: ""
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
try:
|
||||||
|
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
report.note(
|
||||||
|
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
|
||||||
|
"bleiben leer und sind nachzutragen."
|
||||||
|
)
|
||||||
|
return lambda _token: ""
|
||||||
|
|
||||||
|
gescheitert: list[int] = []
|
||||||
|
|
||||||
|
def entschluesseln(token: Any) -> str:
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return fernet.decrypt(str(token).encode("utf-8")).decode("utf-8")
|
||||||
|
except (InvalidToken, ValueError):
|
||||||
|
gescheitert.append(1)
|
||||||
|
if len(gescheitert) == 1:
|
||||||
|
report.note(
|
||||||
|
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
|
||||||
|
"nicht entschluesseln -- es bleibt leer."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return entschluesseln
|
||||||
|
|
||||||
|
|
||||||
|
def _alt_dhcp(conn: Any, vorhanden: set[str]) -> dict[str, Any] | None:
|
||||||
|
"""Baut den DHCP-Abschnitt so, wie der alte Export ihn geschrieben hat."""
|
||||||
|
if "dhcp_subnets" not in vorhanden:
|
||||||
|
return None
|
||||||
|
|
||||||
|
einstellungen: dict[str, Any] = {}
|
||||||
|
if "settings" in vorhanden:
|
||||||
|
# Nur die Schluessel holen, die der Uebersetzer kennt -- der alte
|
||||||
|
# Einstellungsbestand ist gross und heisst groesstenteils anders.
|
||||||
|
for key in DHCP_SETTING_KEYS:
|
||||||
|
row = _eine(conn, "SELECT value FROM settings WHERE key=?", (key,))
|
||||||
|
if row and str(row["value"] or ""):
|
||||||
|
einstellungen[key] = row["value"]
|
||||||
|
|
||||||
|
subnets = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled "
|
||||||
|
"FROM dhcp_subnets"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
reservations = (
|
||||||
|
[
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT mac, ip, name FROM dhcp_manual_reservations"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if "dhcp_manual_reservations" in vorhanden
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
options: list[dict[str, Any]] = []
|
||||||
|
if "dhcp_option_defs" in vorhanden:
|
||||||
|
for definition in conn.execute(
|
||||||
|
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
|
||||||
|
).fetchall():
|
||||||
|
werte = (
|
||||||
|
conn.execute(
|
||||||
|
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?",
|
||||||
|
(definition["id"],),
|
||||||
|
).fetchall()
|
||||||
|
if "dhcp_option_values" in vorhanden
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if not werte and definition["is_standard"]:
|
||||||
|
continue
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"code": definition["code"],
|
||||||
|
"name": definition["name"],
|
||||||
|
"type": definition["type"],
|
||||||
|
"description": definition["description"],
|
||||||
|
"is_standard": definition["is_standard"],
|
||||||
|
"values": [
|
||||||
|
{"device_mac": w["device_mac"], "value": w["value"]} for w in werte
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (einstellungen or subnets or reservations or options):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"settings": einstellungen,
|
||||||
|
"subnets": subnets,
|
||||||
|
"reservations": reservations,
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||||
|
"""Liest eine alte Installation und gibt deren Nutzlast zurueck.
|
||||||
|
|
||||||
|
Ergebnis ist dieselbe Struktur, die ``open_legacy`` aus einer Exportdatei
|
||||||
|
liefert -- der Uebersetzer dahinter bleibt also derselbe. Das ist der
|
||||||
|
Unterschied zum Weg ueber die Oberflaeche: fuer ein Upgrade darf niemand
|
||||||
|
sich anmelden, eine Kategorie anhaken und eine Datei herunterladen muessen.
|
||||||
|
|
||||||
|
Gelesen wird ausschliesslich; die alte Installation bleibt unberuehrt.
|
||||||
|
"""
|
||||||
|
root = Path(root)
|
||||||
|
report = Report()
|
||||||
|
conn = _oeffne_alt(root / LEGACY_DB_NAME)
|
||||||
|
try:
|
||||||
|
vorhanden = _tabellen(conn)
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for abschnitt, sql in _ABFRAGEN.items():
|
||||||
|
tabelle = abschnitt
|
||||||
|
if tabelle not in vorhanden:
|
||||||
|
report.note(f"Tabelle {tabelle!r} fehlt in der alten Datenbank.")
|
||||||
|
continue
|
||||||
|
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||||
|
|
||||||
|
if "credentials" in vorhanden:
|
||||||
|
entschluesseln = _entschluessler(root, report)
|
||||||
|
payload["credentials"] = [
|
||||||
|
{
|
||||||
|
"name": row["name"],
|
||||||
|
"username": row["username"],
|
||||||
|
"password": entschluesseln(row["password"]),
|
||||||
|
"category": row["category"],
|
||||||
|
}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name, username, password, category FROM credentials "
|
||||||
|
"WHERE deleted_at IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
dhcp = _alt_dhcp(conn, vorhanden)
|
||||||
|
if dhcp:
|
||||||
|
payload["dhcp"] = dhcp
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
payload = {key: value for key, value in payload.items() if value}
|
||||||
|
if not payload:
|
||||||
|
raise LegacyTransferError(
|
||||||
|
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
|
||||||
|
"Zugangsdaten, DHCP)."
|
||||||
|
)
|
||||||
|
for abschnitt, inhalt in payload.items():
|
||||||
|
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||||
|
return payload, report
|
||||||
|
|
||||||
|
|
||||||
|
def seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
|
||||||
|
"""Schreibt eine Nutzlast im **alten** Umschlag.
|
||||||
|
|
||||||
|
Damit ist die Datei genau das, was der Vorgaenger selbst exportiert haette
|
||||||
|
-- lesbar fuer ``open_legacy`` und fuer den Import des alten TESM. Das ist
|
||||||
|
beim Upgrade der Unterschied zwischen einem Zwischenschritt und einem
|
||||||
|
Rueckweg: geht danach etwas schief, laesst sich diese Datei in die alte
|
||||||
|
Installation zurueckspielen.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
if len(passphrase) < 8:
|
||||||
|
raise LegacyTransferError("Die Passphrase braucht mindestens 8 Zeichen.")
|
||||||
|
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
|
||||||
|
)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
||||||
|
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
|
||||||
|
umschlag = {
|
||||||
|
"app": LEGACY_APP_TAG,
|
||||||
|
"version": 2,
|
||||||
|
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
||||||
|
"payload": token.decode("utf-8"),
|
||||||
|
}
|
||||||
|
return json.dumps(umschlag, indent=2, ensure_ascii=False) + "\n"
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
"""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",
|
||||||
|
method: str = METHOD_SSH,
|
||||||
|
) -> 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"])
|
||||||
|
# Der Weg wird **uebergeben**, nicht aus dem Geraet abgeleitet. Vorher stand
|
||||||
|
# hier ``device["category"]`` -- und dieser Schluessel bedeutet je Herkunft
|
||||||
|
# etwas anderes: die Wartungsabfrage legt die Kategorie der *Zugangsdaten*
|
||||||
|
# darunter, ``inventory.get_device`` die des *Geraets*. Ueber die
|
||||||
|
# Statusuebersicht kam damit ein Windows-Geraet mit leerer Geraetekategorie
|
||||||
|
# an, wurde fuer Linux gehalten und ueber SSH auf Port 22 angesprochen --
|
||||||
|
# bis zum Zeitlimit. Die Entscheidung faellt jetzt einmal beim Aufrufer
|
||||||
|
# (``restart.resolve``) und wird hier nur noch befolgt.
|
||||||
|
is_windows = method == METHOD_RPC
|
||||||
|
# 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),
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
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.",
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
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."
|
||||||
|
),
|
||||||
|
method=method,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
"""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, timedelta, 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,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
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":
|
||||||
|
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||||
|
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
||||||
|
# kommt bei SSH und RPC erst, wenn das Geraet zurueck ist, und bis
|
||||||
|
# dahin stand die Kachel weiter auf "online".
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||||
|
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
|
||||||
|
|
||||||
|
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
|
||||||
|
auf die Rueckkehr warten. Das Ergebnis steht erst Minuten spaeter fest. Wer
|
||||||
|
den Knopf gedrueckt hat, will die Wirkung aber sofort sehen -- sonst sieht
|
||||||
|
die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||||
|
|
||||||
|
Bewusst ohne ``restart_count``: gezaehlt wird das Ereignis, nicht die
|
||||||
|
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
|
||||||
|
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
||||||
|
(``record_restart``).
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO device_status (device_id, state, fail_count, last_check_at, last_change_at)
|
||||||
|
VALUES (?,?,0,?,?)
|
||||||
|
ON CONFLICT(device_id) DO UPDATE SET
|
||||||
|
state=excluded.state,
|
||||||
|
fail_count=0,
|
||||||
|
last_change_at=CASE WHEN device_status.state <> excluded.state
|
||||||
|
THEN excluded.last_change_at ELSE device_status.last_change_at END
|
||||||
|
""",
|
||||||
|
(device_id, STATE_RESTARTING, _now(), _now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# Sofort sichtbar machen, dass etwas passiert -- der Portzyklus dauert
|
||||||
|
# Sekunden, das Warten auf die Rueckkehr laenger. Gilt fuer den manuellen
|
||||||
|
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
|
||||||
|
if port and switch_ip:
|
||||||
|
with write():
|
||||||
|
mark_restarting(conn, int(device["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 next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime | None:
|
||||||
|
"""Wann der naechste Ueberwachungsdurchlauf faellig ist.
|
||||||
|
|
||||||
|
Anker ist die letzte tatsaechliche Pruefung, nicht der Prozessstart: nach
|
||||||
|
einem Neustart des Dienstes soll der Zaehler dort weiterlaufen, wo der
|
||||||
|
Durchlauf steht, und nicht bei voller Laenge beginnen.
|
||||||
|
"""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT MAX(last_check_at) AS letzte FROM device_status", ()
|
||||||
|
)
|
||||||
|
letzte = (zeile or {}).get("letzte")
|
||||||
|
if not letzte:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
stamp = datetime.fromisoformat(str(letzte))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if stamp.tzinfo is None:
|
||||||
|
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||||
|
return stamp + timedelta(seconds=max(1, int(interval_seconds)))
|
||||||
|
|
||||||
|
|
||||||
|
#: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
|
||||||
|
#: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
|
||||||
|
#: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
|
||||||
|
#: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
|
||||||
|
#: waeren schlimmer als eine doppelte Zeichenkette.
|
||||||
|
DASHBOARD_COUNT_SQL = """
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||||
|
row = Database.one(conn, DASHBOARD_COUNT_SQL)
|
||||||
|
row = row or {}
|
||||||
|
return {
|
||||||
|
key: int(row.get(key) or 0)
|
||||||
|
for key in ("total", "online", "offline", "unknown", "disabled", "restarting")
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
#: Derselbe Weg wie PoE, nur anders benannt: bei einem Geraet, das nicht
|
||||||
|
#: antwortet, ist "Neustart" das falsche Wort. Der Schluessel bleibt ``poe``,
|
||||||
|
#: damit Protokoll und Auswertung unveraendert bleiben.
|
||||||
|
POWER_ON = "poe-einschalten"
|
||||||
|
|
||||||
|
DEFINITIONS: dict[str, Method] = {
|
||||||
|
POWER_ON: Method(
|
||||||
|
key=METHOD_POE,
|
||||||
|
label="Einschalten (PoE)",
|
||||||
|
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht "
|
||||||
|
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
||||||
|
),
|
||||||
|
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] = []
|
||||||
|
hat_poe = bool(field("switch_id")) and bool(str(field("port") or "").strip())
|
||||||
|
if hat_poe:
|
||||||
|
methods.append(DEFINITIONS[METHOD_POE])
|
||||||
|
|
||||||
|
# Ein Geraet, das nicht antwortet, laesst sich nicht ueber SSH oder RPC neu
|
||||||
|
# starten -- dafuer muesste es erreichbar sein. Angeboten wird dann nur noch
|
||||||
|
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
||||||
|
# wieder ein, statt ein laufendes System neu zu starten.
|
||||||
|
if str(field("state") or "") == "offline":
|
||||||
|
return [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""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 -- mit *einem* "
|
||||||
|
"Backslash.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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.",
|
||||||
|
),
|
||||||
|
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||||
|
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||||
|
# Nachgemessen an einem echten Fall -- die Anmeldung gelang, aber schon
|
||||||
|
# "net rpc info" und der Dienstmanager antworteten mit ACCESS_DENIED, also
|
||||||
|
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||||
|
(
|
||||||
|
"pipe winreg",
|
||||||
|
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
|
||||||
|
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
|
||||||
|
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
|
||||||
|
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||||
|
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
|
||||||
|
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||||
|
"gestoppt.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hint_for(output: str) -> str:
|
||||||
|
for needle, hint in HINTS:
|
||||||
|
if needle in output:
|
||||||
|
return hint
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def split_account(username: str, domain: str = "") -> tuple[str, str]:
|
||||||
|
"""Trennt ``DOMAENE\\benutzer`` in Benutzer und Domaene.
|
||||||
|
|
||||||
|
Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere Teile
|
||||||
|
verworfen. Das ist kein Selbstzweck -- der Hinweistext dieser Datei empfahl
|
||||||
|
selbst einmal ``DOMAENE\\\\benutzer`` mit zwei Backslashes, und genau so
|
||||||
|
wurde es dann eingetragen. Ein einfacher ``split()`` schickte daraufhin
|
||||||
|
``\\benutzer`` an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE --
|
||||||
|
bei formal richtigen Zugangsdaten.
|
||||||
|
|
||||||
|
Rueckgabe: ``(benutzer, domaene)``.
|
||||||
|
"""
|
||||||
|
if "\\" in username and not domain:
|
||||||
|
teile = [teil.strip() for teil in username.split("\\") if teil.strip()]
|
||||||
|
if len(teile) >= 2:
|
||||||
|
return teile[-1], teile[0]
|
||||||
|
if teile:
|
||||||
|
return teile[0], domain
|
||||||
|
return username, domain
|
||||||
|
|
||||||
|
|
||||||
|
def _net_with_credentials(
|
||||||
|
args: list[str],
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
domain: str,
|
||||||
|
timeout: int,
|
||||||
|
) -> RemoteResult | tuple[int, str]:
|
||||||
|
"""Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg.
|
||||||
|
|
||||||
|
Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein
|
||||||
|
Klartextpasswort, und diese Behandlung will man genau einmal haben.
|
||||||
|
Rueckgabe: ``(exitcode, ausgabe)`` oder ein fertiges ``RemoteResult`` bei
|
||||||
|
einem Fehler noch vor dem Aufruf.
|
||||||
|
"""
|
||||||
|
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 ansprechen.",
|
||||||
|
)
|
||||||
|
if not username:
|
||||||
|
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||||
|
|
||||||
|
username, domain = split_account(username, domain)
|
||||||
|
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, *args, "--authentication-file", path]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
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))
|
||||||
|
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def check(
|
||||||
|
host: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
domain: str = "",
|
||||||
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
|
) -> RemoteResult:
|
||||||
|
"""Prueft Zugangsdaten gegen ein Windows-Geraet -- **ohne** etwas zu aendern.
|
||||||
|
|
||||||
|
``net rpc info`` ist rein lesend. Der Sinn: ob Zugangsdaten stimmen, soll
|
||||||
|
man erfahren, ohne einen Neustart auszuloesen und hinterher im Protokoll zu
|
||||||
|
raten. Unterschieden wird ausdruecklich zwischen "Passwort falsch" und
|
||||||
|
"Anmeldung in Ordnung, aber keine Administratorrechte" -- das sind zwei
|
||||||
|
voellig verschiedene Aufgaben fuer den, der es beheben muss.
|
||||||
|
|
||||||
|
Ein Fehlversuch zaehlt fuer die Sperrrichtlinie der Domaene mit. Deshalb
|
||||||
|
genau ein Aufruf je Klick und kein automatisches Wiederholen.
|
||||||
|
"""
|
||||||
|
ergebnis = _net_with_credentials(
|
||||||
|
["rpc", "info", "-I", host],
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
domain=domain,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if isinstance(ergebnis, RemoteResult):
|
||||||
|
return ergebnis
|
||||||
|
code, output = ergebnis
|
||||||
|
|
||||||
|
if code == 0:
|
||||||
|
return RemoteResult(
|
||||||
|
True, output, "Anmeldung erfolgreich, Administratorrechte vorhanden."
|
||||||
|
)
|
||||||
|
if "ACCESS_DENIED" in output:
|
||||||
|
# Wichtig: die Anmeldung *hat* funktioniert. Nur die Rechte fehlen.
|
||||||
|
return RemoteResult(
|
||||||
|
False,
|
||||||
|
output,
|
||||||
|
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine "
|
||||||
|
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
||||||
|
)
|
||||||
|
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
ergebnis = _net_with_credentials(
|
||||||
|
[
|
||||||
|
"rpc",
|
||||||
|
"shutdown",
|
||||||
|
"-r", # neu starten statt herunterfahren
|
||||||
|
"-f", # laufende Anwendungen nicht blockieren lassen
|
||||||
|
"-t",
|
||||||
|
str(max(0, int(delay))),
|
||||||
|
"-C",
|
||||||
|
comment,
|
||||||
|
"-I",
|
||||||
|
host,
|
||||||
|
],
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
domain=domain,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
if isinstance(ergebnis, RemoteResult):
|
||||||
|
return ergebnis
|
||||||
|
code, output = ergebnis
|
||||||
|
if code == 0:
|
||||||
|
return RemoteResult(True, output or "Neustart ausgeloest.")
|
||||||
|
return RemoteResult(False, output, hint_for(output))
|
||||||
@@ -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 Statusübersicht -- wird auch einzeln per fetch geladen. #}
|
||||||
|
{% from "tesm_core/macros.html" import pill, empty_state %}
|
||||||
|
|
||||||
|
{# Beide Blöcke in einen Stapel: sonst klebt die erste Gruppe an den
|
||||||
|
Filterkacheln, während 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 geprüft</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
|
||||||
|
über 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 tatsächlich 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>{{ tile.latency_ms | millis }}</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 }} über {{ 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 Geräte erfasst',
|
||||||
|
'Legen Sie das erste Gerät unter Überwachung an.', 'cpu') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<pre class="log-view" data-tail>{{ 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 Übertragung{% endblock %}
|
||||||
|
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
|
|
||||||
|
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne
|
||||||
|
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür. 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 · verlangt eine frische Passwortbestätigung</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">Für diese Kategorie fehlt Ihnen das Recht.</div>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if can_secrets %}
|
||||||
|
{{ checkbox('include_secrets', 'Passwörter der Zugangsdaten mit exportieren',
|
||||||
|
hint_text='Sie liegen dann im Klartext innerhalb der verschlüsselten
|
||||||
|
Datei. Ohne dieses Kennzeichen enthält 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) }} Geöffnete Sicherung</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Wählen Sie, was übernommen werden soll. Gültig für {{ 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">Einträge</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 Passwörtern', 'danger') }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="numeric">{{ entry.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ alert('Bestehende Einträge werden nach ihrem natürlichen Schlüssel abgeglichen
|
||||||
|
(Name, Hostname, MAC, Benutzername) und aktualisiert. Einträge, 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="Ausgewählte Kategorien jetzt übernehmen? Bestehende Daten werden aktualisiert."
|
||||||
|
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||||
|
data-confirm-style="primary">Übernehmen</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 öffnen und prüfen, dann übernehmen</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="Öffne ...">
|
||||||
|
Datei öffnen
|
||||||
|
</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 gehören zur Maschine, nicht zur Sicherung.</li>
|
||||||
|
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll 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 }} · 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('Für dieses Ziel ist kein SSH-Host-Schlüssel freigegeben. Die Konsole verweigert
|
||||||
|
den Aufbau, bis das geschehen ist -- die Freigabe erfolgt auf der Detailseite.',
|
||||||
|
'danger', 'Host-Schlüssel fehlt') }}
|
||||||
|
<p><a class="btn btn--primary" href="{{ back_url }}">Zur Detailseite und Schlüssel 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 Änderungsprotokoll
|
||||||
|
vermerkt. Bei Untätigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekunden.',
|
||||||
|
'warning', 'Mitschrift') }}
|
||||||
|
|
||||||
|
{% if not is_switch %}
|
||||||
|
{{ alert('Jedes Kommando läuft in einer eigenen Sitzung. Das heisst: ein "cd" wirkt nicht
|
||||||
|
auf das nächste Kommando, und interaktive Programme (Editoren, Passwortabfragen) laufen
|
||||||
|
hier nicht. Für 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" data-tail 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 – 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 Änderungsprotokoll.
|
||||||
|
Schliessen Sie die Seite, sobald Sie den Wert übernommen 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 wählbar, z. B. "Switch-Admin" oder "Linux-Wartung".') }}
|
||||||
|
{{ field('username', 'Benutzer', value=entry.username if entry else '', autocomplete='off') }}
|
||||||
|
{{ field('secret', 'Passwort' ~ (' (leer lassen = unverändert)' 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 }} Einträge · verschlüsselt 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('Passwörter liegen AES-256-GCM-verschlüsselt und an ihren Verwendungszweck gebunden.
|
||||||
|
Sie im Klartext zu sehen braucht ein eigenes Recht, eine frische Passwortbestätigung und wird
|
||||||
|
protokolliert. Die Kategorie setzen Sie ausdrücklich -- sie wird bewusst nicht geraten, weil
|
||||||
|
davon abhängt, welche Geräte 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 '–' | 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="Löschen"
|
||||||
|
{% if entry.in_use %}disabled title="Wird noch von {{ entry.in_use }} Einträgen verwendet"{% endif %}
|
||||||
|
data-confirm="{{ entry.name }} in den Papierkorb verschieben?"
|
||||||
|
data-confirm-title="Zugangsdaten löschen" data-confirm-ok="Löschen">
|
||||||
|
{{ 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,22 @@
|
|||||||
|
{% extends "tesm_core/base.html" %}
|
||||||
|
{% from "tesm_core/macros.html" import search_box %}
|
||||||
|
|
||||||
|
{% block page_title %}Statusübersicht{% endblock %}
|
||||||
|
{% block page_sub %}
|
||||||
|
{{ counts.total }} Geräte · Prüfintervall {{ interval }} s
|
||||||
|
{% if not detailed %}· öffentliche Ansicht{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar_extra %}
|
||||||
|
{{ search_box('tile-sections', 'Gerät suchen ...') }}
|
||||||
|
{# Der Knopf "Jetzt prüfen" sass hier ein zweites Mal: die Timer-Pille in
|
||||||
|
der Topbar trägt ihn jetzt, und dort gehört er hin -- er setzt genau die
|
||||||
|
Angabe zurück, die daneben steht. #}
|
||||||
|
{% 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,248 @@
|
|||||||
|
{% 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 }} · {{ 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 }} über {{ 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 Gerät liegt im Papierkorb (gelöscht 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">{{ device.latency_ms | millis if device.latency_ms else '–' | safe }}</span>
|
||||||
|
<span class="stat__hint">letzte Messung</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 %}–{% 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 -- über 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>Auslöser</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">
|
||||||
|
{# Drei Wege, drei Anzeigen: alles ausser 'ssh' als "PoE-Port"
|
||||||
|
zu zeigen hat RPC-Neustarts als PoE ausgegeben. #}
|
||||||
|
{% if event.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||||
|
{% elif event.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||||
|
{% elif event.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||||
|
{% else %}{{ pill(event.method or 'unbekannt', 'warning') }}{% 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('übersprungen', 'warning') }}
|
||||||
|
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
|
||||||
|
<td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ empty_state('Noch keine Neustarts', 'Dieses Gerät 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-Schlüssel
|
||||||
|
{% if host_keys %}{{ pill('freigegeben', 'online') }}
|
||||||
|
{% else %}{{ pill('offen', 'warning') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Ohne freigegebenen Schlüssel 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 Schlüssel</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 unabhängigen Quelle (Konsole am
|
||||||
|
Gerät, 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 vertrauenswürdig hinterlegen?"
|
||||||
|
data-confirm-title="Host-Schlüssel 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-Schlüssel dieses Geräts entfernen? Danach muss der Schlüssel erneut freigegeben werden."
|
||||||
|
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted fs-sm">
|
||||||
|
Noch kein Schlüssel hinterlegt. Erst lesen, Fingerprint prüfen, 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 '–' | safe }}</dd>
|
||||||
|
<dt>Standort</dt><dd>{{ device.location or '–' | 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>Geändert</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">
|
||||||
|
{{ 'Überwachung aussetzen' if device.is_active else 'Überwachung aktivieren' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
{% 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 möglicher Weg: ein Knopf, der ihn benennt. Mehrere: ein Knopf
|
||||||
|
für den bevorzugten Weg (PoE, weil er auch bei hängendem Betriebssystem
|
||||||
|
wirkt) und daneben die Auswahl. Kein Weg: gar nichts -- ein Knopf, der
|
||||||
|
verlässlich 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 }} über {{ 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 }} über {{ 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 (für 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 zusätzlich unter Zugangsdaten gespeichert und
|
||||||
|
ist danach für weitere Geräte 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 Geräts', 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', 'Gerät wird überwacht', 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 gültiger Lizenz und zugeordnetem Switch-Port.') }}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block page_title %}Clients{% endblock %}
|
||||||
|
{% block page_sub %}
|
||||||
|
{{ devices | length }} Geräte · {{ 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) }} Gerät 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 Prüfung</th>
|
||||||
|
<th class="sortable">Host-Schlüssel</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 für dieses Gerät 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 == 'restarting' %}{{ pill('startet neu', 'warning') }}
|
||||||
|
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||||
|
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||||
|
{% if device.fail_count %}<span class="fs-xs faint">({{ device.fail_count }}×)</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 über SSH verwaltet">
|
||||||
|
nicht nötig
|
||||||
|
</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="Löschen"
|
||||||
|
data-confirm="{{ device.name }} in den Papierkorb verschieben? Die MAC bleibt dabei reserviert."
|
||||||
|
data-confirm-title="Gerät löschen" data-confirm-ok="Löschen">
|
||||||
|
{{ icon('trash', size=15) }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if not devices %}
|
||||||
|
{{ empty_state('Keine Geräte', 'Legen Sie das erste überwachte Gerät an.', 'cpu') }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ alert('Ein PoE-Neustart setzt Switch und Port voraus, ein Neustart über SSH oder
|
||||||
|
RPC setzt passende Zugangsdaten voraus. Ist keine Methode möglich, erscheint der Knopf
|
||||||
|
nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block dialogs %}
|
||||||
|
{% if current_user.has('devices.create') %}
|
||||||
|
{% call modal('device-new', 'Gerät 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 %}
|
||||||
@@ -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 · {{ subnets | length }} Subnetze · {{ reservations | length }} Reservierungen
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% for warning in warnings %}{{ alert(warning, 'warning') }}{% endfor %}
|
||||||
|
{% if not sysops_available %}
|
||||||
|
{{ alert('Systemhelfer nicht verfügbar -- Konfiguration und Dienstzustand können 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 + manüll</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">
|
||||||
|
gültig{% 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="Ausführen"
|
||||||
|
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 }} – {{ subnet.range_end }}</td>
|
||||||
|
<td class="mono fs-sm">{{ subnet.gateway or '–' | safe }}</td>
|
||||||
|
<td class="mono fs-sm">{{ subnet.dns or '–' | 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 · Standardoptionen sind nicht löschbar, ihr Wert
|
||||||
|
lässt 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 '–' | 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 }} löschen?"
|
||||||
|
data-confirm-title="Option löschen" data-confirm-ok="Löschen">
|
||||||
|
{{ 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 für alle. Genau dafür gibt es
|
||||||
|
``dhcp_option_values.device_id``: ein eigener Wert je Gerät, der in
|
||||||
|
dessen Reservierung geschrieben wird -- etwa eine Terminal-URL, die je
|
||||||
|
Gerät 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 für ein Gerät</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Der Wert landet in der Reservierung dieses Geräts und überschreibt 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">Gerät<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 }} — {{ 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 }} — {{ option.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{{ field('value', 'Wert', extra_class='field--full',
|
||||||
|
hint_text='Leer = Wert für dieses Gerät entfernen') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn" type="submit">Für dieses Gerät 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 Gerätebestand; bei MAC-Kollision gewinnt der Gerätebestand.
|
||||||
|
Nur Adressen aus eingerichteten Bereichen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if skipped_reservations %}
|
||||||
|
{# Nicht verschweigen, sondern benennen: vorher standen diese Einträge in
|
||||||
|
der Liste, obwohl Kea sie nie ausgeliefert hat. #}
|
||||||
|
<div class="card__body pt-0">
|
||||||
|
{{ alert(skipped_reservations | length ~ ' Gerät(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> – {{ 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">–</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
{% if reservation.source == 'manüll' 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 – je Adresse gilt der letzte Eintrag.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if leases and service.get('active') != 'active' %}
|
||||||
|
{# Sonst sieht man alte Einträge und hält sie für den aktüllen Stand:
|
||||||
|
ohne laufenden Dienst wird keine Lease erneuert, und die Datei
|
||||||
|
veraltet still. #}
|
||||||
|
<div class="card__body pt-0">
|
||||||
|
{{ alert('Der DHCP-Dienst läuft nicht (' ~ service.get('active', 'unbekannt') ~ ').
|
||||||
|
Es wird keine Lease erneuert -- die Liste zeigt den letzten Stand der Datei, nicht die
|
||||||
|
aktülle 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>Läuft 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 '–' | 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('gültig', '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 – so würde 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,236 @@
|
|||||||
|
{% 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">Grösse</th>
|
||||||
|
<th>Geändert</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 class="link-icon" 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 höher
|
||||||
|
</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">auswählen</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">–</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">auswählen</span></label></td>
|
||||||
|
{% endif %}
|
||||||
|
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||||
|
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||||
|
Vorschau; alles andere lädt herunter. #}
|
||||||
|
<td>
|
||||||
|
{% if entry.previewable %}
|
||||||
|
<button class="link-icon file-name" type="button"
|
||||||
|
data-dialog-open="file-preview"
|
||||||
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||||
|
data-fill-Name="{{ entry.name }}"
|
||||||
|
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||||
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a class="link-icon"
|
||||||
|
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||||
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||||
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
{% if entry.previewable %}
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
|
data-dialog-open="file-preview"
|
||||||
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
||||||
|
data-fill-Name="{{ entry.name }}"
|
||||||
|
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||||
|
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare.edit') %}
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
|
data-dialog-open="file-rename"
|
||||||
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
|
data-fill-Name="{{ entry.name }}"
|
||||||
|
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||||
|
{% 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 ausgewählten Einträge endgültig löschen? Auf einer Netzwerkfreigabe gibt es keinen Papierkorb."
|
||||||
|
data-confirm-title="Löschen" data-confirm-ok="Löschen">
|
||||||
|
Ausgewählte löschen
|
||||||
|
</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' }} · 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="Lädt ...">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 %}
|
||||||
|
|
||||||
|
{% if listing %}
|
||||||
|
{# Vorschau im Rahmen statt in einem neuen Tab: der Zusammenhang bleibt
|
||||||
|
erhalten, und man ist mit Escape wieder in der Dateiliste. Titel, Quelle
|
||||||
|
und Download-Ziel werden beim Öffnen aus dem Auslöser gefüllt -- ein
|
||||||
|
Dialog für alle Dateien statt einer je Zeile. #}
|
||||||
|
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
||||||
|
<div class="modal__caption" data-fill-target="Name"></div>
|
||||||
|
<div class="modal__body modal__body--flush">
|
||||||
|
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
|
||||||
|
src="about:blank" referrerpolicy="same-origin"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||||
|
{{ icon('download', size=15) }} Herunterladen
|
||||||
|
</a>
|
||||||
|
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if listing and current_user.has('fileshare.edit') %}
|
||||||
|
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||||
|
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||||
|
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
|
||||||
|
<div class="modal__body">
|
||||||
|
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||||
|
<button class="btn btn--primary" type="submit">Umbenennen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{% extends "tesm_core/base.html" %}
|
||||||
|
{% from "tesm_core/_group_picker.html" import group_picker %}
|
||||||
|
{% from "tesm_core/macros.html" import field, empty_state, alert, modal %}
|
||||||
|
{% block page_title %}Freigaben einrichten{% endblock %}
|
||||||
|
{% block page_sub %}{{ mappings | length }} Zuordnungen · 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 mögliche 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">
|
||||||
|
{% if current_user.has('fileshare.edit') %}
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
|
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||||
|
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
||||||
|
{% endif %}
|
||||||
|
<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='Schrägstriche 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 %}
|
||||||
|
|
||||||
|
{% block dialogs %}
|
||||||
|
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
||||||
|
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
||||||
|
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
||||||
|
{% if current_user.has('fileshare.edit') %}
|
||||||
|
{% for entry in mappings %}
|
||||||
|
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||||
|
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="modal__body">
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('label', 'Anzeigename', value=entry.label, required=True) }}
|
||||||
|
{{ field('unc', 'UNC-Pfad', value=entry.unc, required=True,
|
||||||
|
hint_text='Schrägstriche gehen auch: //dc2022/daten') }}
|
||||||
|
{{ group_picker('ad_group_name', 'ad_group_dn', 'edit-' ~ entry.id,
|
||||||
|
name_value=entry.ad_group_name, dn_value=entry.ad_group_dn,
|
||||||
|
required=True,
|
||||||
|
label='Verzeichnisgruppe, die diese Freigabe bekommt') }}
|
||||||
|
</div>
|
||||||
|
</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,183 @@
|
|||||||
|
{% extends "tesm_core/base.html" %}
|
||||||
|
{% from "tesm_core/macros.html" import pill, empty_state, alert, search_box %}
|
||||||
|
|
||||||
|
{% block page_title %}Host-Schlüssel{% endblock %}
|
||||||
|
{% block page_sub %}
|
||||||
|
{{ targets | length }} Ziele mit SSH-Zugang
|
||||||
|
{%- if open_count %} · {{ open_count }} ohne Freigabe{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar_extra %}{{ search_box('hostkey-table', 'Gerät, IP, Fingerprint ...') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if open_count %}
|
||||||
|
{# Der PoE-Neustart hängt am Schlüssel des *Switches*, nicht am Schlüssel des
|
||||||
|
Clients: geschaltet wird der Port auf dem Switch. Der alte Satz warf beides
|
||||||
|
zusammen und liess vermuten, ein Client ohne freigegebenen Schlüssel sei
|
||||||
|
nicht per PoE neu startbar. #}
|
||||||
|
{{ alert('Für ' ~ open_count ~ ' Ziel(e) ist noch kein Schlüssel freigegeben. Wartung und
|
||||||
|
Konsole brechen dort mit "Schlüssel unbekannt" ab -- absichtlich: ein Hintergrundauftrag
|
||||||
|
darf keinen fremden Schlüssel annehmen. Ein PoE-Neustart braucht dagegen den Schlüssel
|
||||||
|
des Switches, nicht den des Clients.', 'warning', 'Freigaben offen') }}
|
||||||
|
{% else %}
|
||||||
|
{{ alert('Für jedes Ziel mit SSH-Zugang liegt ein freigegebener Schlüssel vor.',
|
||||||
|
'success', 'Vollständig') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{{ alert('Ein Schlüssel wird in zwei Schritten übernommen: erst auslesen, dann -- nach
|
||||||
|
Vergleich mit einer unabhängigen Quelle -- freigeben. Gespeichert wird nach IP und Port;
|
||||||
|
ändert sich die Adresse eines Geräts, ist erneut freizugeben. Ein *geänderter* Schlüssel
|
||||||
|
wird nie stillschweigend übernommen: 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 Schlüssel 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 unabhängigen Quelle -- Konsolenzugang am
|
||||||
|
Gerät, 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">–</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 }} für {{ row.label }} dauerhaft als vertrauenswürdig hinterlegen?"
|
||||||
|
data-confirm-title="Host-Schlüssel 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 für {{ 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 Einträgen gibt es kein Ziel mehr -- gelöscht 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 für {{ 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,316 @@
|
|||||||
|
{% 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 %}gültig, noch nicht aktiviert
|
||||||
|
{% elif state and state.present %}ungültig
|
||||||
|
{% 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) }} ·
|
||||||
|
{{ 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 '–' | 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>Läuft 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 läuft ({{ grace_days }} Tage) – 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 prüfen</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 }} · 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 verändern.
|
||||||
|
Der Anfragecode gehört 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 übermitteln
|
||||||
|
</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 älteren
|
||||||
|
Fassung. Lösen 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 einfügen ..."></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="row row--end mt-3">
|
||||||
|
<button class="btn btn--primary" type="submit">Antwort übernehmen</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 nötig, 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 Prüfung, und jeder Versuch fällt auf den
|
||||||
|
Offline-Weg zurück. Die Prüfung abzuschalten wäre der falsche Weg -- hinterlegen
|
||||||
|
Sie stattdessen das Zertifikat, dann wird gegen genau dieses geprüft.', '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----- ... -----END CERTIFICATE-----"
|
||||||
|
>{{ server_certificate }}</textarea>
|
||||||
|
<span class="field__hint">
|
||||||
|
Leer lassen, um wieder gegen die Systemanker zu prüfen. Auf dem Lizenzserver
|
||||||
|
liegt es unter <span class="mono">/etc/tesm/certs/<name>/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 enthält das Lizenzdokument und den zugehörigen Client-Schlüssel
|
||||||
|
</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 ausdrücklich 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 Maschinenidentität. Eine Umbenennung des Hosts
|
||||||
|
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
||||||
|
„bereits anderweitig gebunden“.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||||
|
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if trusted_keys %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table table--compact">
|
||||||
|
<thead><tr><th>Schlüssel-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 | issuer_source }}
|
||||||
|
{% set erklaerung = key.source | issuer_source_hint %}
|
||||||
|
{% if erklaerung %}<div class="faint">{{ erklaerung }}</div>{% endif %}
|
||||||
|
</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-Schlüssel entfernen? Danach gilt die von ihm ausgestellte Lizenz als nicht vertrauenswürdig."
|
||||||
|
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 Schlüssel des Lizenzservers übernommen 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 Oberfläche 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,63 @@
|
|||||||
|
{% 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', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||||
|
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||||
|
Der Vorgänger protokollierte Neustarts ausdrücklich 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">Gerät</th>
|
||||||
|
<th class="sortable">Methode</th><th class="sortable">Auslöser</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 '–' | safe }}</td>
|
||||||
|
<td class="fs-sm">
|
||||||
|
{# Kein if/else mit zwei Zweigen: es gibt drei Wege, und alles
|
||||||
|
ausser 'ssh' als "PoE-Port" anzuzeigen hat RPC-Neustarts
|
||||||
|
jahrelang als PoE ausgegeben. #}
|
||||||
|
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||||
|
{% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||||
|
{% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||||
|
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% 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('übersprungen', 'warning') }}
|
||||||
|
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="numeric fs-sm nowrap">{{ row.duration_ms | millis }}</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 }} Geräte mit Zugangsdaten der Kategorie
|
||||||
|
„Linux-Client“ oder „Windows-Client“
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar_extra %}{{ search_box('maintenance-table', 'Gerät suchen ...') }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if devices | rejectattr('host_key_ready') | list %}
|
||||||
|
{{ alert('Für mindestens ein Gerät ist der SSH-Host-Schlüssel noch nicht freigegeben.
|
||||||
|
Die Wartung bricht dort mit "Schlüssel unbekannt" ab. Die Freigabe erfolgt auf der
|
||||||
|
Detailseite des Geräts -- erst auslesen, Fingerprint prüfen, dann freigeben.',
|
||||||
|
'warning', 'Host-Schlüssel offen') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if windows_tool_missing %}
|
||||||
|
{# Die Meldung im Auftrag nennt das fehlende Paket -- ohne diesen Knopf wäre sie
|
||||||
|
eine Sackgasse. #}
|
||||||
|
<div class="alert alert--warning">
|
||||||
|
{{ icon('alert', size=18) }}
|
||||||
|
<div class="alert__body">
|
||||||
|
<strong>Windows-Neustart nicht möglich</strong>
|
||||||
|
Auf diesem Server fehlt das Paket <code>{{ windows_package }}</code>. Es bringt
|
||||||
|
<code>net</code> mit, über das der Neustart eines Windows-Gerätes läuft.
|
||||||
|
</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 Geräte hier erscheinen und was mit ihnen möglich ist, entscheidet die
|
||||||
|
Kategorie ihrer Zugangsdaten. Linux-Clients werden über SSH aktualisiert und neu gestartet.
|
||||||
|
Windows-Clients werden nur neu gestartet, und zwar über RPC (wie "shutdown /m") -- sie
|
||||||
|
brauchen dafür weder SSH noch einen Host-Schlüssel, 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 auswählen</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 für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
||||||
|
data-confirm="Paketaktualisierung auf den ausgewählten Geräten 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 ausgewählten Geräte 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">Gerät</th>
|
||||||
|
<th class="sortable">IP</th>
|
||||||
|
<th class="sortable">Zustand</th>
|
||||||
|
<th class="sortable">Zugangsdaten</th>
|
||||||
|
<th class="sortable">Host-Schlüssel</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 }} auswählen</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 über RPC verwaltet, nicht über SSH">
|
||||||
|
nicht nötig
|
||||||
|
</span>
|
||||||
|
{% elif device.host_key_ready %}{{ pill('freigegeben', 'online') }}
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('devices.detail', device_id=device.id) }}"
|
||||||
|
title="Ohne freigegebenen Schlüssel 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 Geräte',
|
||||||
|
'Setzen Sie bei den Zugangsdaten die Kategorie "Linux-Client" und ordnen Sie
|
||||||
|
sie einem Gerät 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 über die Abfrage unten, die bei laufenden
|
||||||
|
Aufträgen die ganze Seite neu lädt. #}
|
||||||
|
<section class="card" id="jobs">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</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>Gerät</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('läuft', '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 Aufträge', 'Es wurde noch keine Wartung gestartet.', 'clock') }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="module" nonce="{{ csp_nonce }}">
|
||||||
|
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||||
|
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
|
||||||
|
// versehentlich das falsche Gerät 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 läuft.
|
||||||
|
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' }} · {{ 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('läuft', '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') }}">Zurück</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="module" nonce="{{ csp_nonce }}">
|
||||||
|
// Beim Öffnen ans Ende springen: bei einem laufenden Auftrag ist die
|
||||||
|
// jüngste Zeile die interessante, nicht die erste. Das Nachführen 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,172 @@
|
|||||||
|
{% 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 %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if not sysops_available %}
|
||||||
|
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||||
|
nicht geändert 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) }} Überwachung</div>
|
||||||
|
<div class="card__sub">Wie oft geprüft 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 prüfen</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', 'Prüfintervall (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='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
|
||||||
|
funktioniert unabhängig davon.') }}
|
||||||
|
{{ checkbox('dashboard_public', 'Statusübersicht 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 Passwortbestätigung,
|
||||||
|
vollständige 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 für Switche zulassen',
|
||||||
|
checked=values.terminal_enabled) }}
|
||||||
|
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (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('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
|
{{ 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 für 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 Einträge in der gewünschten
|
||||||
|
Reihenfolge speichern (Drag-and-drop folgt in einer späteren 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) }}">Zurück</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 %} · {{ 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-Schlüssel
|
||||||
|
{% if host_keys %}{{ pill('freigegeben', 'online') }}{% else %}{{ pill('offen', 'danger') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Ohne freigegebenen Schlüssel 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 Schlüssel</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 unabhängigen 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 vertrauenswürdig hinterlegen?"
|
||||||
|
data-confirm-title="Host-Schlüssel 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-Schlüssel entfernen? Danach ist kein Zugriff mehr möglich, bis erneut freigegeben wird."
|
||||||
|
data-confirm-title="Schlüssel verwerfen" data-confirm-ok="Verwerfen">
|
||||||
|
Schlüssel verwerfen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="muted fs-sm">Noch kein Schlüssel hinterlegt. Bitte auslesen und prüfen.</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 ausführen</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Positivliste lesender Kommandos – verlangt eine frische Passwortbestätigung
|
||||||
|
</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 }} – {{ command }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn btn--primary" type="submit" data-busy-label="Führe aus ...">
|
||||||
|
Ausführen
|
||||||
|
</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 '–' | safe }}</dd>
|
||||||
|
<dt>Zuletzt gesehen</dt><dd>{{ switch.last_seen_at | dt }}</dd>
|
||||||
|
<dt>Geändert</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 Geräte</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>Gerät</th><th>Zustand</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for device in devices %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono fs-sm">{{ device.port or '–' | 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 Geräte', 'Diesem Switch ist noch kein Gerät zugeordnet.', 'cpu') }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
{% 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-Schlüssel eines Switches ausdrücklich
|
||||||
|
freigegeben werden. Unbekannte oder geänderte Schlüssel werden abgewiesen -- nicht
|
||||||
|
stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
||||||
|
|
||||||
|
<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">Geräte</th>
|
||||||
|
<th class="sortable">Host-Schlüssel</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="Löschen"
|
||||||
|
data-confirm="{{ switch.hostname }} in den Papierkorb verschieben?"
|
||||||
|
data-confirm-title="Switch löschen" data-confirm-ok="Löschen">
|
||||||
|
{{ 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>
|
||||||
|
|
||||||
|
{# Die freigegebenen Host-Schlüssel standen hier ein zweites Mal. Gepflegt
|
||||||
|
werden sie unter Überwachung -> Host-Schlüssel: dort mit Auslesen,
|
||||||
|
Fingerprint-Vergleich, Freigeben, Verwerfen und Aufräumen. Eine reine
|
||||||
|
Zweitanzeige lädt dazu ein, sie an der falschen Stelle zu suchen. #}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block dialogs %}
|
||||||
|
{% if current_user.has('switches.create') %}
|
||||||
|
{% call modal('switch-new', 'Switch anlegen', 'Host-Schlüssel 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 %}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""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 inventory, monitor
|
||||||
|
|
||||||
|
logger = logging.getLogger("tesm.monitor")
|
||||||
|
|
||||||
|
MIN_INTERVAL = 30
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorDaemon:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.app = create_app()
|
||||||
|
self.extension = core(self.app)
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||||
|
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||||
|
# Mount-Namensraum, und dieser Dienst sieht diese Einbindungen nicht
|
||||||
|
# einmal. Ein umount von hier waere ein Aufruf ins Leere -- die
|
||||||
|
# Datenbankzeile als geloest markiert, die Freigabe aber weiter
|
||||||
|
# eingebunden. Siehe tesm/__init__.py::_start_fileshare_sweep.
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""WSGI-Einstiegspunkt fuer gunicorn: ``gunicorn tesm.wsgi:app``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
#!/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_"
|
||||||
|
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||||
|
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||||
|
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||||
|
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||||
|
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||||
|
# nicht ausdruecklich neu gesetzt wird.
|
||||||
|
ENV_FILE="/etc/tesm/$NAME.env"
|
||||||
|
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||||
|
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -f "$NGINX_SITE" ]]; then
|
||||||
|
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
|
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; 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"
|
||||||
|
|
||||||
|
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||||
|
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||||
|
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||||
|
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||||
|
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||||
|
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||||
|
#
|
||||||
|
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||||
|
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||||
|
step "Anmeldebanner"
|
||||||
|
if [[ -d /etc/update-motd.d ]]; then
|
||||||
|
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||||
|
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||||
|
TMP_MOTD="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "#!/bin/sh"
|
||||||
|
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||||
|
echo "# Installation ueberschrieben."
|
||||||
|
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||||
|
} > "$TMP_MOTD"
|
||||||
|
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||||
|
rm -f "$TMP_MOTD"
|
||||||
|
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||||
|
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||||
|
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||||
|
# nichts anderes wird angefasst.
|
||||||
|
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||||
|
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||||
|
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||||
|
rm -f "$ALT_MOTD"
|
||||||
|
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||||
|
# (oder umgekehrt).
|
||||||
|
#
|
||||||
|
# sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
|
||||||
|
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \
|
||||||
|
# --to tesm-license
|
||||||
|
#
|
||||||
|
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
|
||||||
|
# erwartet, und drei davon sind leicht zu vergessen:
|
||||||
|
#
|
||||||
|
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
|
||||||
|
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||||
|
# Instanz.
|
||||||
|
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
|
||||||
|
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
|
||||||
|
# nach dem Umzug nicht mehr.
|
||||||
|
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
|
||||||
|
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
|
||||||
|
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
|
||||||
|
# Port 80 und die erste gewinnt.
|
||||||
|
#
|
||||||
|
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
|
||||||
|
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
|
||||||
|
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
|
||||||
|
# System zeigt mehr ins Leere.
|
||||||
|
#
|
||||||
|
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
|
||||||
|
# danach anders.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP=""
|
||||||
|
FROM=""
|
||||||
|
TO=""
|
||||||
|
SUFFIX="sonnet5"
|
||||||
|
ASSUME_YES=0
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
|
||||||
|
|
||||||
|
--app <name> Welche Anwendung (Pflicht)
|
||||||
|
--from <name> Bisheriger Installationsname, z.B. tesm-opus (Pflicht)
|
||||||
|
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
|
||||||
|
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
|
||||||
|
der Fassung 1.x (Vorgabe: sonnet5)
|
||||||
|
--dry-run Nur zeigen, was geschehen wuerde
|
||||||
|
--yes Keine Rueckfrage
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--app) APP="${2:?}"; shift 2 ;;
|
||||||
|
--from) FROM="${2:?}"; shift 2 ;;
|
||||||
|
--to) TO="${2:?}"; shift 2 ;;
|
||||||
|
--suffix) SUFFIX="${2:?}"; shift 2 ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) usage; fail "unbekannte Option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||||
|
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
|
||||||
|
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
|
||||||
|
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
|
||||||
|
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||||
|
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||||
|
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
|
||||||
|
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
|
||||||
|
|
||||||
|
ENV_PREFIX="TESM_"
|
||||||
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
|
|
||||||
|
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
|
||||||
|
VERSCHIEBEN=(
|
||||||
|
"/srv/$FROM:/srv/$TO"
|
||||||
|
"/var/log/$FROM:/var/log/$TO"
|
||||||
|
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
|
||||||
|
"/var/www/$FROM-acme:/var/www/$TO-acme"
|
||||||
|
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
|
||||||
|
)
|
||||||
|
ENTFERNEN=(
|
||||||
|
"/etc/systemd/system/$FROM.service"
|
||||||
|
"/etc/systemd/system/$FROM-monitor.service"
|
||||||
|
"/etc/nginx/sites-enabled/$FROM"
|
||||||
|
"/etc/nginx/sites-available/$FROM"
|
||||||
|
"/usr/local/bin/$FROM-admin"
|
||||||
|
"/etc/logrotate.d/$FROM"
|
||||||
|
"/etc/update-motd.d/50-$FROM"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
|
||||||
|
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
|
||||||
|
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
|
||||||
|
# nicht garantiert.
|
||||||
|
lese_einstellung() {
|
||||||
|
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
|
||||||
|
import sqlite3, sys
|
||||||
|
schluessel, datenbank = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
row = None
|
||||||
|
print((row[0] if row and row[0] is not None else "").strip())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
DOMAIN="$(lese_einstellung web_domain || true)"
|
||||||
|
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
|
||||||
|
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
|
||||||
|
CERT_ALT="$(lese_einstellung web_cert_path || true)"
|
||||||
|
|
||||||
|
step "Vorhaben"
|
||||||
|
info "Anwendung : $APP"
|
||||||
|
info "Alt : $FROM"
|
||||||
|
info "Neu : $TO"
|
||||||
|
info "Domain : ${DOMAIN:-(keine)}"
|
||||||
|
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
|
||||||
|
for paar in "${VERSCHIEBEN[@]}"; do
|
||||||
|
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||||
|
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
|
||||||
|
done
|
||||||
|
for pfad in "${ENTFERNEN[@]}"; do
|
||||||
|
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- Was belegt den Zielnamen? ----------------------------------------------- #
|
||||||
|
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
|
||||||
|
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
|
||||||
|
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
|
||||||
|
BELEGT=()
|
||||||
|
for paar in "${VERSCHIEBEN[@]}"; do
|
||||||
|
ziel="${paar##*:}"
|
||||||
|
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
|
||||||
|
done
|
||||||
|
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
|
||||||
|
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
|
||||||
|
done
|
||||||
|
|
||||||
|
BEISEITE=""
|
||||||
|
if [[ ${#BELEGT[@]} -gt 0 ]]; then
|
||||||
|
if [[ -f "/srv/$TO/data/app.db" ]]; then
|
||||||
|
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
|
||||||
|
fi
|
||||||
|
BEISEITE="/srv/$TO-$SUFFIX"
|
||||||
|
if [[ -e "$BEISEITE" ]]; then
|
||||||
|
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
|
||||||
|
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
|
||||||
|
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
|
||||||
|
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
|
||||||
|
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
|
||||||
|
else
|
||||||
|
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
|
||||||
|
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
step "--dry-run: hier ist Schluss."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||||
|
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
|
||||||
|
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 1. Dienste anhalten ----------------------------------------------------- #
|
||||||
|
step "Dienste anhalten"
|
||||||
|
for unit in "$FROM.service" "$FROM-monitor.service"; do
|
||||||
|
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||||
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
|
info "$unit gestoppt und deaktiviert"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- 2. Zielnamen freimachen ------------------------------------------------- #
|
||||||
|
if [[ -n "$BEISEITE" ]]; then
|
||||||
|
step "Zielnamen freimachen"
|
||||||
|
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
|
||||||
|
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
|
||||||
|
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
|
||||||
|
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
|
||||||
|
mkdir -p "$BEISEITE"
|
||||||
|
for pfad in "${BELEGT[@]}"; do
|
||||||
|
ziel="$BEISEITE/$(basename "$pfad")"
|
||||||
|
if [[ "$pfad" == "/srv/$TO" ]]; then
|
||||||
|
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
|
||||||
|
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
|
||||||
|
[[ -e "$eintrag" ]] || continue
|
||||||
|
mv "$eintrag" "$BEISEITE/"
|
||||||
|
done
|
||||||
|
rmdir "$pfad"
|
||||||
|
info "/srv/$TO -> $BEISEITE/"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -e "$ziel" ]]; then
|
||||||
|
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
|
||||||
|
fi
|
||||||
|
mv "$pfad" "$ziel"
|
||||||
|
info "$pfad -> $ziel"
|
||||||
|
done
|
||||||
|
mkdir -p "$BEISEITE/systemd-alt"
|
||||||
|
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
|
||||||
|
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
|
||||||
|
"/etc/systemd/system/$TO-check-restart.timer"; do
|
||||||
|
[[ -f "$unit" ]] || continue
|
||||||
|
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
|
||||||
|
mv "$unit" "$BEISEITE/systemd-alt/"
|
||||||
|
info "$(basename "$unit") archiviert"
|
||||||
|
done
|
||||||
|
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
|
||||||
|
[[ -e "$site" ]] || continue
|
||||||
|
[[ -L "$site" ]] && { rm -f "$site"; continue; }
|
||||||
|
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
|
||||||
|
info "nginx-Site archiviert"
|
||||||
|
done
|
||||||
|
for motd in "/etc/update-motd.d/50-$TO"; do
|
||||||
|
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
|
||||||
|
&& info "Banner der Fassung 1.x entfernt"
|
||||||
|
done
|
||||||
|
info "Alles Verdraengte liegt unter $BEISEITE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 3. Umziehen ------------------------------------------------------------- #
|
||||||
|
step "Umziehen"
|
||||||
|
for paar in "${VERSCHIEBEN[@]}"; do
|
||||||
|
quelle="${paar%%:*}"; ziel="${paar##*:}"
|
||||||
|
[[ -e "$quelle" ]] || continue
|
||||||
|
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
|
||||||
|
mkdir -p "$(dirname "$ziel")"
|
||||||
|
mv "$quelle" "$ziel"
|
||||||
|
info "$quelle -> $ziel"
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
|
||||||
|
step "Namen nachziehen"
|
||||||
|
ENV_FILE="/etc/tesm/$TO.env"
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
|
||||||
|
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
|
||||||
|
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
|
||||||
|
info "SITE_KEY=$TO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pfade in der Datenbank, die den Namen enthalten.
|
||||||
|
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
|
||||||
|
import sqlite3, sys
|
||||||
|
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(datenbank, timeout=5.0)
|
||||||
|
except sqlite3.Error as fehler:
|
||||||
|
print(f" Datenbank nicht schreibbar: {fehler}")
|
||||||
|
raise SystemExit(0)
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
for key in schluessel:
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||||
|
if not row or not row[0]:
|
||||||
|
continue
|
||||||
|
wert = str(row[0])
|
||||||
|
ersetzt = wert.replace(alt, neu)
|
||||||
|
if ersetzt != wert:
|
||||||
|
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
|
||||||
|
print(f" {key}: {wert} -> {ersetzt}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
PY
|
||||||
|
|
||||||
|
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
|
||||||
|
step "Alte Konfiguration entfernen"
|
||||||
|
for pfad in "${ENTFERNEN[@]}"; do
|
||||||
|
[[ -e "$pfad" ]] || continue
|
||||||
|
rm -f "$pfad"
|
||||||
|
info "$pfad"
|
||||||
|
done
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
|
||||||
|
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
|
||||||
|
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
|
||||||
|
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
|
||||||
|
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
|
||||||
|
# sichtbar da ist. Live auf POETEST genau so passiert.
|
||||||
|
if [[ -d "/srv/$TO/venv" ]]; then
|
||||||
|
step "venv verwerfen (Shebangs tragen den alten Pfad)"
|
||||||
|
rm -rf "/srv/$TO/venv"
|
||||||
|
info "wird von install.sh neu gebaut"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Unter dem neuen Namen einrichten"
|
||||||
|
INSTALL_ARGS=(--app "$APP" --yes)
|
||||||
|
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
|
||||||
|
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
|
||||||
|
|
||||||
|
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
|
||||||
|
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
|
||||||
|
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
|
||||||
|
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
|
||||||
|
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
|
||||||
|
if [[ -n "$DOMAIN" ]]; then
|
||||||
|
step "Webserver-Konfiguration erneuern"
|
||||||
|
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
|
||||||
|
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
|
||||||
|
# geschrieben hat -- mit "server_name _" und ohne TLS.
|
||||||
|
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
|
||||||
|
if [[ "$HTTPS_AN" == "1" ]]; then
|
||||||
|
WEB_ARGS+=(--https)
|
||||||
|
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
|
||||||
|
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
|
||||||
|
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
|
||||||
|
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
|
||||||
|
# Umzug wirklich keines da ist, wird eines erzeugt.
|
||||||
|
CERT_NEU="${CERT_ALT//$FROM/$TO}"
|
||||||
|
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
|
||||||
|
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
|
||||||
|
else
|
||||||
|
WEB_ARGS+=(--self-signed)
|
||||||
|
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
|
||||||
|
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
|
||||||
|
info "Site neu geschrieben: server_name $DOMAIN"
|
||||||
|
else
|
||||||
|
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
|
||||||
|
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 8. Ergebnis ------------------------------------------------------------- #
|
||||||
|
step "Fertig"
|
||||||
|
info "Verzeichnis : /srv/$TO"
|
||||||
|
info "Dienst : systemctl status $TO.service"
|
||||||
|
info "Werkzeug : $TO-admin"
|
||||||
|
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
|
||||||
|
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
|
||||||
|
echo
|
||||||
|
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
[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
|
||||||
|
# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber
|
||||||
|
# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht.
|
||||||
|
# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1):
|
||||||
|
# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten,
|
||||||
|
# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt
|
||||||
|
# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts
|
||||||
|
# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet.
|
||||||
|
#
|
||||||
|
# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau
|
||||||
|
# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu
|
||||||
|
# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die
|
||||||
|
# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess --
|
||||||
|
# der Ueberwachungsdienst koennte sie nicht einmal sehen.
|
||||||
|
SystemCallFilter=@system-service @mount
|
||||||
|
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
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
#!/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"
|
||||||
|
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||||
|
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||||
|
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||||
|
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||||
|
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||||
|
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||||
|
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||||
|
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||||
|
# der Gewinn.
|
||||||
|
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}"
|
||||||
|
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||||
|
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||||
|
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||||
|
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||||
|
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
|
||||||
|
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
|
||||||
|
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
|
||||||
|
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
|
||||||
|
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
|
||||||
|
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
|
||||||
|
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
|
||||||
|
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||||
|
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
|
||||||
|
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
|
||||||
|
# Kommandozeile, die sie erst wieder zerlegen muesste.
|
||||||
|
ausserhalb=(systemd-run --wait --pipe --collect --quiet
|
||||||
|
--setenv=DEBIAN_FRONTEND=noninteractive)
|
||||||
|
"${ausserhalb[@]}" apt-get update -qq
|
||||||
|
"${ausserhalb[@]}" apt-get install -y -qq "$@"
|
||||||
|
else
|
||||||
|
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq "$@"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||||
|
|
||||||
|
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||||
|
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||||
|
Datenbank der Installation.
|
||||||
|
|
||||||
|
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||||
|
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||||
|
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||||
|
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||||
|
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||||
|
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||||
|
|
||||||
|
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||||
|
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||||
|
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
AMBER = "\033[38;2;226;166;60m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||||
|
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||||
|
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||||
|
|
||||||
|
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
|
||||||
|
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
||||||
|
#: zusammen.
|
||||||
|
TAGLINE = {
|
||||||
|
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
||||||
|
"tesm-license": "LIZENZIEREN",
|
||||||
|
}
|
||||||
|
|
||||||
|
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||||
|
|
||||||
|
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||||
|
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||||
|
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||||
|
DASHBOARD_COUNT_SQL = """
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||||
|
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||||
|
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||||
|
pfad = root / "data" / "app.db"
|
||||||
|
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _flotte(conn: sqlite3.Connection) -> str:
|
||||||
|
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||||
|
online = int(row["online"] or 0)
|
||||||
|
offline = int(row["offline"] or 0)
|
||||||
|
teile = [
|
||||||
|
_zahl(online, "Client online", "Clients online"),
|
||||||
|
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||||
|
]
|
||||||
|
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||||
|
# Auskunft, sondern Fuellmaterial.
|
||||||
|
restarting = int(row["restarting"] or 0)
|
||||||
|
if restarting:
|
||||||
|
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||||
|
unbekannt = int(row["unknown"] or 0)
|
||||||
|
if unbekannt:
|
||||||
|
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||||
|
return " · ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _dhcp() -> str:
|
||||||
|
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||||
|
datei = Path(LEASE_FILE)
|
||||||
|
if not datei.is_file():
|
||||||
|
return "keine Lease-Datei"
|
||||||
|
try:
|
||||||
|
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "Lease-Datei nicht lesbar"
|
||||||
|
if len(zeilen) < 2:
|
||||||
|
return "0 Leases aktiv"
|
||||||
|
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||||
|
try:
|
||||||
|
i_adresse = kopf.index("address")
|
||||||
|
i_ablauf = kopf.index("expire")
|
||||||
|
i_zustand = kopf.index("state")
|
||||||
|
except ValueError:
|
||||||
|
return "Lease-Datei mit unerwarteten Spalten"
|
||||||
|
|
||||||
|
jetzt = int(time.time())
|
||||||
|
letzte: dict[str, tuple[int, str]] = {}
|
||||||
|
for zeile in zeilen[1:]:
|
||||||
|
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||||
|
if len(felder) < len(kopf):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||||
|
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||||
|
|
||||||
|
|
||||||
|
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||||
|
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||||
|
lizenzen = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_zahl(kunden, "Kunde", "Kunden")
|
||||||
|
+ " · "
|
||||||
|
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||||
|
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||||
|
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||||
|
parser.add_argument("--no-color", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.no_color:
|
||||||
|
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||||
|
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||||
|
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||||
|
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||||
|
if TAGLINE[args.app]:
|
||||||
|
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
conn = _oeffnen(root)
|
||||||
|
except sqlite3.Error:
|
||||||
|
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if args.app == "tesm":
|
||||||
|
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||||
|
else:
|
||||||
|
print(_lizenzen(conn))
|
||||||
|
except sqlite3.Error as fehler:
|
||||||
|
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||||
|
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||||
|
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||||
|
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||||
|
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||||
|
hat_crlf() {
|
||||||
|
local cr
|
||||||
|
cr="$(printf '\r')"
|
||||||
|
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||||
|
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||||
|
# findet deshalb nie einen.
|
||||||
|
LC_ALL=C grep -qU "$cr" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
APP="${1:-}"
|
||||||
|
TAG="${2:-latest}"
|
||||||
|
|
||||||
|
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||||
|
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||||
|
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||||
|
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||||
|
# nginx-Site und demselben Port.
|
||||||
|
shift $(( $# > 2 ? 2 : $# ))
|
||||||
|
EXTRA=("$@")
|
||||||
|
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||||
|
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||||
|
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||||
|
#
|
||||||
|
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||||
|
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||||
|
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||||
|
|
||||||
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||||
|
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||||
|
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||||
|
|
||||||
|
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"
|
||||||
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
|
SRC="$(find "$WORK" -maxdepth 3 -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 hat_crlf "$SRC/deploy/install.sh"; 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${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||||
|
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||||
|
|
||||||
|
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||||
|
#
|
||||||
|
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm-license
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
|
||||||
|
#
|
||||||
|
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||||
|
#
|
||||||
|
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
|
||||||
|
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
|
||||||
|
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
|
||||||
|
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
|
||||||
|
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
|
||||||
|
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
|
||||||
|
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
|
||||||
|
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
|
||||||
|
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
|
||||||
|
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
|
||||||
|
# Sicherung.
|
||||||
|
#
|
||||||
|
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
|
||||||
|
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
|
||||||
|
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
|
||||||
|
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
|
||||||
|
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
|
||||||
|
#
|
||||||
|
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
|
||||||
|
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
|
||||||
|
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
|
||||||
|
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
|
||||||
|
# Kopie aus Schritt 1 der Export.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP=""
|
||||||
|
INSTANCE=""
|
||||||
|
SOURCE=""
|
||||||
|
PASSPHRASE=""
|
||||||
|
ASSUME_YES=0
|
||||||
|
DRY_RUN=0
|
||||||
|
WITH_DATA=1
|
||||||
|
STOP_OLD=1
|
||||||
|
ADOPT_KEY=1
|
||||||
|
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||||
|
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||||
|
#: Hygienetest vergleicht sie.
|
||||||
|
ARCHIV_SUFFIX="sonnet5"
|
||||||
|
|
||||||
|
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
|
||||||
|
info_quelle=""
|
||||||
|
EXTRA=()
|
||||||
|
|
||||||
|
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: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||||
|
|
||||||
|
--app <name> Welche Anwendung (Pflicht)
|
||||||
|
--source <pfad> Verzeichnis der alten Installation
|
||||||
|
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
|
||||||
|
--instance <name> Neue Installation als benannte Instanz anlegen, statt
|
||||||
|
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
|
||||||
|
alte Installation liegen und ist zurueckholbar.
|
||||||
|
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
|
||||||
|
erzeugt und in der Sicherung abgelegt.
|
||||||
|
--no-data Nur installieren, keine Datenuebernahme
|
||||||
|
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
|
||||||
|
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
|
||||||
|
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||||
|
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||||
|
Vorgabe ist deshalb die Uebernahme.
|
||||||
|
--dry-run Nur zeigen, was geschehen wuerde
|
||||||
|
--yes Keine Rueckfrage
|
||||||
|
-h, --help Diese Hilfe
|
||||||
|
|
||||||
|
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
|
||||||
|
oder --domain.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
|
||||||
|
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--app) APP="${2:?}"; shift 2 ;;
|
||||||
|
--source) SOURCE="${2:?}"; shift 2 ;;
|
||||||
|
--instance) INSTANCE="${2:?}"; shift 2 ;;
|
||||||
|
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
|
||||||
|
--no-data) WITH_DATA=0; shift ;;
|
||||||
|
--keep-old-running) STOP_OLD=0; shift ;;
|
||||||
|
--new-key) ADOPT_KEY=0; shift ;;
|
||||||
|
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) EXTRA+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||||
|
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||||
|
|
||||||
|
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
|
||||||
|
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
|
||||||
|
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
|
||||||
|
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
|
||||||
|
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
|
||||||
|
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
|
||||||
|
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
|
||||||
|
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
|
||||||
|
|
||||||
|
if [[ -z "$SOURCE" ]]; then
|
||||||
|
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
|
||||||
|
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
|
||||||
|
done
|
||||||
|
if [[ -z "$SOURCE" ]]; then
|
||||||
|
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
|
||||||
|
warn " /srv/$APP"
|
||||||
|
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie)"
|
||||||
|
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
|
||||||
|
fail "Bitte --source setzen."
|
||||||
|
fi
|
||||||
|
info_quelle="gefunden: $SOURCE"
|
||||||
|
fi
|
||||||
|
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||||
|
ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||||
|
|
||||||
|
NAME="$APP"
|
||||||
|
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||||
|
ADMIN="$NAME-admin"
|
||||||
|
NEW_ROOT="/srv/$NAME"
|
||||||
|
IN_PLACE=0
|
||||||
|
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||||
|
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||||
|
ALT_KOPIE="$BACKUP/alt"
|
||||||
|
EXPORT_ALT="$BACKUP/export-alt.json"
|
||||||
|
IMPORT_NEU="$BACKUP/import-neu.json"
|
||||||
|
|
||||||
|
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
||||||
|
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
|
||||||
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||||
|
tesm-check-restart.timer tesm-check-restart.service)
|
||||||
|
else
|
||||||
|
ALTE_UNITS=(tesm-license.service)
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Vorhaben"
|
||||||
|
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||||
|
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
|
||||||
|
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||||
|
info "Sicherung : $ALT_KOPIE"
|
||||||
|
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||||
|
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
|
||||||
|
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
|
||||||
|
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
|
||||||
|
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
step "--dry-run: hier ist Schluss."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||||
|
read -r -p "Fortfahren? [j/N] " answer
|
||||||
|
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 1. Sichern -------------------------------------------------------------- #
|
||||||
|
step "Sichern"
|
||||||
|
mkdir -p "$ALT_KOPIE"
|
||||||
|
chmod 700 "$BACKUP"
|
||||||
|
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
|
||||||
|
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
|
||||||
|
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
|
||||||
|
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||||
|
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||||
|
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||||
|
|
||||||
|
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||||
|
if [[ $STOP_OLD -eq 1 ]]; then
|
||||||
|
step "Altes System abschalten"
|
||||||
|
for unit in "${ALTE_UNITS[@]}"; do
|
||||||
|
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||||
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
|
info "$unit gestoppt und deaktiviert"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
step "Altes System bleibt in Betrieb"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 3. Neu installieren ----------------------------------------------------- #
|
||||||
|
step "Neu installieren"
|
||||||
|
INSTALL_ARGS=(--app "$APP" --yes)
|
||||||
|
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||||
|
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
|
|
||||||
|
if [[ $WITH_DATA -eq 0 ]]; then
|
||||||
|
step "Fertig -- ohne Datenuebernahme"
|
||||||
|
info "Sicherung der alten Installation: $ALT_KOPIE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
|
||||||
|
|
||||||
|
if [[ -z "$PASSPHRASE" ]]; then
|
||||||
|
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
|
||||||
|
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
|
||||||
|
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
|
||||||
|
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
|
||||||
|
# auch als Rueckweg.
|
||||||
|
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
|
||||||
|
chmod 600 "$BACKUP/passphrase.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
# -- 4. Exportieren -------------------------------------------------------- #
|
||||||
|
step "Daten der alten Installation lesen"
|
||||||
|
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
|
||||||
|
--passphrase "$PASSPHRASE" --force
|
||||||
|
chmod 600 "$EXPORT_ALT"
|
||||||
|
|
||||||
|
# -- 5. Uebersetzen -------------------------------------------------------- #
|
||||||
|
step "Uebersetzen"
|
||||||
|
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
|
||||||
|
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
|
||||||
|
chmod 600 "$IMPORT_NEU"
|
||||||
|
|
||||||
|
# -- 6. Einspielen --------------------------------------------------------- #
|
||||||
|
step "Einspielen"
|
||||||
|
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
|
||||||
|
else
|
||||||
|
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
|
||||||
|
step "Lizenzbestand uebernehmen"
|
||||||
|
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
|
||||||
|
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
|
||||||
|
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
|
||||||
|
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
|
||||||
|
# wenn die neue Installation schon einen eigenen Schluessel hat.
|
||||||
|
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
|
||||||
|
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
|
||||||
|
"$ADMIN" "${MIGRATE_ARGS[@]}"
|
||||||
|
if [[ $ADOPT_KEY -eq 0 ]]; then
|
||||||
|
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
|
||||||
|
warn "Ausstellerwechsel einmal bestaetigen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Abschluss --------------------------------------------------------------- #
|
||||||
|
step "Fertig"
|
||||||
|
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||||
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
|
info "Export im alten Format : $EXPORT_ALT"
|
||||||
|
info "Uebersetzte Datei : $IMPORT_NEU"
|
||||||
|
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
|
||||||
|
fi
|
||||||
|
cat <<TEXT
|
||||||
|
|
||||||
|
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
|
||||||
|
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||||
|
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
|
||||||
|
sudo -u $APP $ADMIN create-admin
|
||||||
|
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
|
||||||
|
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
|
||||||
|
TEXT
|
||||||
|
|
||||||
|
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
|
||||||
|
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
|
||||||
|
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
|
||||||
|
# einem alten Stand ueberschreiben.
|
||||||
|
if [[ $IN_PLACE -eq 1 ]]; then
|
||||||
|
cat <<TEXT
|
||||||
|
|
||||||
|
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
|
||||||
|
bringt es zurueck:
|
||||||
|
sudo systemctl disable --now $NAME
|
||||||
|
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
|
||||||
|
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
|
||||||
|
# venv der alten Installation neu bauen:
|
||||||
|
sudo python3 -m venv $SOURCE/venv
|
||||||
|
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
|
||||||
|
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
else
|
||||||
|
cat <<TEXT
|
||||||
|
|
||||||
|
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
|
||||||
|
die neue Instanz abzuschalten:
|
||||||
|
sudo systemctl disable --now $NAME $NAME-monitor
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
|
||||||
|
sudo systemctl enable --now ${ALTE_UNITS[0]}
|
||||||
|
|
||||||
|
TEXT
|
||||||
|
fi
|
||||||
+475
@@ -0,0 +1,475 @@
|
|||||||
|
# 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 tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Was wo liegt
|
||||||
|
|
||||||
|
| Zweck | Pfad |
|
||||||
|
|---|---|
|
||||||
|
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||||
|
| Instanzdaten (Datenbank, Schlüssel, 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 zusätzlichen
|
||||||
|
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||||
|
ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
|
||||||
|
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||||
|
nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
|
||||||
|
|
||||||
|
Alles, was ein Mensch verlieren würde, 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>` | Öffentlicher 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 bestätigen. |
|
||||||
|
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||||
|
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||||
|
| `--skip-packages` | Keine Systempakete installieren. |
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u tesm tesm-admin create-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern!
|
||||||
|
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||||
|
sudo -u tesm-license tesm-license-admin create-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
|
||||||
|
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||||
|
> prüfbar. 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 Übernehmen 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 zusätzlich:
|
||||||
|
|
||||||
|
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||||
|
unabhängig davon, ob sich das Schema geändert hat.
|
||||||
|
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||||
|
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||||
|
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||||
|
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||||
|
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||||
|
|
||||||
|
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||||
|
`--https`.
|
||||||
|
|
||||||
|
### Ohne Quellbaum: `update.sh`
|
||||||
|
|
||||||
|
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||||
|
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||||
|
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||||
|
eine **benannte Instanz** zu aktualisieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||||
|
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||||
|
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||||
|
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||||
|
|
||||||
|
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||||
|
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||||
|
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||||
|
Release, sondern den Repositoriumsstand.
|
||||||
|
|
||||||
|
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||||
|
etwa auf eine Spiegelung:
|
||||||
|
|
||||||
|
| Variable | Vorgabe |
|
||||||
|
|---|---|
|
||||||
|
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||||
|
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||||
|
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||||
|
|
||||||
|
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
||||||
|
|
||||||
|
### Upgrade vom Stand 1.x (Branch `SONNET5`)
|
||||||
|
|
||||||
|
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
||||||
|
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||||
|
Exportformat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
|
||||||
|
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
|
||||||
|
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
|
||||||
|
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
|
||||||
|
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
|
||||||
|
legt ihre Datenbank unter `data/app.db` ab.
|
||||||
|
|
||||||
|
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
|
||||||
|
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
|
||||||
|
| 3. Installieren | `install.sh` dieses Verzeichnisses |
|
||||||
|
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
|
||||||
|
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
|
||||||
|
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
|
||||||
|
|
||||||
|
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
|
||||||
|
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
|
||||||
|
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
|
||||||
|
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
|
||||||
|
verändert.
|
||||||
|
|
||||||
|
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
|
||||||
|
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
|
||||||
|
Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||||
|
|
||||||
|
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
|
||||||
|
und die alte bleibt vollständig liegen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was nicht mitkommt** -- und warum:
|
||||||
|
|
||||||
|
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
|
||||||
|
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
|
||||||
|
`tesm-admin create-admin`.
|
||||||
|
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
|
||||||
|
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
|
||||||
|
|
||||||
|
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
|
||||||
|
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
|
||||||
|
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
|
||||||
|
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
|
||||||
|
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
|
||||||
|
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
|
||||||
|
für die alte Datei.
|
||||||
|
|
||||||
|
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
|
||||||
|
`--dry-run`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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) Nachträglich über 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 Oberfläche** 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 für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||||
|
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||||
|
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||||
|
Anwendung.
|
||||||
|
|
||||||
|
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||||
|
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||||
|
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||||
|
|
||||||
|
### Selbstsigniert oder Let's Encrypt?
|
||||||
|
|
||||||
|
* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||||
|
sinnvoll.
|
||||||
|
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||||
|
Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
|
||||||
|
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||||
|
monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
|
||||||
|
|
||||||
|
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||||
|
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||||
|
würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4a. Anmeldebanner (MOTD)
|
||||||
|
|
||||||
|
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||||
|
steht dann der Zustand der Installation im Login-Banner:
|
||||||
|
|
||||||
|
```
|
||||||
|
TESM · TimEShepManager
|
||||||
|
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||||
|
|
||||||
|
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||||
|
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||||
|
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||||
|
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||||
|
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||||
|
die niemand mehr füllte.
|
||||||
|
|
||||||
|
Von Hand prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-opus
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||||
|
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||||
|
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||||
|
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. Installation umbenennen
|
||||||
|
|
||||||
|
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
|
||||||
|
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --to tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||||
|
leicht zu übersehen:
|
||||||
|
|
||||||
|
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
|
||||||
|
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
|
||||||
|
Instanz.
|
||||||
|
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
|
||||||
|
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
|
||||||
|
dem Umzug nicht mehr.
|
||||||
|
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
|
||||||
|
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
|
||||||
|
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
|
||||||
|
`server_name _` auf Port 80, und die erste gewinnt.
|
||||||
|
|
||||||
|
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
|
||||||
|
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
|
||||||
|
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
|
||||||
|
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
|
||||||
|
|
||||||
|
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
|
||||||
|
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
|
||||||
|
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
|
||||||
|
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
|
||||||
|
|
||||||
|
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
|
||||||
|
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
|
||||||
|
das Sitzungscookie heisst danach anders.
|
||||||
|
|
||||||
|
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dienste
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||||
|
systemctl status tesm-monitor.service # Überwachungsschleife (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 gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||||
|
Pfad mit `NOPASSWD` freigegeben und prüft 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 für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
|
||||||
|
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||||
|
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
|
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||||
|
AES-256-GCM) mit einem selbst gewählten 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 ungültig
|
||||||
|
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||||
|
* `data/license.json` und `data/license_key.json`
|
||||||
|
* Lizenzserver zusätzlich: `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 lässt sich nicht neu laden.**
|
||||||
|
```bash
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||||
|
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||||
|
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||||
|
|
||||||
|
**Anmeldung schlägt ohne Fehlermeldung fehl.**
|
||||||
|
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||||
|
das er über HTTP nie zurücksendet.
|
||||||
|
```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 Oberfläche ist unformatiert.**
|
||||||
|
```bash
|
||||||
|
grep alias /etc/nginx/sites-available/tesm
|
||||||
|
ls /srv/tesm/static /srv/tesm/static-core
|
||||||
|
```
|
||||||
|
Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
|
||||||
|
|
||||||
|
**Zwei Instanzen stören 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-Schlüssel ist unbekannt".**
|
||||||
|
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||||
|
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||||
|
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||||
|
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||||
|
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||||
|
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||||
|
Freigabe auf der Switch-Detailseite.
|
||||||
|
|
||||||
|
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||||
|
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||||
|
eines Geräts, ist der Schlüssel erneut freizugeben.
|
||||||
|
|
||||||
|
**Die Prüfkette des Änderungsprotokolls 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. Zurück auf eine ältere 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 vorwärts. Ein Rücksprung über eine
|
||||||
|
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||||
|
genau das liegt im Backup-Verzeichnis.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Sicherheit
|
||||||
|
|
||||||
|
Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Anmeldung
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Speicherung | Argon2id (argon2-cffi) |
|
||||||
|
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||||
|
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||||
|
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||||
|
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||||
|
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges 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 Bestätigung ("sudo-Modus")
|
||||||
|
|
||||||
|
Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
|
||||||
|
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||||
|
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||||
|
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||||
|
|
||||||
|
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sitzungen
|
||||||
|
|
||||||
|
Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
|
||||||
|
Zufallskennung.
|
||||||
|
|
||||||
|
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||||
|
* Leerlaufzeit **und** absolute Höchstdauer
|
||||||
|
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||||
|
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||||
|
unterscheiden keine Ports)
|
||||||
|
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CSRF
|
||||||
|
|
||||||
|
Drei voneinander unabhängige Schichten:
|
||||||
|
|
||||||
|
1. `SameSite=Lax` am Sitzungscookie.
|
||||||
|
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||||
|
3. Origin-/Referer-Prüfung 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 Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||||
|
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||||
|
|
||||||
|
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||||
|
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||||
|
CSRF-Schutz wäre eine Lücke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Header
|
||||||
|
|
||||||
|
Bei jeder Antwort:
|
||||||
|
|
||||||
|
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||||
|
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||||
|
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||||
|
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||||
|
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||||
|
werden; Style-Attribute führen 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 tatsächlich terminiert wird
|
||||||
|
|
||||||
|
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||||
|
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
|
||||||
|
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Geheimnisse im Ruhezustand
|
||||||
|
|
||||||
|
Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||||
|
|
||||||
|
* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
|
||||||
|
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||||
|
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||||
|
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||||
|
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||||
|
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||||
|
|
||||||
|
Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
|
||||||
|
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||||
|
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rechtetrennung auf dem Host
|
||||||
|
|
||||||
|
Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||||
|
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||||
|
sudoers ausschliesslich für 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 Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
|
||||||
|
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||||
|
den zulässigen 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 Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||||
|
einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Änderungsprotokoll
|
||||||
|
|
||||||
|
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||||
|
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||||
|
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||||
|
|
||||||
|
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
|
||||||
|
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||||
|
|
||||||
|
Ältere 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 Rückstufung, Antwort an die Anfrage gebunden,
|
||||||
|
Aussteller-Pinning.
|
||||||
|
|
||||||
|
Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
|
||||||
|
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||||
|
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||||
|
Änderungsprotokoll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Ein- und Ausfuhr
|
||||||
|
|
||||||
|
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||||
|
gewählten 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-Zertifikatsprüfung abschaltet.** Wer ihn
|
||||||
|
einmal setzt, setzt ihn dauerhaft.
|
||||||
|
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||||
|
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||||
|
* **Keine Rechteausweitung**, auch nicht für 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 öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||||
|
Lizenzservers hinterlegte Adresse.
|
||||||
@@ -1,48 +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 / {
|
|
||||||
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
|
||||||
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
|
||||||
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
|
||||||
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
|
||||||
client_max_body_size 16m;
|
|
||||||
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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=TESM Web App
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/srv/tesm
|
|
||||||
# Markiert genau DIESEN Prozess als den echten Web-App-Dienst -- app.py
|
|
||||||
# wird nämlich NICHT nur hierüber gestartet, sondern auch von
|
|
||||||
# generate_ips.py (via "from app import ...") als reines Hilfsmodul
|
|
||||||
# importiert, z.B. aus poe.sh/tesm-check.service heraus, alle paar
|
|
||||||
# Sekunden. Ein einfacher Import führt JEDEN Modul-Level-Code in app.py
|
|
||||||
# erneut aus -- ohne dieses Flag würde jeder generate_ips.py-Aufruf
|
|
||||||
# _fileshare_cleanup_all_on_startup() erneut auslösen und damit gerade
|
|
||||||
# aktive Fileshare-Mounts anderer, echter Sitzungen sofort wieder
|
|
||||||
# aushängen. Siehe die Prüfung auf TESM_WEB_PROCESS in app.py.
|
|
||||||
Environment=TESM_WEB_PROCESS=1
|
|
||||||
# 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.
|
|
||||||
# "--bind 127.0.0.1:5000" statt 0.0.0.0: Flask/Gunicorn soll ausschließlich
|
|
||||||
# über den nginx-Reverse-Proxy (siehe Einstellungen -> NGINX) erreichbar
|
|
||||||
# sein, nicht zusätzlich direkt und unverschlüsselt über Port 5000 aus dem
|
|
||||||
# Netz -- nginx selbst greift ohnehin nur über 127.0.0.1 zu.
|
|
||||||
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
|
|
||||||
--timeout 0 --bind 127.0.0.1:5000 app:app
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -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
|
|
||||||
-253
@@ -1,253 +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; }
|
|
||||||
|
|
||||||
# Kopiert nur, wenn der Inhalt sich tatsächlich unterscheidet (oder das
|
|
||||||
# Ziel noch fehlt) -- bei einem Update sollen ausschließlich Dateien
|
|
||||||
# angefasst werden, die sich zwischen der laufenden und der neuen Version
|
|
||||||
# wirklich geändert haben (unverändert lässt "cp" Zeitstempel/Inode aber
|
|
||||||
# trotzdem hochzählen, was z.B. Monitoring/Backups unnötig triggert).
|
|
||||||
# Gibt 0 zurück, wenn tatsächlich kopiert wurde, 1 wenn übersprungen.
|
|
||||||
install_if_changed() {
|
|
||||||
local src="$1" dst="$2" mode="${3:-644}"
|
|
||||||
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
sudo install -m "$mode" "$src" "$dst"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# "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 certbot cifs-utils >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
|
||||||
|
|
||||||
# ---- Log-Verzeichnis ----
|
|
||||||
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
|
|
||||||
# auch tesm-check.service (poe.sh) laufen als root, kein anderer
|
|
||||||
# Dienstbenutzer schreibt hierher (Keas eigenes Log liegt separat unter
|
|
||||||
# TESM_KEA_LOG_PATH, i.d.R. /var/log/kea/). Ein group-/world-writable
|
|
||||||
# Verzeichnis lässt logrotate die Rotation aus Sicherheitsgründen
|
|
||||||
# komplett verweigern ("insecure permissions") -- live reproduziert: bei
|
|
||||||
# 0777 rotierte live.log über Wochen hinweg gar nicht mehr. 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 755 /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' \
|
|
||||||
--exclude 'known_hosts' --exclude 'license.json' \
|
|
||||||
"$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
|
|
||||||
UNITS_CHANGED=0
|
|
||||||
install_if_changed "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh 755 || true
|
|
||||||
for f in "$REPO_DIR"/etc/systemd/system/*.service "$REPO_DIR"/etc/systemd/system/*.timer; do
|
|
||||||
if install_if_changed "$f" "/etc/systemd/system/$(basename "$f")" 644; then
|
|
||||||
UNITS_CHANGED=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
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"
|
|
||||||
install_if_changed "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm 755 || true
|
|
||||||
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 ----
|
|
||||||
# Nur bei einer Frischinstallation aus dem Repo-Template geschrieben --
|
|
||||||
# eine bereits vorhandene Konfiguration (egal ob unverändert oder von der
|
|
||||||
# App selbst z.B. um SSL/HSTS erweitert, siehe Systemeinstellungen -> SSL)
|
|
||||||
# wird NIE wieder überschrieben. Symlink/Default-Site-Entfernung und der
|
|
||||||
# Validierungs-Reload laufen dagegen immer -- rein idempotent, ändern an
|
|
||||||
# einer bestehenden Konfiguration selbst nichts.
|
|
||||||
step "Configuring nginx reverse proxy"
|
|
||||||
if [ ! -f /etc/nginx/sites-available/tesm ]; then
|
|
||||||
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
|
|
||||||
fi
|
|
||||||
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"
|
|
||||||
if [ "$UNITS_CHANGED" -eq 1 ]; then
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
fi
|
|
||||||
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)."
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tesm-core"
|
||||||
|
version = "2.0.8"
|
||||||
|
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,51 @@
|
|||||||
|
"""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 aus den Metadaten des installierten Pakets -- die einzige Quelle
|
||||||
|
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
|
||||||
|
#: Fusszeile zeigte vier Releases lang 2.0.2.
|
||||||
|
#:
|
||||||
|
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
|
||||||
|
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
|
||||||
|
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
|
||||||
|
#: tatsaechlich installiert ist.
|
||||||
|
try: # pragma: no cover - haengt an der Installationsart
|
||||||
|
from importlib.metadata import PackageNotFoundError, version as _paketversion
|
||||||
|
|
||||||
|
__version__ = _paketversion("tesm-core")
|
||||||
|
except (ImportError, PackageNotFoundError): # pragma: no cover
|
||||||
|
__version__ = "unbekannt"
|
||||||
|
|
||||||
|
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,417 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||||
|
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||||
|
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||||
|
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||||
|
|
||||||
|
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(
|
||||||
|
"session_idle_minutes",
|
||||||
|
30,
|
||||||
|
"int",
|
||||||
|
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||||
|
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||||
|
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||||
|
"Einbindung.",
|
||||||
|
section="system",
|
||||||
|
minimum=5,
|
||||||
|
maximum=1440,
|
||||||
|
),
|
||||||
|
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 = nur die
|
||||||
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
|
#: Anwendungen.
|
||||||
|
auth_tagline: str = "",
|
||||||
|
app_version: str = "2.0.8",
|
||||||
|
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
|
||||||
|
|
||||||
|
def _leerlauf(conn: Any) -> int:
|
||||||
|
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||||
|
|
||||||
|
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||||
|
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||||
|
gilt hier als Fehler.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wert = 0
|
||||||
|
return wert if wert > 0 else config.security.session_idle_minutes
|
||||||
|
|
||||||
|
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,
|
||||||
|
idle_lookup=_leerlauf,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- 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]
|
||||||
|
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||||
|
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||||
|
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||||
|
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||||
|
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||||
|
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||||
|
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||||
|
# kann.
|
||||||
|
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||||
|
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||||
|
response.headers["X-Session-Expired"] = "1"
|
||||||
|
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,
|
||||||
|
"copyright_notice": COPYRIGHT_NOTICE,
|
||||||
|
"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
|
||||||
@@ -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 Gruppenaufloesung 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 Gruppenaufloesung 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("Gruppenaufloesung 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,311 @@
|
|||||||
|
"""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
|
||||||
|
from ..security.sessions import is_automatic
|
||||||
|
|
||||||
|
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
|
||||||
|
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
|
||||||
|
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
|
||||||
|
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
|
||||||
|
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
|
||||||
|
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
|
||||||
|
# Mensch sieht die Anmeldemaske.
|
||||||
|
if is_automatic(request):
|
||||||
|
abort(401, description="Sitzung abgelaufen.")
|
||||||
|
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||||
|
abort(401, description="Anmeldung erforderlich.")
|
||||||
|
# "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
|
||||||
|
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
|
||||||
|
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
|
||||||
|
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
|
||||||
|
if getattr(g, "session_expired", False):
|
||||||
|
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut 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"
|
||||||
@@ -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"]
|
||||||
@@ -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 = 30
|
||||||
|
#: 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", 30),
|
||||||
|
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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user