Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
491724beb5 | ||
|
|
b54b87ca6f | ||
|
|
270ba455a8 | ||
|
|
f3b312046f | ||
|
|
436b080deb | ||
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 | ||
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 | ||
|
|
292ea622a7 | ||
|
|
0d99760d89 | ||
|
|
713ff12db2 | ||
|
|
2db7e03154 | ||
|
|
5dcb87d6ec | ||
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 | ||
|
|
ce22239d78 | ||
|
|
92c8abb79b | ||
|
|
dcb4f57eeb | ||
|
|
11a55b20f8 | ||
|
|
ed17bce40a | ||
|
|
5282cd8f2d | ||
|
|
754cbea6bb | ||
|
|
07ae69e3b7 | ||
|
|
d3c700283a | ||
|
|
61a47debdc | ||
|
|
bec74e6936 | ||
|
|
d2679361bc | ||
|
|
20c7fd8813 | ||
|
|
eaca6f7990 | ||
|
|
4eb1c0c2d8 | ||
|
|
c0a6dd057c | ||
|
|
8c2cd10ee4 | ||
|
|
2c3ca4acc1 | ||
|
|
f16ba7d875 | ||
|
|
7bfe328e4b | ||
|
|
5b9373bb24 | ||
|
|
f77c8b221c | ||
|
|
985deb7a24 | ||
|
|
258e90bf4f | ||
|
|
337a52cc38 | ||
|
|
160675e368 | ||
|
|
3bbddd7d29 | ||
|
|
7407f4bc00 | ||
|
|
635722089e | ||
|
|
6dff1ed137 | ||
|
|
b4529a0e31 | ||
|
|
313eb4bbf9 | ||
|
|
51ae39dd53 | ||
|
|
a8bd5763fb | ||
|
|
d6ba98782e | ||
|
|
47ab97a0ac | ||
|
|
fbbf8eb87b | ||
|
|
9a760274f8 | ||
|
|
cf16809ee2 | ||
|
|
d8368fc200 | ||
|
|
e343aee026 | ||
|
|
4065b3586b | ||
|
|
5bb0809009 | ||
|
|
64e25bb9ae | ||
|
|
7354ff352b | ||
|
|
a193b9d6e9 | ||
|
|
56974a4308 | ||
|
|
b69d0745dd | ||
|
|
7ab6358b9f | ||
|
|
f02db4c6b5 | ||
|
|
c7ad0d3d49 | ||
|
|
f09aaae8a9 | ||
|
|
36816c57ef | ||
|
|
4a77a3ba32 | ||
|
|
7a1f2b164e | ||
|
|
682e9b4420 | ||
|
|
90e07aa310 | ||
|
|
c9ac0bf69b | ||
|
|
b70ebefb17 | ||
|
|
d90c27b8f8 | ||
|
|
8577cd24cd | ||
|
|
877903bd77 | ||
|
|
2b05d012ee | ||
|
|
0f0fa99e53 |
No files matched your search
@@ -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)
|
||||
/srv/tesm/venv/
|
||||
# Instanzdaten und Geheimnisse gehoeren nie ins Repo.
|
||||
instance/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data.keys
|
||||
secret.key
|
||||
license.json
|
||||
license_key.json
|
||||
master_signing_key.json
|
||||
known_hosts
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Logfiles
|
||||
*.log
|
||||
# Werkzeuge
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Secrets — niemals committen
|
||||
/srv/tesm/sqlite.db
|
||||
/srv/tesm/fernet.key
|
||||
/srv/tesm/secret.key
|
||||
/srv/tesm/known_hosts
|
||||
|
||||
# Laufzeit-Uploads (Profilbilder) — werden bei Bedarf automatisch angelegt
|
||||
/srv/tesm/static/uploads/
|
||||
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||
*.tar.gz
|
||||
@@ -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.8.17"
|
||||
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,398 @@
|
||||
"""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 DHCP_PAGE, 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,
|
||||
screen,
|
||||
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)
|
||||
screen.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 für %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 flask import session
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
extension = core()
|
||||
# Nur die Einbindungen dieser Sitzung: wer an zwei Geraeten angemeldet ist,
|
||||
# soll am zweiten weiterarbeiten koennen.
|
||||
sitzung = str(getattr(session, "sid", "") or "")
|
||||
with extension.database.transaction(conn):
|
||||
released = fileshare.release_all_for_user(conn, user.id, sitzung)
|
||||
if released:
|
||||
current_app.logger.info(
|
||||
"Dateifreigaben für %s gelöst: %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.read", "monitoring.read")):
|
||||
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": "Nächste Prüfung in",
|
||||
"countdown_to": faellig.isoformat(),
|
||||
"countdown_done": "jetzt",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": f"Der Überwachungsdienst prüft 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 prüfen"
|
||||
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": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||
}
|
||||
)
|
||||
|
||||
if user.has_any(DHCP_PAGE) 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 Gerät 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="Endgültiges Löschen 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 übersprungen – 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 Wartungsaufträge 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
|
||||
import time
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
def einmal() -> None:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen gelöst", geloest)
|
||||
|
||||
def schleife() -> None:
|
||||
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
||||
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
|
||||
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
|
||||
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
|
||||
# war weg, ohne eine Zeile zu hinterlassen.
|
||||
try:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
einmal()
|
||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||
logger.exception("Aufräumen der Dateifreigaben fehlgeschlagen")
|
||||
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
||||
logger.exception("Der Aufräumfaden der Dateifreigaben ist beendet")
|
||||
raise
|
||||
|
||||
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="/administration/backup")
|
||||
|
||||
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_export.secrets"):
|
||||
continue
|
||||
allowed.append(category)
|
||||
return allowed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("backup_export.read", "backup_import.read", 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_export.secrets"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup_export.export")
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
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 ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
include_secrets = (
|
||||
request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
|
||||
)
|
||||
passphrase = request.form.get("passphrase") or ""
|
||||
repeat = request.form.get("passphrase_repeat") or ""
|
||||
if passphrase != repeat:
|
||||
flash("Die beiden Passphrasen stimmen nicht überein.", "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/preview")
|
||||
@permission_required("backup_import.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 ausgewählt.", "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 groß.", "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 geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Übernahme zu bestätigen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/apply")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
extension = core()
|
||||
conn = db()
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if not token:
|
||||
flash("Es liegt keine geöffnete 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 ausgewählt.", "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']} übersprungen"
|
||||
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']} übersprungen" if counts["skipped"] else "")
|
||||
for key, counts in report.items()
|
||||
if key in transfer.CATEGORY_BY_KEY
|
||||
]
|
||||
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/discard")
|
||||
@permission_required("backup_import.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
|
||||
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="/monitoring/credentials")
|
||||
|
||||
|
||||
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.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/credentials.html",
|
||||
credentials=inventory.list_credentials(conn),
|
||||
categories=inventory.CREDENTIAL_CATEGORIES,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/new")
|
||||
@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.write")
|
||||
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("Änderungen gespeichert." if changes else "Keine Änderungen.", "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>/delete")
|
||||
@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>/reveal")
|
||||
@permission_required("credentials.secrets")
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
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,175 @@
|
||||
"""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
|
||||
|
||||
from ..services import vnc as vnc_dienst
|
||||
|
||||
conn = db()
|
||||
counts = monitor.dashboard_counts(conn)
|
||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||
|
||||
detailed = authenticated and current_user.has("devices.read")
|
||||
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
|
||||
# und ohne das Recht wird gar nicht erst nachgesehen.
|
||||
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
|
||||
vnc_port = (
|
||||
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
||||
else 0
|
||||
)
|
||||
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.
|
||||
# Mit Rechtefilter: die Kachel zeigt nur Wege, die
|
||||
# dieser Benutzer auch ausloesen darf.
|
||||
"restart_methods": restart.available(
|
||||
row,
|
||||
maintenance_active=_maintenance_active(),
|
||||
darf=current_user.has,
|
||||
),
|
||||
# Der Knopf oeffnet die Sitzung in TESM selbst. Ein
|
||||
# vnc://-Link setzte einen eingerichteten Viewer auf dem
|
||||
# Arbeitsplatz voraus -- und tat dort, wo keiner
|
||||
# registriert ist, schlicht nichts.
|
||||
#
|
||||
# Ob dort ein Server antwortet, steht aus dem letzten
|
||||
# Durchlauf in der Datenbank. Die Uebersicht zeigt viele
|
||||
# Kacheln und laedt sich selbst nach; ein
|
||||
# Verbindungsversuch je Kachel und Aufbau waere genau die
|
||||
# Art Hintergrundlast, die niemand bestellt hat.
|
||||
"vnc": bool(vnc_port and row["vnc_open"]),
|
||||
}
|
||||
)
|
||||
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 läuft",
|
||||
"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 geprüft",
|
||||
"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("/overview")
|
||||
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("/overview/tiles")
|
||||
@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,517 @@
|
||||
"""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,
|
||||
Response,
|
||||
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, vnc
|
||||
|
||||
bp = Blueprint("devices", __name__, url_prefix="/monitoring/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.read")
|
||||
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("Für 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 Geräts erfasst.",
|
||||
actor=current_user.username,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/new")
|
||||
@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("Gerät 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.write")
|
||||
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("Änderungen gespeichert." if changes else "Keine Änderungen.", "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>/active")
|
||||
@permission_required("devices.write")
|
||||
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("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/delete")
|
||||
@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("Gerät in den Papierkorb verschoben.", "success")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/restart")
|
||||
# Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht,
|
||||
# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber
|
||||
# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn
|
||||
# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen:
|
||||
# "dieser Weg steht nicht zur Verfuegung".
|
||||
@permission_required("devices.read")
|
||||
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,
|
||||
darf=current_user.has,
|
||||
)
|
||||
if method is None:
|
||||
options = restart.available(
|
||||
dict(device), maintenance_active=maintenance_active, darf=current_user.has
|
||||
)
|
||||
if options:
|
||||
flash(
|
||||
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
|
||||
"Möglich wäre: " + ", ".join(entry.label for entry in options),
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
|
||||
"für den PoE-Weg und passende Zugangsdaten für 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 für {device['name']} durchgeführt."
|
||||
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} für {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.read")
|
||||
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_clients_enabled")),
|
||||
vnc=_vnc_zugang(conn, device),
|
||||
)
|
||||
|
||||
|
||||
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||
"""Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist.
|
||||
|
||||
Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
|
||||
Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
|
||||
ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
|
||||
jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
|
||||
Server zaehlt so etwas mit.
|
||||
"""
|
||||
extension = core()
|
||||
if not current_user.has("device_vnc.execute"):
|
||||
return None
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return None
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
return vnc.zugang_gemerkt(device, port=port)
|
||||
|
||||
|
||||
@bp.get("/<int:device_id>/vnc")
|
||||
@permission_required("device_vnc.execute")
|
||||
def vnc_connection(device_id: int):
|
||||
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||
|
||||
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||
"""
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
abort(404)
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
ziel = vnc.ziel(device)
|
||||
if not ziel:
|
||||
abort(404)
|
||||
with transaction():
|
||||
_audit(
|
||||
conn,
|
||||
"device.vnc_opened",
|
||||
str(device["name"]),
|
||||
f"Verbindungsdatei für {ziel}:{port}",
|
||||
)
|
||||
return Response(
|
||||
vnc.verbindungsdatei(
|
||||
device,
|
||||
port=port,
|
||||
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
|
||||
),
|
||||
mimetype="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SSH-Host-Schluessel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/host-key/probe")
|
||||
@permission_required("devices.write")
|
||||
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>/host-key/trust")
|
||||
@permission_required("devices.write")
|
||||
@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 gültiger Fingerprint übergeben.", "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-Schlüssel freigegeben.", "success")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/host-key/forget")
|
||||
@permission_required("devices.write")
|
||||
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-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
@@ -0,0 +1,554 @@
|
||||
"""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
|
||||
from ..permissions import DHCP_PAGE
|
||||
|
||||
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_PAGE, 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("/settings")
|
||||
@permission_required("dhcp_subnets.write")
|
||||
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 Änderungen.", "success" if changed else "info")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnet")
|
||||
@permission_required("dhcp_subnets.create")
|
||||
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("/subnet/<int:subnet_id>")
|
||||
@permission_required("dhcp_subnets.write")
|
||||
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"))
|
||||
|
||||
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
|
||||
# gespeichert" allein sagt nicht, was vorher eingestellt war.
|
||||
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||
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,
|
||||
),
|
||||
)
|
||||
nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||
_audit(
|
||||
"dhcp.subnet_updated",
|
||||
f"{interface} {start}-{end}",
|
||||
audit.diff(
|
||||
vorher,
|
||||
nachher,
|
||||
felder=(
|
||||
("interface", "Schnittstelle"),
|
||||
("range_start", "Von"),
|
||||
("range_end", "Bis"),
|
||||
("gateway", "Gateway"),
|
||||
("dns", "DNS"),
|
||||
("comment", "Kommentar"),
|
||||
("enabled", "Aktiv"),
|
||||
),
|
||||
)
|
||||
or "keine inhaltliche Änderung",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Subnetz gespeichert.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||
@permission_required("dhcp_subnets.delete")
|
||||
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/value")
|
||||
@permission_required("dhcp_options.write")
|
||||
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/device")
|
||||
@permission_required("dhcp_options.write")
|
||||
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']} für {device['name']} "
|
||||
f"{'gespeichert' if value else 'entfernt'}.",
|
||||
"success",
|
||||
)
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/option/new")
|
||||
@permission_required("dhcp_options.write")
|
||||
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>/delete")
|
||||
@permission_required("dhcp_options.write")
|
||||
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 können nicht gelöscht werden – nur ihr Wert lässt 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("/reservation")
|
||||
@permission_required("dhcp_reservations.create")
|
||||
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 ungültig.", "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 geprüft 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 wäre ohne Wirkung.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||
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(),
|
||||
),
|
||||
)
|
||||
nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||
_audit(
|
||||
"dhcp.reservation_saved",
|
||||
mac,
|
||||
audit.diff(
|
||||
vorher,
|
||||
nachher,
|
||||
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
|
||||
)
|
||||
or f"neu angelegt: {ip}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Reservierung gespeichert.", "success")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||
@permission_required("dhcp_reservations.delete")
|
||||
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 verfügbar."}
|
||||
|
||||
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 gültiges 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("/write")
|
||||
@permission_required("dhcp_config.execute")
|
||||
def write_config():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/service/<action>")
|
||||
@permission_required("dhcp_service.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 verfügbar.", "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,558 @@
|
||||
"""Dateifreigaben: Blaettern, Herunterladen, Hochladen, Verwalten."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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 import uploads
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import fileshare as service
|
||||
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
|
||||
|
||||
|
||||
@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 _unterschriften(roh: str) -> list[str]:
|
||||
"""Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur.
|
||||
|
||||
Was hier ankommt, hat der Browser geschickt und damit der Mensch davor.
|
||||
Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche
|
||||
raus, mehr passiert damit nicht.
|
||||
"""
|
||||
namen: list[str] = []
|
||||
try:
|
||||
eintraege = json.loads(roh) if roh else []
|
||||
except ValueError:
|
||||
return []
|
||||
if not isinstance(eintraege, list):
|
||||
return []
|
||||
for eintrag in eintraege[:20]:
|
||||
name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", ""))
|
||||
name = " ".join(name.split())[:120]
|
||||
if name:
|
||||
namen.append(name)
|
||||
return namen
|
||||
|
||||
|
||||
def _mount_or_404(token: str) -> service.Mount:
|
||||
conn = db()
|
||||
mounts = service.doppelte_zusammenfassen(
|
||||
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||
)
|
||||
for mount in mounts:
|
||||
if mount.token == token:
|
||||
return mount
|
||||
abort(404)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("fileshare.read")
|
||||
def browse():
|
||||
conn = db()
|
||||
mounts = service.doppelte_zusammenfassen(
|
||||
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||
)
|
||||
token = request.args.get("share") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("path") 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,
|
||||
# Dieselbe Zahl, die auch die Anfrage begrenzt und in nginx steht --
|
||||
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
|
||||
# wurde.
|
||||
max_upload_mb=uploads.limit_mb(conn),
|
||||
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||
verzeichnisse=(service.all_directories(mount) if listing else []),
|
||||
signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")),
|
||||
# Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht
|
||||
# davon abhaengen, wie das Tablet gestellt ist.
|
||||
serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/download")
|
||||
# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
|
||||
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
|
||||
# heruntergeladene Datei nicht.
|
||||
@permission_required("fileshare.export")
|
||||
def download(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("path") 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,
|
||||
# Bewusst der neutrale Typ und nicht der erkannte: iOS Safari
|
||||
# entscheidet nach dem MIME-Typ und zeigt eine "application/pdf" im
|
||||
# Browser an, auch wenn sie als Anhang ausgeliefert wird. Wer hier
|
||||
# klickt, will die Datei aber ablegen -- angesehen wird sie in der
|
||||
# Vorschau. Fuer alle anderen Browser aendert sich damit nichts.
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/preview")
|
||||
@permission_required("fileshare.read")
|
||||
def preview(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("path") 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>/upload")
|
||||
@permission_required("fileshare.create")
|
||||
def upload(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("path") or ""
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
limit = uploads.limit_bytes(db())
|
||||
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 groß (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", share=token, path=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", share=token, path=relative))
|
||||
|
||||
|
||||
@bp.get("/<token>/signature")
|
||||
@permission_required("fileshare.read")
|
||||
def signature_state(token: str):
|
||||
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
||||
|
||||
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
|
||||
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
|
||||
das ist die Frage, die man sich vor einer Unterschrift stellt.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
try:
|
||||
return service.signaturstand(db(), mount, request.args.get("path") or "")
|
||||
except service.FileshareError as exc:
|
||||
return {"error": str(exc)}, 400
|
||||
|
||||
|
||||
@bp.get("/<token>/folders")
|
||||
@permission_required("fileshare.read")
|
||||
def folders(token: str):
|
||||
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
||||
|
||||
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
|
||||
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
|
||||
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
|
||||
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
|
||||
viertel Megabyte, die fast nie gebraucht wird.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
return {"folders": service.all_directories(mount)}
|
||||
|
||||
|
||||
@bp.post("/<token>/form")
|
||||
@permission_required("fileshare_pdf.create")
|
||||
def save_form(token: str):
|
||||
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
||||
|
||||
Als **neue** Datei: auf einer Freigabe liegt in der Regel die leere
|
||||
Vorlage, und die soll leer bleiben. Wer das Original ersetzen will, kann
|
||||
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
|
||||
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
|
||||
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
|
||||
# diesem Benutzer gerade gehoert, kommt in Frage.
|
||||
ziel_token = (request.form.get("ziel") or "").strip()
|
||||
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
|
||||
relative = request.form.get("path") or ""
|
||||
upload_file = request.files.get("datei")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
return {"error": "Es kam keine Datei an."}, 400
|
||||
|
||||
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
|
||||
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
|
||||
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
|
||||
# abgewiesen.
|
||||
if service.signaturstand(db(), mount, relative).get("status") in {
|
||||
"unveraendert",
|
||||
"veraendert",
|
||||
}:
|
||||
return {
|
||||
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
|
||||
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
|
||||
}, 409
|
||||
|
||||
grenze = uploads.limit_bytes(db())
|
||||
daten = upload_file.stream.read(grenze + 1)
|
||||
if len(daten) > grenze:
|
||||
return {"error": f"Zu groß (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
||||
if not daten.startswith(b"%PDF-"):
|
||||
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
||||
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
||||
return {"error": "Das ist keine PDF-Datei."}, 415
|
||||
|
||||
# Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur
|
||||
# anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch
|
||||
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
|
||||
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
|
||||
# niemandem weiter.
|
||||
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.write"):
|
||||
return {
|
||||
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
|
||||
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
|
||||
}, 403
|
||||
|
||||
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
|
||||
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
||||
wunschname = (request.form.get("name") or "").strip()
|
||||
ersetzen = request.form.get("ersetzen") == "1"
|
||||
|
||||
try:
|
||||
original = service.resolve_path(mount, relative)
|
||||
# Ohne Namen landet die Datei neben dem Original -- das geht nur in
|
||||
# derselben Freigabe.
|
||||
if ziel_mount.token != mount.token and not wunschname:
|
||||
return {"error": "Für eine andere Freigabe braucht es einen Dateinamen."}, 400
|
||||
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||
# es beim Ordner des Originals.
|
||||
if wunschname:
|
||||
zielordner = service.resolve_path(ziel_mount, ordner)
|
||||
else:
|
||||
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||
if not zielordner.is_dir():
|
||||
return {
|
||||
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
|
||||
f"'{ziel_mount.label}' nicht."
|
||||
}, 400
|
||||
if wunschname:
|
||||
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
|
||||
name = service.safe_filename(wunschname)
|
||||
if not name.lower().endswith(".pdf"):
|
||||
name += ".pdf"
|
||||
ziel = zielordner / name
|
||||
if ziel.exists() and not ersetzen:
|
||||
return {
|
||||
"error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
|
||||
"Namen wählen oder 'Vorhandene ersetzen' ankreuzen."
|
||||
}, 409
|
||||
else:
|
||||
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
|
||||
ziel.write_bytes(daten)
|
||||
except service.FileshareError as exc:
|
||||
return {"error": str(exc)}, 400
|
||||
|
||||
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
||||
# Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
|
||||
# mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll.
|
||||
# Wer Dateien ablegen darf, darf das nicht automatisch.
|
||||
if unterschriften and not current_user.has("fileshare_signature.execute"):
|
||||
return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403
|
||||
|
||||
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
|
||||
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
|
||||
# Zeile drueckte der Hash den Rest aus der Spalte.
|
||||
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
|
||||
if unterschriften:
|
||||
kopf += f" von: {', '.join(unterschriften)}"
|
||||
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
|
||||
_audit(
|
||||
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||
f"{mount.label}:{relative}",
|
||||
beschreibung,
|
||||
severity="notice",
|
||||
)
|
||||
if unterschriften:
|
||||
# Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann
|
||||
# archiviert, die Frage "ist diese Datei noch die unterschriebene?"
|
||||
# stellt sich aber noch Jahre spaeter. Und mit fsync -- die Datei liegt
|
||||
# schon auf der Freigabe; ein Stromausfall soll nicht den Nachweis
|
||||
# dazu verschlucken.
|
||||
verbindung = db()
|
||||
with core().database.dauerhaft(verbindung), transaction():
|
||||
service.signatur_merken(
|
||||
verbindung,
|
||||
sha256=pruefsumme,
|
||||
label=ziel_mount.label,
|
||||
pfad=wohin,
|
||||
actor=current_user.username,
|
||||
namen=unterschriften,
|
||||
)
|
||||
# Zweites Exemplar ausserhalb der Datenbank.
|
||||
service.signatur_journalisieren(
|
||||
sha256=pruefsumme,
|
||||
label=ziel_mount.label,
|
||||
pfad=wohin,
|
||||
actor=current_user.username,
|
||||
namen=unterschriften,
|
||||
)
|
||||
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||
|
||||
|
||||
@bp.post("/<token>/folder")
|
||||
@permission_required("fileshare.create")
|
||||
def make_directory(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("path") 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", share=token, path=relative))
|
||||
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
||||
flash("Ordner angelegt.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
|
||||
@bp.post("/<token>/rename")
|
||||
@permission_required("fileshare.write")
|
||||
def rename(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("path") 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", share=token, path=parent))
|
||||
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
||||
flash("Umbenannt.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/delete")
|
||||
@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("paths"):
|
||||
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/Einträge gelöscht.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/release")
|
||||
@permission_required("fileshare.read")
|
||||
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 gelöst.", "success")
|
||||
return redirect(url_for("fileshare.browse"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verwaltung der Zuordnungen (Einstellungen)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/mappings")
|
||||
@permission_required("fileshare_mappings.read")
|
||||
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("/mappings")
|
||||
@permission_required("fileshare_mappings.create")
|
||||
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", ""),
|
||||
service.describe_mapping(
|
||||
{
|
||||
"unc": request.form.get("unc", ""),
|
||||
"ad_group_name": request.form.get("ad_group_name", ""),
|
||||
"ad_group_dn": request.form.get("ad_group_dn", ""),
|
||||
}
|
||||
),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Zuordnung gespeichert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/mappings/<int:mapping_id>")
|
||||
@permission_required("fileshare_mappings.write")
|
||||
def mapping_update(mapping_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
# Den Stand vor der Aenderung merken -- danach ist er weg, und
|
||||
# "geaendert" ohne "was" beantwortet keine einzige Frage, die man
|
||||
# spaeter an ein Protokoll stellt.
|
||||
vorher = service.get_mapping(conn, mapping_id)
|
||||
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", ""),
|
||||
)
|
||||
nachher = service.get_mapping(conn, mapping_id)
|
||||
_audit(
|
||||
"fileshare.mapping_updated",
|
||||
str((nachher or {}).get("label") or request.form.get("label", "")),
|
||||
service.mapping_changes(vorher, nachher) or "keine inhaltliche Änderung",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Zuordnung geändert.", "success")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||
@permission_required("fileshare_mappings.delete")
|
||||
def mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
entfernt = service.get_mapping(conn, mapping_id)
|
||||
service.delete_mapping(conn, mapping_id)
|
||||
# Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang
|
||||
# zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung.
|
||||
_audit(
|
||||
"fileshare.mapping_deleted",
|
||||
str((entfernt or {}).get("label") or mapping_id),
|
||||
service.describe_mapping(entfernt),
|
||||
severity="warning",
|
||||
)
|
||||
flash("Zuordnung entfernt.", "success")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
@@ -0,0 +1,253 @@
|
||||
"""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="/monitoring/host-keys")
|
||||
|
||||
KIND_DEVICE = "client"
|
||||
KIND_SWITCH = "switch"
|
||||
|
||||
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
|
||||
#: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass
|
||||
#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels
|
||||
#: entscheidet nicht mehr mit -- Schluessel ist Schluessel.
|
||||
FREIGABE_RECHT = "hostkeys.execute"
|
||||
EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT}
|
||||
|
||||
|
||||
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("hostkeys.read")
|
||||
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>/probe")
|
||||
@permission_required("hostkeys.read")
|
||||
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>/trust")
|
||||
@permission_required("hostkeys.read")
|
||||
@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 gültiger Fingerprint übergeben.", "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-Schlüssel von {target['label']} freigegeben.", "success")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/forget")
|
||||
@permission_required("hostkeys.read")
|
||||
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 Schlüssel von {target['label']} entfernt.", "info")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/orphaned/forget")
|
||||
@permission_required("hostkeys.execute")
|
||||
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("Ungültiges 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 für {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="/administration/license")
|
||||
|
||||
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.read")
|
||||
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("/install")
|
||||
@permission_required("settings_license.write")
|
||||
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 ausgewählt.", "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 groß für 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 ungewöhnlich.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/activate")
|
||||
@permission_required("settings_license.write")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deactivate")
|
||||
@permission_required("settings_license.write")
|
||||
@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.write")
|
||||
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-response")
|
||||
@permission_required("settings_license.write")
|
||||
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("/server-certificate")
|
||||
@permission_required("settings_license.write")
|
||||
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 nächste Versuch läuft wieder über den Online-Weg."
|
||||
if pem
|
||||
else "Zertifikat entfernt – es gelten wieder die Systemanker.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/issuer/<key_id>/remove")
|
||||
@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-Schlüssel 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="/logs")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
|
||||
@bp.get("/dhcp")
|
||||
@permission_required("logs_dhcp.read")
|
||||
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 verfügbar."
|
||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||
|
||||
|
||||
@bp.get("/restarts")
|
||||
@permission_required("devices.read")
|
||||
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("method") 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,291 @@
|
||||
"""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 ..permissions import SERVICING_PAGE
|
||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||
|
||||
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
||||
|
||||
|
||||
@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(*SERVICING_PAGE, 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_clients_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("/install-package")
|
||||
@permission_required("servicing_tool.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 verfügbar.", "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(*SERVICING_PAGE, 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("/job/<job_id>")
|
||||
@permission_required(*SERVICING_PAGE, 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("/job/<job_id>/output")
|
||||
@permission_required(*SERVICING_PAGE, 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 Gerät ausgewählt.", "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 für Linux-Clients. "
|
||||
"Windows-Geräte 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/Aufträge gestartet.", "success")
|
||||
|
||||
|
||||
@bp.post("/update")
|
||||
@permission_required("servicing_update.execute")
|
||||
def start_update():
|
||||
_start(service.JOB_KIND_UPDATE)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/restart")
|
||||
@permission_required("servicing_reboot.execute")
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Bildschirm eines Clients im Browser -- noVNC ueber eine WebSocket-Strecke.
|
||||
|
||||
Warum ueberhaupt im Browser und nicht mit dem Viewer auf dem Arbeitsplatz: der
|
||||
Viewer muss installiert, gepflegt und mit Zugangsdaten versorgt werden, und vom
|
||||
Telefon aus gibt es ihn gar nicht. Hier genuegt die Anmeldung an TESM -- die
|
||||
Rechte, die Protokollierung und die Frage "darf dieser Mensch auf diesen
|
||||
Bildschirm?" haengen ohnehin schon dort.
|
||||
|
||||
**Die Strecke reicht durch, sie versteht nichts.** Der Server oeffnet eine
|
||||
gewoehnliche TCP-Verbindung zum VNC-Server und schaufelt Bytes in beide
|
||||
Richtungen; das RFB-Protokoll samt Authentifizierung handelt der Browser
|
||||
unmittelbar mit dem Geraet aus. Das ist Absicht: eine Gegenstelle, die das
|
||||
Protokoll auspackt, muesste die Verschluesselung aufbrechen und wieder
|
||||
aufbauen -- mehr Code an der empfindlichsten Stelle, ohne dass jemand etwas
|
||||
davon haette.
|
||||
|
||||
**Was daraus folgt** und nicht verschwiegen werden soll: weil der Browser die
|
||||
Anmeldung fuehrt, muss er die Zugangsdaten kennen. Hinterlegte Zugangsdaten
|
||||
gibt TESM deshalb nur an einen Menschen heraus, der Geheimnisse ohnehin sehen
|
||||
darf (``credentials.secrets``) -- jede Herausgabe steht im Aenderungsprotokoll.
|
||||
Wer das Recht nicht hat, tippt sie in der Seite selbst ein; sie gehen dann
|
||||
durch den Browser, aber nie durch unsere Datenbank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, 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, reauth_is_fresh
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import inventory, vnc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("screen", __name__)
|
||||
|
||||
#: Wie lange auf den VNC-Server gewartet wird, bis die Strecke steht.
|
||||
VERBINDUNGSLIMIT = 6.0
|
||||
|
||||
#: Groesse der Haeppchen, in denen vom Geraet gelesen wird. Ein Bildschirm
|
||||
#: liefert in Schueben; kleiner hiesse mehr Runden, groesser mehr Wartezeit,
|
||||
#: bis ein Schub weitergereicht wird.
|
||||
BLOCK = 32 * 1024
|
||||
|
||||
|
||||
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or device.get("deleted_at"):
|
||||
return None
|
||||
return device
|
||||
|
||||
|
||||
def _port(conn: Any) -> int:
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/screen")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def screen(device_id: int):
|
||||
"""Die Seite mit der Leinwand.
|
||||
|
||||
Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass,
|
||||
dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der
|
||||
Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren
|
||||
pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler:
|
||||
jede Messung ist eine angefangene und abgebrochene Protokollverhandlung,
|
||||
und ein RealVNC Server zaehlt so etwas -- das Oeffnen der Seite hat der
|
||||
Sitzung also selbst im Weg gestanden.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||
benutzer, passwort = "", ""
|
||||
darf_geheimnis = bool(current_user.has("credentials.secrets"))
|
||||
if device.get("credential_id") and darf_geheimnis:
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, core().keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError:
|
||||
benutzer, passwort = "", ""
|
||||
if passwort:
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.vnc_credentials_used",
|
||||
target=str(device["name"]),
|
||||
detail=(
|
||||
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
|
||||
"übergeben"
|
||||
),
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"tesm/screen.html",
|
||||
device=device,
|
||||
zugang=zugang,
|
||||
websocket_path=f"/ws/screen/{device_id}",
|
||||
vorgaben={"username": benutzer, "password": passwort},
|
||||
darf_geheimnis=darf_geheimnis,
|
||||
verfahren_name=vnc.verfahren_name,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def probe(device_id: int):
|
||||
"""Fragt das Geraet ausdruecklich, wie es sich authentifizieren will.
|
||||
|
||||
Ein Knopf und keine Nebenwirkung des Seitenaufbaus: die Messung bricht eine
|
||||
Protokollverhandlung ab, und RealVNC zaehlt das. Sie gehoert in die Hand
|
||||
dessen, der gerade sucht -- nicht in jeden Aufruf.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
from ..services import monitor
|
||||
|
||||
zugang = vnc.zugang(device, port=port)
|
||||
with core().database.transaction(conn):
|
||||
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
|
||||
monitor.merke_verfahren(conn, int(device["id"]), zugang.verfahren, zugang.absage)
|
||||
if zugang.absage:
|
||||
flash(f"Der Server antwortet: „{zugang.absage}“", "warning")
|
||||
elif zugang.verfahren:
|
||||
flash(
|
||||
"Angebotene Anmeldeverfahren: "
|
||||
+ ", ".join(vnc.verfahren_name(v) for v in zugang.verfahren),
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
flash(f"{zugang.ziel}:{port} hat nicht geantwortet.", "danger")
|
||||
return redirect(url_for("screen.screen", device_id=device_id))
|
||||
|
||||
|
||||
def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
|
||||
app.register_blueprint(bp)
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||
logger.info("flask-sock fehlt – die Bildschirmsitzung bleibt abgeschaltet.")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route("/ws/screen/<int:device_id>")
|
||||
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
|
||||
_strecke(ws, device_id)
|
||||
|
||||
@sock.route("/ws/probe")
|
||||
def probe_socket(ws): # type: ignore[no-untyped-def]
|
||||
"""Sagt "da", und mehr nicht.
|
||||
|
||||
Wozu: bricht eine Bildschirmsitzung ab, gibt es zwei sehr verschiedene
|
||||
Ursachen -- der VNC-Server mag nicht, oder die WebSocket-Verbindung
|
||||
kommt gar nicht erst durch (ein Proxy zwischen Browser und TESM, der
|
||||
"Upgrade" nicht weiterreicht, ist der haeufigste Fall). Von aussen
|
||||
sehen beide gleich aus: ein Abbruch ohne Grund. Diese Probe trennt
|
||||
sie, ohne dass jemand ein Protokoll lesen muss.
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
ws.close(1008, "Nicht angemeldet.")
|
||||
return
|
||||
try:
|
||||
ws.send("da")
|
||||
ws.receive(timeout=2)
|
||||
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||
pass
|
||||
|
||||
|
||||
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bytes hin, Bytes her -- und sonst nichts.
|
||||
|
||||
Die Pruefungen stehen hier ausdruecklich noch einmal: ein WebSocket geht an
|
||||
den Dekoratoren der HTTP-Route vorbei.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
|
||||
if not current_user.is_authenticated or not current_user.has("device_vnc.execute"):
|
||||
ws.close(1008, "Keine Berechtigung für den Bildschirm dieses Clients.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
ws.close(1008, "Die Passwortbestätigung ist abgelaufen.")
|
||||
return
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
ws.close(1008, "Der Bildschirmzugriff ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
ws.close(1008, "Client nicht gefunden.")
|
||||
return
|
||||
host = vnc.ziel(device)
|
||||
if not host:
|
||||
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
|
||||
return
|
||||
|
||||
try:
|
||||
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT)
|
||||
except OSError as exc:
|
||||
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
|
||||
return
|
||||
|
||||
begonnen = time.monotonic()
|
||||
with extension.database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.screen_opened",
|
||||
target=str(device["name"]),
|
||||
detail=f"Bildschirmsitzung zu {host}:{port}",
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
|
||||
def vom_geraet() -> None:
|
||||
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
daten = gegenstelle.recv(BLOCK)
|
||||
if not daten:
|
||||
break
|
||||
ws.send(daten)
|
||||
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
|
||||
pass
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
leser = threading.Thread(target=vom_geraet, name=f"vnc-{device_id}", daemon=True)
|
||||
leser.start()
|
||||
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
nachricht = ws.receive()
|
||||
if nachricht is None:
|
||||
break
|
||||
if isinstance(nachricht, str):
|
||||
# Der Browser-Client schickt ausschliesslich Binaerrahmen. Text
|
||||
# waere entweder ein Irrtum oder ein Versuch -- beides endet
|
||||
# hier und nicht auf dem Geraet.
|
||||
continue
|
||||
gegenstelle.sendall(nachricht)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
gegenstelle.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
gegenstelle.close()
|
||||
dauer = int(time.monotonic() - begonnen)
|
||||
with extension.database.session() as frisch, extension.database.transaction(frisch):
|
||||
audit.log(
|
||||
frisch,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.screen_closed",
|
||||
target=str(device["name"]),
|
||||
detail=f"Dauer {dauer}s",
|
||||
ip=client_ip(),
|
||||
severity="info",
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""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
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||
|
||||
|
||||
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 _module_rows(conn: Any) -> list[dict[str, Any]]:
|
||||
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
|
||||
|
||||
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
|
||||
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
|
||||
will (dafuer ist der Schalter da).
|
||||
"""
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return []
|
||||
state = manager.reload(conn)
|
||||
return [
|
||||
{
|
||||
"key": name,
|
||||
"label": lic.MODULE_LABELS.get(name, name),
|
||||
"licensed": name in state.modules,
|
||||
"enabled": name not in state.modules_off,
|
||||
}
|
||||
for name in lic.ALL_MODULES
|
||||
]
|
||||
|
||||
|
||||
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.read")
|
||||
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",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"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(),
|
||||
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||
nav_baum=extension.nav.items,
|
||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||
license_hint=_license_hint("monitor_interval"),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
module_rows=_module_rows(conn),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/system")
|
||||
@permission_required("settings_system.write")
|
||||
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",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"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 Prüfintervall greift beim nächsten Durchlauf."
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash("Keine Änderungen.", "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/modules")
|
||||
@permission_required("settings_system.write")
|
||||
def modules_save():
|
||||
"""Module ein- und ausschalten.
|
||||
|
||||
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
|
||||
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
|
||||
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
|
||||
wieder einschaltet, findet alles vor wie zuvor.
|
||||
"""
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
if manager is None:
|
||||
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
|
||||
with transaction() as conn:
|
||||
eingeschaltet, abgeschaltet = manager.module_abschalten(
|
||||
conn, aus, actor=current_user.username
|
||||
)
|
||||
if eingeschaltet or abgeschaltet:
|
||||
teile = []
|
||||
if abgeschaltet:
|
||||
teile.append(
|
||||
"abgeschaltet: "
|
||||
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
|
||||
)
|
||||
if eingeschaltet:
|
||||
teile.append(
|
||||
"eingeschaltet: "
|
||||
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.modules_changed",
|
||||
detail="; ".join(teile),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
if eingeschaltet or abgeschaltet:
|
||||
flash("Module gespeichert.", "success")
|
||||
else:
|
||||
flash("Keine Änderungen.", "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 ungültig.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "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/timezone")
|
||||
@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 verfügbar.", "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/check-now")
|
||||
@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, vnc
|
||||
|
||||
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,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
|
||||
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="/monitoring/switches")
|
||||
|
||||
|
||||
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.read")
|
||||
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_switches_enabled")),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/new")
|
||||
@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-Schlüssel 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.write")
|
||||
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("Änderungen gespeichert." if changes else "Keine Änderungen.", "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>/delete")
|
||||
@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.read")
|
||||
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_switches_enabled")),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Host-Schluessel
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/host-key/probe")
|
||||
@permission_required("switches.write")
|
||||
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>/host-key/trust")
|
||||
@permission_required("switches.write")
|
||||
@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 gültiger Fingerprint übergeben.", "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-Schlüssel freigegeben.", "success")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/host-key/forget")
|
||||
@permission_required("switches.write")
|
||||
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-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Kommandos
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/command")
|
||||
@permission_required("switch_console.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("Für 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,437 @@
|
||||
"""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** -- ``device_console.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 -- getrennt fuer Switche (``terminal_switches_enabled``) und
|
||||
Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln
|
||||
haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den
|
||||
PoE-Neustart gebraucht.
|
||||
* 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", "Verfügbare 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 _konsole_erlaubt(conn: Any, kind: str) -> bool:
|
||||
"""Ist die Konsole fuer diese Art Ziel eingeschaltet?
|
||||
|
||||
Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine
|
||||
Werkstatt will vielleicht auf Switche, aber nicht auf Clients.
|
||||
"""
|
||||
schluessel = (
|
||||
"terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled"
|
||||
)
|
||||
return bool(core().settings.get(conn, schluessel))
|
||||
|
||||
|
||||
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 _konsole_erlaubt(conn, target.kind):
|
||||
abort(404)
|
||||
if not target.credential_id:
|
||||
abort(409, description="Für 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("/monitoring/switches/<int:switch_id>/console")
|
||||
@permission_required("switch_console.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("/monitoring/clients/<int:device_id>/console")
|
||||
@permission_required("device_console.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]
|
||||
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
|
||||
|
||||
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
|
||||
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
|
||||
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
|
||||
nicht die Meldung des Switches.
|
||||
"""
|
||||
try:
|
||||
ws.send(json.dumps({"type": kind, "data": text}))
|
||||
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
|
||||
|
||||
|
||||
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]
|
||||
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
|
||||
|
||||
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
|
||||
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
|
||||
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
|
||||
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
|
||||
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
_send(ws, "info", "Sitzung wird geöffnet …")
|
||||
|
||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||
required = (
|
||||
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
|
||||
)
|
||||
if not current_user.is_authenticated or not current_user.has(required):
|
||||
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
_send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
|
||||
return
|
||||
if not _konsole_erlaubt(conn, kind):
|
||||
_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 gehört 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"
|
||||
)
|
||||
|
||||
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, target.credential_id
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
return
|
||||
_send(ws, "info", f"Anmeldung als {username} …")
|
||||
|
||||
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 Schlüssel – bitte auf der "
|
||||
f"{page} ausdrücklich 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 Untätigkeit 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
|
||||
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
|
||||
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
|
||||
_send(ws, "info", "Verbindung wird aufgebaut …")
|
||||
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"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||
)
|
||||
return commands
|
||||
@@ -0,0 +1,658 @@
|
||||
"""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 Passwörter stimmen nicht überein.", 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"
|
||||
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
|
||||
# Administratorkennzeichen den Verwaltungsbereich und sonst
|
||||
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
|
||||
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
|
||||
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
|
||||
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
|
||||
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
|
||||
# wieder weg.
|
||||
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
gruppe = rbac.funktionsgruppe_anlegen(
|
||||
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||
)
|
||||
# Liegt sie im Papierkorb, kommt sie zurueck. Sonst haette dieser
|
||||
# Aufruf nichts bewirkt: das Konto waere in einer geloeschten
|
||||
# Gruppe -- und das faellt genau dann auf, wenn man es am
|
||||
# wenigsten gebrauchen kann.
|
||||
conn.execute(
|
||||
"UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL",
|
||||
(gruppe,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
(user_id, gruppe),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="cli",
|
||||
actor_id=user_id,
|
||||
action="user.admin_created",
|
||||
target=username,
|
||||
detail=(
|
||||
f"über die Kommandozeile {action}, Gruppe "
|
||||
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||
),
|
||||
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']} geprüft)"
|
||||
)
|
||||
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_restore_signatures(_args: argparse.Namespace) -> int:
|
||||
"""Traegt Unterschriften aus dem Journal wieder in die Datenbank ein.
|
||||
|
||||
Gebraucht nach einer Wiederherstellung: die Freigabe traegt die Dateien
|
||||
von gestern, die zurueckgespielte Datenbank ist von vorgestern -- die
|
||||
Unterschriften dazwischen stehen dann nur noch im Journal neben dem
|
||||
Protokollarchiv.
|
||||
"""
|
||||
app, extension = _app_and_core()
|
||||
from .services import fileshare
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
uebernommen = fileshare.signaturen_aus_journal(conn)
|
||||
gesamt = int(
|
||||
conn.execute("SELECT COUNT(*) FROM document_signatures").fetchone()[0]
|
||||
)
|
||||
print(f"{uebernommen} Unterschrift(en) übernommen, {gesamt} insgesamt.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
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,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
print(
|
||||
f"{report.checked} geprüft, {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_switch_test(args: argparse.Namespace) -> int:
|
||||
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
|
||||
|
||||
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
|
||||
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
|
||||
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
|
||||
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
|
||||
sie bekommen hat, notfalls als rohe Bytes.
|
||||
|
||||
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
|
||||
geschaltet.
|
||||
"""
|
||||
from .services import inventory, switchcli
|
||||
from .services.ssh import SshError
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
|
||||
app = create_app()
|
||||
erweiterung = core(app)
|
||||
|
||||
def sagen(stufe: str, text: str = "") -> None:
|
||||
print(f" {stufe:<28} {text}".rstrip())
|
||||
|
||||
with app.app_context():
|
||||
with erweiterung.database.session() as conn:
|
||||
zeile = Database.one(
|
||||
conn,
|
||||
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
|
||||
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
|
||||
(args.switch, args.switch),
|
||||
)
|
||||
if zeile is None:
|
||||
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
|
||||
print("Vorhanden:")
|
||||
for andere in Database.all(
|
||||
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
|
||||
):
|
||||
print(f" {andere['hostname']} ({andere['ip']})")
|
||||
return 2
|
||||
|
||||
host = str(zeile["ip"])
|
||||
ssh_port = int(zeile["ssh_port"] or 22)
|
||||
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
|
||||
print()
|
||||
|
||||
if not zeile["credential_id"]:
|
||||
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
|
||||
return 1
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, erweiterung.keystore, int(zeile["credential_id"])
|
||||
)
|
||||
except Exception as fehler: # noqa: BLE001
|
||||
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
|
||||
return 1
|
||||
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
|
||||
|
||||
from .services import ssh as ssh_modul
|
||||
|
||||
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
|
||||
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
|
||||
|
||||
try:
|
||||
sitzung = switchcli.SwitchCli(
|
||||
conn,
|
||||
host=host,
|
||||
port=ssh_port,
|
||||
username=benutzer,
|
||||
password=passwort,
|
||||
)
|
||||
with sitzung as cli:
|
||||
sagen("Anmeldung", "erfolgreich")
|
||||
erstes = cli.full_transcript
|
||||
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
|
||||
print()
|
||||
print("--- was der Switch nach der Anmeldung geschickt hat ---")
|
||||
print(erstes if erstes.strip() else "(nichts)")
|
||||
print("--- Ende ---")
|
||||
print()
|
||||
|
||||
if args.port:
|
||||
ausgabe = cli.send(
|
||||
f"show power-over-ethernet {args.port}", tolerate_error=True
|
||||
)
|
||||
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
|
||||
print("--- show power-over-ethernet ---")
|
||||
print(ausgabe)
|
||||
print("--- Ende ---")
|
||||
sagen("Erkannter Zustand", cli.poe_state(args.port))
|
||||
|
||||
if args.zyklus:
|
||||
if not args.port:
|
||||
print("Fuer --zyklus braucht es --port.")
|
||||
return 2
|
||||
ergebnis = cli.cycle_poe(args.port)
|
||||
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
|
||||
for schritt in ergebnis.steps:
|
||||
sagen("", schritt)
|
||||
if not ergebnis.ok:
|
||||
return 1
|
||||
except SshError as fehler:
|
||||
sagen("FEHLGESCHLAGEN", str(fehler))
|
||||
# Gerade dann sind die Bytes interessant: "kein Prompt" kann
|
||||
# heissen, dass nichts kam -- oder dass etwas kam, das nicht
|
||||
# erkannt wurde. Das sieht man nur im Rohtext, und
|
||||
# Steuerzeichen sieht man nur als repr().
|
||||
roh = "".join(sitzung.transcript)
|
||||
print()
|
||||
print("--- rohe Antwort des Switches (mit Steuerzeichen) ---")
|
||||
print(repr(roh) if roh else "(nichts)")
|
||||
print("--- Ende ---")
|
||||
return 1
|
||||
print()
|
||||
print("Fertig.")
|
||||
return 0
|
||||
|
||||
|
||||
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 überschreiben.")
|
||||
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 für 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 überschreiben.")
|
||||
return 2
|
||||
|
||||
passphrase = args.passphrase or getpass("Passphrase für 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']} übersprungen"
|
||||
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']} übersprungen"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||
admin.add_argument("--username")
|
||||
admin.add_argument("--password", help="Nur für 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 aufräumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
restore = sub.add_parser(
|
||||
"restore-signatures",
|
||||
help="Unterschriften aus dem Journal neben dem Protokollarchiv übernehmen",
|
||||
)
|
||||
restore.set_defaults(func=cmd_restore_signatures)
|
||||
|
||||
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
||||
check.set_defaults(func=cmd_check_now)
|
||||
|
||||
pruefung = sub.add_parser(
|
||||
"switch-test",
|
||||
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
|
||||
description=(
|
||||
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
|
||||
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
|
||||
"Port wirklich geschaltet."
|
||||
),
|
||||
)
|
||||
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
|
||||
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
|
||||
pruefung.add_argument(
|
||||
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
|
||||
)
|
||||
pruefung.set_defaults(func=cmd_switch_test)
|
||||
|
||||
legacy = sub.add_parser(
|
||||
"convert-legacy",
|
||||
help="Export des alten TESM in das neue Format übersetzen",
|
||||
description=(
|
||||
"Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
|
||||
"Format dieser Anwendung. Übersetzt 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 überschreiben")
|
||||
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 Vorgängerfassung 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 "
|
||||
"möglich, 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 überschreiben")
|
||||
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 übersetzte Datei ein – derselbe Weg wie unter "
|
||||
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||
"Kategorien übernommen, 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,247 @@
|
||||
"""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
|
||||
|
||||
from .permissions import DHCP_PAGE, SERVICING_PAGE
|
||||
|
||||
|
||||
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.write"):
|
||||
return True
|
||||
from .services import fileshare
|
||||
|
||||
return fileshare.has_mounts(user.id)
|
||||
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="monitoring",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
children=(
|
||||
NavItem(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
icon="cpu",
|
||||
endpoint="devices.overview",
|
||||
permissions=("devices.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
icon="switch",
|
||||
endpoint="switches.overview",
|
||||
permissions=("switches.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
icon="key",
|
||||
endpoint="credentials.overview",
|
||||
permissions=("credentials.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="hostkeys",
|
||||
label="Host-Schlüssel",
|
||||
icon="shield",
|
||||
endpoint="hostkeys.overview",
|
||||
permissions=("devices.read", "switches.read"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
endpoint="maintenance.overview",
|
||||
permissions=SERVICING_PAGE,
|
||||
condition=_module("maintenance"),
|
||||
),
|
||||
NavItem(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
icon="network",
|
||||
endpoint="dhcp.overview",
|
||||
permissions=DHCP_PAGE,
|
||||
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.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="fileshare_mappings",
|
||||
label="Zuordnungen",
|
||||
icon="link",
|
||||
endpoint="fileshare.mappings",
|
||||
permissions=("fileshare.write",),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="observability",
|
||||
label="Protokolle",
|
||||
icon="terminal",
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_history",
|
||||
label="Verlauf",
|
||||
icon="clock",
|
||||
endpoint="logs.history",
|
||||
permissions=("logs_history.read", "logs_history.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.read", "logs_audit.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_dhcp",
|
||||
label="DHCP",
|
||||
icon="network",
|
||||
endpoint="tesmlogs.dhcp",
|
||||
permissions=("logs_dhcp.read",),
|
||||
condition=_module("dhcp"),
|
||||
),
|
||||
NavItem(
|
||||
key="restarts",
|
||||
label="Neustarts",
|
||||
icon="power",
|
||||
endpoint="tesmlogs.restarts",
|
||||
permissions=("devices.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="administration",
|
||||
label="Verwaltung",
|
||||
icon="sliders",
|
||||
children=(
|
||||
NavItem(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
icon="users",
|
||||
endpoint="admin.users",
|
||||
permissions=("users.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
icon="shield-users",
|
||||
endpoint="admin.groups",
|
||||
permissions=("groups.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_system",
|
||||
label="System",
|
||||
icon="sliders",
|
||||
endpoint="settings.system",
|
||||
permissions=("settings_system.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_network",
|
||||
label="Netzwerk",
|
||||
icon="network",
|
||||
endpoint="hostsettings.network",
|
||||
permissions=("settings_network.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
icon="server",
|
||||
endpoint="hostsettings.web",
|
||||
permissions=("settings_web.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst",
|
||||
icon="users",
|
||||
endpoint="hostsettings.directory_settings",
|
||||
permissions=("settings_directory.read",),
|
||||
),
|
||||
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.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="backup",
|
||||
label="Sicherung",
|
||||
icon="transfer",
|
||||
endpoint="backup.overview",
|
||||
permissions=("backup_export.read", "backup_import.read"),
|
||||
),
|
||||
NavItem(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
icon="trash",
|
||||
endpoint="trash.overview",
|
||||
permissions=("trash.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
icon="info",
|
||||
endpoint="diagnostics.overview",
|
||||
permissions=("diagnostics.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,462 @@
|
||||
"""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.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
|
||||
action_hints={
|
||||
A.DELETE: "Gerät in den Papierkorb legen.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``.
|
||||
#:
|
||||
#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem
|
||||
#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm
|
||||
#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind
|
||||
#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei
|
||||
#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``.
|
||||
DEVICE_RESTART_POE = Resource(
|
||||
key="device_restart_poe",
|
||||
parent="devices",
|
||||
label="Clients: Neustart über PoE",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt",
|
||||
action_hints={
|
||||
A.READ: "Den Knopf sehen.",
|
||||
A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes "
|
||||
"Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.",
|
||||
},
|
||||
)
|
||||
|
||||
DEVICE_RESTART_SSH = Resource(
|
||||
key="device_restart_ssh",
|
||||
parent="devices",
|
||||
label="Clients: Neustart über SSH",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)",
|
||||
action_hints={
|
||||
A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein "
|
||||
"antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.",
|
||||
},
|
||||
)
|
||||
|
||||
DEVICE_RESTART_RPC = Resource(
|
||||
key="device_restart_rpc",
|
||||
parent="devices",
|
||||
label="Clients: Neustart über RPC",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)",
|
||||
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
||||
)
|
||||
|
||||
#: Bildschirmzugriff ist **nicht** dasselbe wie die Konsole, auch wenn beides
|
||||
#: "auf das Geraet" fuehrt: hier sieht jemand den Bildschirm, wie er ist --
|
||||
#: samt dem, was ein Mensch dort gerade offen hat. Das ist eine eigene
|
||||
#: Entscheidung, also eine eigene Zeile. Sie wird **niemandem** mitgegeben:
|
||||
#: keine Migration setzt sie, auch nicht bei denen, die schon eine Konsole
|
||||
#: oeffnen duerfen. Wer sie haben soll, bekommt sie in der Matrix -- einmal,
|
||||
#: sichtbar, mit Zeile im Aenderungsprotokoll.
|
||||
DEVICE_VNC = Resource(
|
||||
key="device_vnc",
|
||||
parent="devices",
|
||||
label="Clients: Bildschirm (VNC)",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Verbindungsdatei bzw. vnc://-Adresse für den Bildschirm des Clients",
|
||||
action_hints={
|
||||
A.READ: "Den Knopf sehen, wo ein VNC-Server antwortet.",
|
||||
A.EXECUTE: "Verbindung öffnen. Die Datei enthält nur Ziel und Namen – "
|
||||
"was beim Verbinden verlangt wird, entscheidet der VNC-Server auf dem Gerät.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
||||
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||
DEVICE_CONSOLE = Resource(
|
||||
key="device_console",
|
||||
parent="devices",
|
||||
label="Clients: Konsole",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Browser-Terminal auf einem Client (Modul Wartung)",
|
||||
action_hints={
|
||||
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||
},
|
||||
)
|
||||
|
||||
SWITCHES = Resource(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Aruba-Switche, ihre Erreichbarkeit und Ports",
|
||||
)
|
||||
|
||||
SWITCH_CONSOLE = Resource(
|
||||
key="switch_console",
|
||||
parent="switches",
|
||||
label="Switche: Konsole",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Kommandos auf dem Switch ausführen",
|
||||
action_hints={
|
||||
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw.
|
||||
#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der
|
||||
#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich
|
||||
#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen.
|
||||
HOSTKEYS = Resource(
|
||||
key="hostkeys",
|
||||
label="Host-Schlüssel",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal",
|
||||
action_hints={
|
||||
A.READ: "Offene und freigegebene Schlüssel sehen.",
|
||||
A.EXECUTE: "Einen geänderten Schlüssel freigeben.",
|
||||
},
|
||||
)
|
||||
|
||||
CREDENTIALS = Resource(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
|
||||
hint="Wiederverwendbare SSH-Logins",
|
||||
action_hints={
|
||||
A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
|
||||
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||
"Passwortbestätigung und wird protokolliert.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
|
||||
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
|
||||
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
|
||||
#: Clients steht, wo sie hingehoert.
|
||||
SERVICING_UPDATE = Resource(
|
||||
key="servicing_update",
|
||||
label="Paketaktualisierung",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)",
|
||||
action_hints={A.EXECUTE: "Auftrag starten."},
|
||||
)
|
||||
|
||||
SERVICING_REBOOT = Resource(
|
||||
key="servicing_reboot",
|
||||
label="Neustart als Auftrag",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung",
|
||||
action_hints={A.EXECUTE: "Auftrag starten."},
|
||||
)
|
||||
|
||||
SERVICING_TOOL = Resource(
|
||||
key="servicing_tool",
|
||||
label="Windows-Werkzeug installieren",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät",
|
||||
action_hints={A.EXECUTE: "Installation auslösen."},
|
||||
)
|
||||
|
||||
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
|
||||
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
||||
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
||||
SERVICING_PAGE: tuple[str, ...] = (
|
||||
"servicing_update.read",
|
||||
"servicing_reboot.read",
|
||||
"servicing_tool.read",
|
||||
)
|
||||
|
||||
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
||||
#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das
|
||||
#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte,
|
||||
#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben.
|
||||
DHCP_SUBNETS = Resource(
|
||||
key="dhcp_subnets",
|
||||
label="DHCP: Subnetze",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)",
|
||||
)
|
||||
|
||||
DHCP_OPTIONS = Resource(
|
||||
key="dhcp_options",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Optionen",
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Globale Optionen, Optionen je Gerät und eigene Optionen",
|
||||
action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."},
|
||||
)
|
||||
|
||||
DHCP_RESERVATIONS = Resource(
|
||||
key="dhcp_reservations",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Reservierungen",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Feste Adressen zu einer MAC",
|
||||
)
|
||||
|
||||
DHCP_CONFIG = Resource(
|
||||
key="dhcp_config",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Konfiguration schreiben",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen",
|
||||
action_hints={
|
||||
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
|
||||
"Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.",
|
||||
},
|
||||
)
|
||||
|
||||
DHCP_SERVICE = Resource(
|
||||
key="dhcp_service",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Dienst",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Kea installieren, starten, stoppen",
|
||||
action_hints={A.EXECUTE: "Dienstzustand ändern."},
|
||||
)
|
||||
|
||||
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
|
||||
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
|
||||
DHCP_PAGE: tuple[str, ...] = (
|
||||
"dhcp_subnets.read",
|
||||
"dhcp_options.read",
|
||||
"dhcp_reservations.read",
|
||||
"dhcp_config.read",
|
||||
"dhcp_service.read",
|
||||
)
|
||||
|
||||
FILESHARE = Resource(
|
||||
key="fileshare",
|
||||
label="Dateifreigaben",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
|
||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||
A.WRITE: "Umbenennen und verschieben.",
|
||||
# Genau die Bedeutung aus der Legende: Daten aus dem System
|
||||
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
|
||||
# bleibt im Haus, eine heruntergeladene Datei nicht.
|
||||
A.EXPORT: "Dateien herunterladen.",
|
||||
},
|
||||
# Exporte verlangen sonst eine frische Passwortbestaetigung. Hier nicht:
|
||||
# Herunterladen ist in einer Dateifreigabe der Alltag, nicht der
|
||||
# Sonderfall. Alle 15 Minuten ein Passwort waere Schikane -- und wuerde
|
||||
# nur dazu fuehren, dass jemand das Recht pauschal vergibt oder ganz
|
||||
# weglaesst. Die Grenze zieht hier das Recht selbst, nicht die Abfrage.
|
||||
ohne_wiederanmeldung=(A.EXPORT,),
|
||||
)
|
||||
|
||||
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
|
||||
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
|
||||
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
|
||||
FILESHARE_PDF = Resource(
|
||||
key="fileshare_pdf",
|
||||
parent="fileshare",
|
||||
label="PDF-Formulare",
|
||||
actions=(A.READ, A.CREATE, A.WRITE),
|
||||
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
|
||||
action_hints={
|
||||
A.READ: "In der Vorschau ausfüllen (ohne Speichern).",
|
||||
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
|
||||
A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
|
||||
#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer
|
||||
#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das
|
||||
#: nicht automatisch duerfen.
|
||||
FILESHARE_SIGNATURE = Resource(
|
||||
key="fileshare_signature",
|
||||
parent="fileshare",
|
||||
label="Unterschriften",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar",
|
||||
action_hints={
|
||||
A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.",
|
||||
A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration
|
||||
#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen".
|
||||
FILESHARE_MAPPINGS = Resource(
|
||||
key="fileshare_mappings",
|
||||
parent="fileshare",
|
||||
label="Freigabe-Zuordnungen",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe",
|
||||
)
|
||||
|
||||
LOGS_DHCP = Resource(
|
||||
key="logs_dhcp",
|
||||
label="DHCP-Protokoll",
|
||||
actions=(A.READ,),
|
||||
)
|
||||
|
||||
MONITORING_AREA = Area(
|
||||
key="monitoring",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||
"gesamte Bereich verborgen.",
|
||||
resources=(
|
||||
DEVICES,
|
||||
DEVICE_RESTART_POE,
|
||||
DEVICE_RESTART_SSH,
|
||||
DEVICE_RESTART_RPC,
|
||||
DEVICE_CONSOLE,
|
||||
DEVICE_VNC,
|
||||
SWITCHES,
|
||||
SWITCH_CONSOLE,
|
||||
HOSTKEYS,
|
||||
CREDENTIALS,
|
||||
),
|
||||
)
|
||||
|
||||
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
|
||||
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
|
||||
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
|
||||
MAINTENANCE_AREA = Area(
|
||||
key="servicing",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
|
||||
)
|
||||
|
||||
NETWORK_AREA = Area(
|
||||
key="network",
|
||||
label="Netzwerkdienste",
|
||||
icon="network",
|
||||
resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE),
|
||||
)
|
||||
|
||||
FILES_AREA = Area(
|
||||
key="files",
|
||||
label="Dateifreigaben",
|
||||
icon="folder",
|
||||
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
[
|
||||
MONITORING_AREA,
|
||||
MAINTENANCE_AREA,
|
||||
NETWORK_AREA,
|
||||
FILES_AREA,
|
||||
observability_area(LOGS_DHCP),
|
||||
administration_area(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
|
||||
#:
|
||||
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
|
||||
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
|
||||
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
|
||||
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
|
||||
#: was vorher ging. Entfernen kann man sie mit einem Klick.
|
||||
#:
|
||||
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
|
||||
#: Matrix ist sonst genau die, die hier vergessen wird.
|
||||
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
|
||||
key="functions",
|
||||
name="Funktionen (Vollzugriff)",
|
||||
description=(
|
||||
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
|
||||
"die nicht nur einstellen, sondern auch bedienen."
|
||||
),
|
||||
grants=tuple(
|
||||
(resource.key, "*")
|
||||
for area in PERMISSIONS.areas
|
||||
if area.key != PermissionTree.VERWALTUNGSBEREICH
|
||||
for resource in area.resources
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
|
||||
#:
|
||||
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
|
||||
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
|
||||
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
|
||||
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
|
||||
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
|
||||
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
|
||||
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
|
||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
||||
grants=(
|
||||
("devices", (A.READ,)),
|
||||
("switches", (A.READ,)),
|
||||
("logs_live", (A.READ,)),
|
||||
),
|
||||
is_default=True,
|
||||
),
|
||||
RolePreset(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||
grants=(
|
||||
("users", "*"),
|
||||
("groups", "*"),
|
||||
("settings_system", "*"),
|
||||
("settings_nav", "*"),
|
||||
("settings_license", "*"),
|
||||
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||
("settings_web", "*"),
|
||||
("settings_network", "*"),
|
||||
("backup_export", (A.READ, A.EXPORT)),
|
||||
("backup_import", "*"),
|
||||
("trash", "*"),
|
||||
("diagnostics", (A.READ,)),
|
||||
("logs_audit", (A.READ,)),
|
||||
),
|
||||
),
|
||||
FUNKTIONEN_VOLLZUGRIFF,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PERMISSIONS",
|
||||
"ROLE_PRESETS",
|
||||
"LOGS_DHCP",
|
||||
"SERVICING_PAGE",
|
||||
"DHCP_PAGE",
|
||||
]
|
||||
@@ -0,0 +1,588 @@
|
||||
"""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 typing import Any
|
||||
|
||||
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);
|
||||
"""
|
||||
|
||||
|
||||
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
|
||||
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
|
||||
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
|
||||
# vorher galt.
|
||||
V4 = """
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare.export' FROM group_permissions
|
||||
WHERE permission = 'fileshare.read';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
|
||||
WHERE permission = 'fileshare.read';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
|
||||
WHERE permission = 'fileshare.create';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.write' FROM group_permissions
|
||||
WHERE permission = 'fileshare.write';
|
||||
"""
|
||||
|
||||
|
||||
# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie
|
||||
# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer
|
||||
# beide weiter. Fehlt er, greift die Vorgabe der Einstellung.
|
||||
V5 = """
|
||||
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||
SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration'
|
||||
FROM settings WHERE key = 'terminal_enabled';
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||
SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration'
|
||||
FROM settings WHERE key = 'terminal_enabled';
|
||||
|
||||
DELETE FROM settings WHERE key = 'terminal_enabled';
|
||||
"""
|
||||
|
||||
|
||||
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
|
||||
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
|
||||
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
|
||||
# greift kein Recht darunter.
|
||||
#
|
||||
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
||||
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
||||
# darf Zugang erhalten, aber keinen neuen schaffen.
|
||||
#
|
||||
# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe:
|
||||
# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die
|
||||
# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die
|
||||
# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_".
|
||||
# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall
|
||||
# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht:
|
||||
# eine angewendete Migration laeuft nie wieder.
|
||||
V6 = """
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT DISTINCT group_id, 'servicing.read'
|
||||
FROM group_permissions
|
||||
WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
|
||||
AND group_id IN (
|
||||
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read'
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll
|
||||
# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem
|
||||
# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und
|
||||
# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden.
|
||||
# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert
|
||||
# gibt es keinen Widerspruch.
|
||||
#
|
||||
# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie
|
||||
# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt
|
||||
# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv.
|
||||
V7 = """
|
||||
CREATE TABLE IF NOT EXISTS document_signatures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sha256 TEXT NOT NULL,
|
||||
share_label TEXT NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
signed_at TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
names TEXT NOT NULL DEFAULT '',
|
||||
audit_id INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_signatures_sha
|
||||
ON document_signatures(sha256);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_signatures_ort
|
||||
ON document_signatures(share_label, path);
|
||||
"""
|
||||
|
||||
|
||||
def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein.
|
||||
|
||||
Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen
|
||||
ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja
|
||||
noch da.
|
||||
"""
|
||||
from .services import fileshare as service
|
||||
|
||||
zeilen = conn.execute(
|
||||
"SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' "
|
||||
"ORDER BY id"
|
||||
).fetchall()
|
||||
for zeile in zeilen:
|
||||
detail = str(zeile["detail"] or "")
|
||||
gelesen = service.signaturdetail_lesen(detail)
|
||||
if not gelesen["sha256"]:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
gelesen["sha256"],
|
||||
gelesen["label"],
|
||||
gelesen["pfad"],
|
||||
str(zeile["ts"]),
|
||||
str(zeile["actor"] or ""),
|
||||
", ".join(gelesen["namen"]),
|
||||
int(zeile["id"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe.
|
||||
|
||||
Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen
|
||||
ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns
|
||||
(die Rechte heissen dann schon read/write und sind aufgeteilt).
|
||||
"""
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
||||
|
||||
|
||||
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
|
||||
#:
|
||||
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
|
||||
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
|
||||
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
|
||||
#: unveraendert die ausgelieferte ist.
|
||||
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||
(
|
||||
"operations",
|
||||
"Betrieb",
|
||||
(
|
||||
("devices", "*"),
|
||||
("device_restart_poe", "*"),
|
||||
("device_restart_ssh", "*"),
|
||||
("device_restart_rpc", "*"),
|
||||
("device_console", "*"),
|
||||
("switches", ("read", "create", "write")),
|
||||
("switch_console", "*"),
|
||||
("hostkeys", "*"),
|
||||
("credentials", ("read",)),
|
||||
("servicing_update", "*"),
|
||||
("servicing_reboot", "*"),
|
||||
("servicing_tool", "*"),
|
||||
("logs_live", ("read",)),
|
||||
("logs_history", ("read",)),
|
||||
("trash", ("read", "write")),
|
||||
),
|
||||
),
|
||||
(
|
||||
"support",
|
||||
"Support",
|
||||
(
|
||||
("devices", ("read",)),
|
||||
("device_restart_poe", "*"),
|
||||
("switches", ("read",)),
|
||||
("logs_live", ("read",)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"network",
|
||||
"Netzwerk",
|
||||
(
|
||||
("dhcp_subnets", "*"),
|
||||
("dhcp_options", "*"),
|
||||
("dhcp_reservations", "*"),
|
||||
("dhcp_config", "*"),
|
||||
("dhcp_service", "*"),
|
||||
("settings_network", "*"),
|
||||
("logs_dhcp", ("read",)),
|
||||
("devices", ("read",)),
|
||||
("switches", ("read",)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"auditor",
|
||||
"Revision",
|
||||
(
|
||||
("devices", ("read",)),
|
||||
("switches", ("read",)),
|
||||
("credentials", ("read",)),
|
||||
("dhcp_subnets", ("read",)),
|
||||
("dhcp_options", ("read",)),
|
||||
("dhcp_reservations", ("read",)),
|
||||
("users", ("read",)),
|
||||
("groups", ("read",)),
|
||||
("logs_live", ("read",)),
|
||||
("logs_history", ("read", "export")),
|
||||
("logs_audit", ("read", "export")),
|
||||
("diagnostics", ("read",)),
|
||||
("trash", ("read",)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def eingefrorene_vorlagen(
|
||||
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
|
||||
) -> list[Any]:
|
||||
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
|
||||
|
||||
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
|
||||
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
|
||||
"""
|
||||
from tesm_core.rbac.model import Action, RolePreset
|
||||
|
||||
vorlagen = []
|
||||
for key, name, grants in beschreibung:
|
||||
gewandelt = tuple(
|
||||
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
|
||||
for ressource, aktionen in grants
|
||||
)
|
||||
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
|
||||
return vorlagen
|
||||
|
||||
|
||||
# Version 10 ----------------------------------------------------------------
|
||||
#
|
||||
# Wo antwortet ein VNC-Server? Die Frage beantwortet der Ueberwachungs-
|
||||
# durchlauf mit, der ohnehin jedes Geraet anfasst -- einmal je Intervall statt
|
||||
# einmal je Seitenaufruf. Die Kacheln der Statusuebersicht koennen den Knopf
|
||||
# damit zeigen, ohne dass das Rendern der Seite ein Dutzend Verbindungen
|
||||
# aufbaut.
|
||||
V10 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_open INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE device_status ADD COLUMN vnc_checked_at TEXT;
|
||||
"""
|
||||
|
||||
|
||||
# Version 11 ----------------------------------------------------------------
|
||||
#
|
||||
# Nicht nur *ob* dort ein VNC-Server antwortet, sondern *wohin* gefragt wurde.
|
||||
# Die Kacheln der Statusuebersicht verlinken die Verbindung unmittelbar
|
||||
# (vnc://hap02); das Ziel dafuer aus dem Namen neu herzuleiten hiesse, bei
|
||||
# jedem Seitenaufbau je Kachel eine Namensaufloesung zu versuchen. Der
|
||||
# Durchlauf hat sie ohnehin schon gemacht -- also schreibt er sie hin.
|
||||
V11 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 12 ----------------------------------------------------------------
|
||||
#
|
||||
# Welche Sicherheitsverfahren der VNC-Server angeboten hat. Daran entscheidet
|
||||
# sich, ob eine Sitzung im Browser ueberhaupt zustande kommen kann -- und wer
|
||||
# das erst erfaehrt, wenn er vor einer schwarzen Flaeche sitzt, sucht den
|
||||
# Fehler bei uns.
|
||||
V12 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 13 ----------------------------------------------------------------
|
||||
#
|
||||
# Die Absage eines VNC-Servers und der Zeitpunkt der Verfahrensmessung. Beide
|
||||
# gehoeren getrennt vom Ueberwachungsdurchlauf gefuehrt: der fragt nur noch,
|
||||
# ob der Port annimmt (eine abgebrochene Protokollverhandlung sperrt bei
|
||||
# RealVNC die eigene Adresse aus), die Verfahren misst die Seite -- und auch
|
||||
# die nur, wenn die letzte Messung alt ist.
|
||||
V13 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_reason TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
|
||||
"""
|
||||
|
||||
|
||||
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||
|
||||
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
|
||||
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
|
||||
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
|
||||
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
|
||||
"Administration" und "Funktionen (Vollzugriff)".
|
||||
|
||||
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
|
||||
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
|
||||
Weggeraeumte liegt nur im Papierkorb.
|
||||
"""
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from .permissions import PERMISSIONS
|
||||
|
||||
rbac.unberuehrte_vorlagen_entfernen(
|
||||
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
Migration(
|
||||
version=4,
|
||||
name="fileshare-pdf-rechte",
|
||||
sql=V4,
|
||||
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
|
||||
),
|
||||
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||
Migration(
|
||||
version=6,
|
||||
name="wartung-eigener-bereich",
|
||||
sql=V6,
|
||||
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
|
||||
),
|
||||
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||
Migration(version=12, name="vnc-verfahren", sql=V12),
|
||||
Migration(version=13, name="vnc-absage", sql=V13),
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Fachdienste von TESM -- Netzwerk, SSH, DHCP, Dateifreigaben, Wartung, Sicherung."""
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,773 @@
|
||||
"""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 über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||
"windows": (
|
||||
"Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
|
||||
"Gerät 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 löst 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 zwölf 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 gültige 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 enthält 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 enthält 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 löschen Sie sie endgültig."
|
||||
)
|
||||
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 geändert")
|
||||
if category != str(row["category"]):
|
||||
changes.append(f"Kategorie: {row['category']} -> {category}")
|
||||
if (description or "").strip() != str(row["description"] or ""):
|
||||
changes.append("Beschreibung geändert")
|
||||
|
||||
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 geändert")
|
||||
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 entschlüsselt werden. Wurde der "
|
||||
"Datenschlüssel 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')} Gerät(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} Gerät(e) zugeordnet. Bitte zuerst umhängen."
|
||||
)
|
||||
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, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
||||
COALESCE(st.vnc_target, '') AS vnc_target,
|
||||
COALESCE(st.vnc_types, '') AS vnc_types,
|
||||
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at
|
||||
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 Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError(
|
||||
"Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
|
||||
"PoE-Neustart möglich."
|
||||
)
|
||||
|
||||
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 Gerät 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 Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError("Zu einem zugeordneten Switch gehört 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))),
|
||||
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
|
||||
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
|
||||
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
|
||||
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||
("Kategorie", str(row["category"] or ""), (category or "").strip()),
|
||||
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||
):
|
||||
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-Größe (Blöcke)"),
|
||||
(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-Reassemblierungsgröße"),
|
||||
(23, "default-ip-ttl", "uint8", "Standard-TTL für 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 für 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 ausgewählt."
|
||||
try:
|
||||
start = ipaddress.ip_address(range_start)
|
||||
end = ipaddress.ip_address(range_end)
|
||||
except ValueError:
|
||||
return "Start- und Endadresse müssen gültige 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 übersprungen."
|
||||
)
|
||||
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 gültiges Subnetz konfiguriert | Kea würde 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 Gerät benutzt diese Adresse gerade."),
|
||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
|
||||
"andere_adresse": (
|
||||
"warning",
|
||||
"Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
|
||||
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
||||
),
|
||||
"unbenutzt": ("", "Für dieses Gerät 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,947 @@
|
||||
"""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, die DHCP-Konfiguration, die
|
||||
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
|
||||
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
|
||||
was von Hand nachzuziehen ist.
|
||||
|
||||
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
|
||||
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
|
||||
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
|
||||
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
|
||||
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
|
||||
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
|
||||
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
|
||||
"""
|
||||
|
||||
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", "ldap")
|
||||
|
||||
#: 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 lässt sich nicht umrechnen – das ist sein "
|
||||
"Zweck. Konten neu anlegen."
|
||||
),
|
||||
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||
# pauschale Zeile hier es koennte.
|
||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||
"known_hosts": (
|
||||
"SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
|
||||
"über die Seite 'Host-Schlüssel' 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 enthält kein Objekt.")
|
||||
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
||||
raise LegacyTransferError(
|
||||
f"Diese Datei gehört 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(
|
||||
"Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(klartext.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise LegacyTransferError("Die Nutzlast enthält 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 übersprungen.")
|
||||
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 übernommen",
|
||||
}
|
||||
# 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 müssen 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 übersprungen.")
|
||||
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 übersprungen.")
|
||||
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 Vorgänger kennt kein solches Feld. Bitte prüfen."
|
||||
)
|
||||
report.took("devices", len(ergebnis))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
|
||||
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
|
||||
|
||||
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
|
||||
|
||||
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
|
||||
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
|
||||
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
|
||||
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
|
||||
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
|
||||
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
|
||||
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
|
||||
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
|
||||
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
|
||||
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
|
||||
Waren sie gesetzt, steht es im Bericht.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
|
||||
ergebnis: dict[str, Any] = {}
|
||||
einstellungen: dict[str, Any] = {}
|
||||
|
||||
def ja(schluessel: str) -> bool:
|
||||
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
server = str(alt.get("ldap_server") or "").strip()
|
||||
if server:
|
||||
einstellungen["ldap_host"] = server
|
||||
if str(alt.get("ldap_port") or "").strip().isdigit():
|
||||
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
|
||||
if "ldap_enabled" in alt:
|
||||
einstellungen["ldap_enabled"] = ja("ldap_enabled")
|
||||
if str(alt.get("ldap_base_dn") or "").strip():
|
||||
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
|
||||
if str(alt.get("ldap_user_filter_attr") or "").strip():
|
||||
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
|
||||
|
||||
if "ldap_use_ssl" in alt:
|
||||
if ja("ldap_use_ssl"):
|
||||
einstellungen["ldap_security"] = "ldaps"
|
||||
else:
|
||||
einstellungen["ldap_security"] = "starttls"
|
||||
report.note(
|
||||
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
|
||||
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
|
||||
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
|
||||
"Verbindungstest ausführen."
|
||||
)
|
||||
if ja("ldap_tls_skip_verify"):
|
||||
einstellungen["ldap_verify_cert"] = False
|
||||
report.note(
|
||||
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
|
||||
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
|
||||
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
|
||||
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
|
||||
)
|
||||
if einstellungen:
|
||||
ergebnis["settings"] = einstellungen
|
||||
|
||||
for schluessel, text in (
|
||||
(
|
||||
"ldap_default_group",
|
||||
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
|
||||
"(Verwaltung -> Gruppen). Bitte dort setzen.",
|
||||
),
|
||||
(
|
||||
"ldap_required_login_group",
|
||||
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
|
||||
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
|
||||
),
|
||||
):
|
||||
wert = str(alt.get(schluessel) or "").strip()
|
||||
if wert:
|
||||
report.note(text.format(wert=wert))
|
||||
|
||||
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
|
||||
passwort = str(data.get("bind_password") or "")
|
||||
if benutzer:
|
||||
ergebnis["bind_username"] = benutzer
|
||||
ergebnis["bind_password"] = passwort
|
||||
if not passwort:
|
||||
report.note(
|
||||
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
|
||||
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
|
||||
)
|
||||
|
||||
zuordnungen: list[dict[str, Any]] = []
|
||||
offen: list[str] = []
|
||||
for eintrag in _liste(data.get("group_mappings")):
|
||||
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not dn:
|
||||
continue
|
||||
name = str(eintrag.get("ad_group_name") or "").strip() or dn
|
||||
ziel = str(
|
||||
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
|
||||
).strip()
|
||||
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
|
||||
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
|
||||
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
|
||||
if ziel.lower() == "admin":
|
||||
zuordnungen.append(
|
||||
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
|
||||
)
|
||||
continue
|
||||
if ziel:
|
||||
offen.append(f"{name} -> {ziel}")
|
||||
if zuordnungen:
|
||||
ergebnis["group_mappings"] = zuordnungen
|
||||
if offen:
|
||||
report.note(
|
||||
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
|
||||
"werden (die Rechteschlüssel sind neu geschnitten): "
|
||||
+ ", ".join(sorted(offen))
|
||||
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
|
||||
"nachtragen."
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
|
||||
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
|
||||
|
||||
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
|
||||
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
|
||||
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
eintraege = _liste(data.get("fileshare_mappings"))
|
||||
else:
|
||||
eintraege = _liste(data)
|
||||
ergebnis: list[dict[str, Any]] = []
|
||||
ohne_pfad = 0
|
||||
for eintrag in eintraege:
|
||||
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
|
||||
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
|
||||
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
|
||||
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not unc or not gruppe:
|
||||
ohne_pfad += 1
|
||||
continue
|
||||
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
|
||||
ergebnis.append(
|
||||
{
|
||||
"ad_group_dn": gruppe,
|
||||
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
|
||||
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
|
||||
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
|
||||
# speichert "//server/share", getippt wird "\server\share".
|
||||
"label": beschriftung
|
||||
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
|
||||
"unc": unc,
|
||||
}
|
||||
)
|
||||
if ohne_pfad:
|
||||
report.note(
|
||||
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
|
||||
"übersprungen."
|
||||
)
|
||||
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 übersprungen.")
|
||||
continue
|
||||
reservations.append(
|
||||
{
|
||||
"mac": mac,
|
||||
"ip": str(entry.get("ip") or ""),
|
||||
"hostname": str(entry.get("name") or ""),
|
||||
"comment": "aus altem TESM übernommen",
|
||||
}
|
||||
)
|
||||
|
||||
# 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 übersprungen.")
|
||||
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 übersprungen – 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
|
||||
|
||||
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
|
||||
if freigaben:
|
||||
payload["fileshare"] = freigaben
|
||||
|
||||
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
|
||||
if verzeichnis:
|
||||
payload["directory"] = verzeichnis
|
||||
|
||||
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' mitwählen."
|
||||
)
|
||||
|
||||
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 übersprungen: {', '.join(unbekannt)}.")
|
||||
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
|
||||
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
|
||||
)
|
||||
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 Passwörter 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 Passwörter 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 ließ sich mit dem gefundenen Schlüssel "
|
||||
"nicht entschlüsseln – 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()]
|
||||
|
||||
entschluesseln_bind = _entschluessler(root, report)
|
||||
if "credentials" in vorhanden:
|
||||
entschluesseln = entschluesseln_bind
|
||||
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.")
|
||||
|
||||
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
|
||||
# die Uebersetzung beide Wege gleich liest.
|
||||
ldap: dict[str, Any] = {}
|
||||
if "ldap_fileshare_mappings" in vorhanden:
|
||||
freigaben = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
|
||||
"FROM ldap_fileshare_mappings"
|
||||
).fetchall()
|
||||
]
|
||||
if freigaben:
|
||||
ldap["fileshare_mappings"] = freigaben
|
||||
else:
|
||||
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
|
||||
|
||||
if "settings" in vorhanden:
|
||||
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
|
||||
# Bericht wie eine Uebernahme aus und ist keine.
|
||||
ldap_einstellungen = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in conn.execute(
|
||||
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
|
||||
).fetchall()
|
||||
}
|
||||
if ldap_einstellungen:
|
||||
ldap["settings"] = ldap_einstellungen
|
||||
|
||||
if "ldap_group_mappings" in vorhanden:
|
||||
# Der Sonderwert "admin" steht in derselben Spalte wie eine
|
||||
# Gruppen-Id; der Name dahinter ist das, was ueber die
|
||||
# Systemgrenze traegt.
|
||||
gruppen_da = "groups" in vorhanden
|
||||
zuordnungen = [
|
||||
{
|
||||
"ad_group_dn": str(row["ad_group_dn"]),
|
||||
"ad_group_name": str(row["ad_group_name"]),
|
||||
"app_group_name": str(row["ziel"] or ""),
|
||||
}
|
||||
for row in conn.execute(
|
||||
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
|
||||
" WHEN m.app_group_id = 'admin' THEN 'admin' "
|
||||
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
|
||||
+ "END AS ziel FROM ldap_group_mappings m"
|
||||
+ (
|
||||
" LEFT JOIN groups g ON g.id = m.app_group_id"
|
||||
if gruppen_da
|
||||
else ""
|
||||
)
|
||||
).fetchall()
|
||||
]
|
||||
if zuordnungen:
|
||||
ldap["group_mappings"] = zuordnungen
|
||||
|
||||
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
|
||||
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
|
||||
if "service_accounts" in vorhanden:
|
||||
konto = _eine(
|
||||
conn,
|
||||
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
|
||||
)
|
||||
if konto is not None:
|
||||
ldap["bind_username"] = str(konto["username"] or "")
|
||||
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
|
||||
if not ldap.get("bind_username"):
|
||||
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
|
||||
if aus_einstellungen:
|
||||
ldap["bind_username"] = str(aus_einstellungen)
|
||||
ldap["bind_password"] = entschluesseln_bind(
|
||||
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
|
||||
)
|
||||
if ldap:
|
||||
payload["ldap"] = ldap
|
||||
|
||||
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 übernehmbaren Daten (Clients, Switche, "
|
||||
"Zugangsdaten, DHCP, Freigaben)."
|
||||
)
|
||||
for abschnitt, inhalt in payload.items():
|
||||
if abschnitt == "ldap" and isinstance(inhalt, dict):
|
||||
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
|
||||
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
|
||||
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
|
||||
if inhalt.get("bind_username"):
|
||||
report.took("Bind-Konto", 1)
|
||||
continue
|
||||
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. Für die "
|
||||
"SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät 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 Gerät kein 'sudo' ausführen. Auf dem "
|
||||
"Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||
"NOPASSWD: entfällt die Passwortabfrage ganz.",
|
||||
),
|
||||
(
|
||||
"a password is required",
|
||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
|
||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
|
||||
"es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
|
||||
),
|
||||
(
|
||||
"no tty present",
|
||||
"Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||
"Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
|
||||
),
|
||||
(
|
||||
"command not found",
|
||||
"Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
|
||||
"Debian- oder Ubuntu-System mit apt-get.",
|
||||
),
|
||||
(
|
||||
"Access is denied",
|
||||
"Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
|
||||
"Mitglied der lokalen Administratoren sein.",
|
||||
),
|
||||
(
|
||||
"is not recognized as an internal or external command",
|
||||
"Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
|
||||
"richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
|
||||
),
|
||||
(
|
||||
"Could not get lock",
|
||||
"Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
|
||||
"unattended-upgrades). Später 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 während des Laufs neu gestartet. Was auf dem Zielgerät "
|
||||
"passiert ist, lässt 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 übergeben 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 überschritten | 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 möglich.")
|
||||
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 übergeben.")
|
||||
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 Rückgabewert {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-Gerät | Neustart über 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 ausgelöst ({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 Rückkehr des Geräts")
|
||||
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"Gerät 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 über SSH, Gerät ist zurückgekehrt.",
|
||||
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"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||
finished=True,
|
||||
)
|
||||
_log_restart(
|
||||
database,
|
||||
device,
|
||||
result="error",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=(
|
||||
f"Neustart ausgelöst, 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 (Gerät %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,721 @@
|
||||
"""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 überschritten")
|
||||
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 über SSH",
|
||||
METHOD_RPC: "Neustart über 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 möglich."
|
||||
)
|
||||
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"Für 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 durchgeführt.",
|
||||
"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 merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None:
|
||||
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
|
||||
|
||||
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
|
||||
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
|
||||
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
|
||||
geschrieben, von dem, der ohnehin gefragt hat.
|
||||
|
||||
Die Anmeldeverfahren bleiben unberuehrt: sie kommen aus einer anderen
|
||||
Quelle (:func:`merke_verfahren`) und haetten hier nur den Wert "nicht
|
||||
gefragt" -- der wuerde eine gute Messung ueberschreiben.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?",
|
||||
(1 if offen else 0, _now(), ziel, device_id),
|
||||
)
|
||||
|
||||
|
||||
def merke_verfahren(
|
||||
conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = ""
|
||||
) -> None:
|
||||
"""Haelt fest, wie sich der VNC-Server authentifizieren will.
|
||||
|
||||
Mit Zeitstempel, denn diese Messung kostet eine angefangene
|
||||
Protokollverhandlung -- und die soll sich nicht bei jedem Seitenaufbau
|
||||
wiederholen.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_types=?, vnc_reason=?, vnc_types_at=? WHERE device_id=?",
|
||||
(",".join(str(v) for v in verfahren), absage, _now(), device_id),
|
||||
)
|
||||
|
||||
|
||||
def run_sweep(
|
||||
*,
|
||||
database: Any,
|
||||
live: LiveLog,
|
||||
keystore: Any,
|
||||
license_allows_auto_restart: bool,
|
||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||
vnc_port: int = 0,
|
||||
) -> 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.
|
||||
|
||||
``vnc_port`` (0 = aus) haengt eine zweite, sehr kurze Frage an jedes
|
||||
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
|
||||
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
|
||||
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
|
||||
|
||||
**Nur die Frage nach dem Port**, kein Wort Protokoll: ein RealVNC Server
|
||||
zaehlt abgebrochene Verhandlungen und sperrt die Adresse eine Weile aus.
|
||||
Welche Anmeldeverfahren er anbietet, misst die Seite, die es braucht.
|
||||
"""
|
||||
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
|
||||
|
||||
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
|
||||
|
||||
def pruefen(
|
||||
device: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], PingResult, vnc_dienst.Zugang | None]:
|
||||
ergebnis = ping(str(device["ip"]))
|
||||
if not vnc_port or not ergebnis.ok:
|
||||
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
|
||||
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||
# seinen Knopf nicht verlieren.
|
||||
return device, ergebnis, None
|
||||
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||
results = list(pool.map(pruefen, devices))
|
||||
|
||||
restart_candidates: list[dict[str, Any]] = []
|
||||
with database.session() as conn:
|
||||
with database.transaction(conn):
|
||||
for device, result, vnc_offen in results:
|
||||
report.checked += 1
|
||||
previous, current = record_status(conn, int(device["id"]), result)
|
||||
if vnc_offen is not None:
|
||||
merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel)
|
||||
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 übersprungen – keine gültige 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,175 @@
|
||||
"""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, Callable
|
||||
|
||||
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 recht(self) -> str:
|
||||
"""Das Recht, das genau diesen Weg freigibt.
|
||||
|
||||
Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen
|
||||
gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht,
|
||||
das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften.
|
||||
"""
|
||||
return f"device_restart_{self.key}.execute"
|
||||
|
||||
@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 Gerät, 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 Gerät hängt.",
|
||||
),
|
||||
METHOD_SSH: Method(
|
||||
key=METHOD_SSH,
|
||||
label="Neustart über SSH",
|
||||
hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
|
||||
"und einen freigegebenen Host-Schlüssel voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
METHOD_RPC: Method(
|
||||
key=METHOD_RPC,
|
||||
label="Neustart über RPC",
|
||||
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def available(
|
||||
device: Any,
|
||||
*,
|
||||
maintenance_active: bool = True,
|
||||
darf: Callable[[str], bool] | None = None,
|
||||
) -> 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.
|
||||
|
||||
``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der
|
||||
Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der
|
||||
ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle
|
||||
dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch.
|
||||
"""
|
||||
|
||||
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":
|
||||
einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||
if darf is not None:
|
||||
einschalten = [entry for entry in einschalten if darf(entry.recht)]
|
||||
return einschalten
|
||||
|
||||
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])
|
||||
|
||||
if darf is not None:
|
||||
methods = [entry for entry in methods if darf(entry.recht)]
|
||||
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
|
||||
|
||||
|
||||
def preferred(
|
||||
device: Any,
|
||||
*,
|
||||
maintenance_active: bool = True,
|
||||
darf: Callable[[str], bool] | None = None,
|
||||
) -> Method | None:
|
||||
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
|
||||
options = available(device, maintenance_active=maintenance_active, darf=darf)
|
||||
return options[0] if options else None
|
||||
|
||||
|
||||
def resolve(
|
||||
device: Any,
|
||||
requested: str,
|
||||
*,
|
||||
maintenance_active: bool = True,
|
||||
darf: Callable[[str], bool] | None = None,
|
||||
) -> 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, darf=darf)
|
||||
}
|
||||
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 überschritten."
|
||||
)
|
||||
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-Schlüssel von {host}:{port} ist unbekannt "
|
||||
f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich 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-Schlüssel von {host}:{port} hat sich geändert "
|
||||
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
||||
"Das kann ein Gerätetausch 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-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
|
||||
"Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
|
||||
)
|
||||
if known.fingerprint != record.fingerprint:
|
||||
return (
|
||||
"danger",
|
||||
f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
|
||||
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
||||
"Gerätetausch sein – oder ein Angriff.",
|
||||
)
|
||||
return ("success", "Der Host-Schlüssel stimmt mit dem gespeicherten überein.")
|
||||
|
||||
|
||||
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 Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
|
||||
"Schlüssel 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,369 @@
|
||||
"""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__)
|
||||
|
||||
#: Steuerzeichen eines Terminals -- ANSI-Sequenzen, Glocke, Einzelbefehle.
|
||||
#:
|
||||
#: Ein ArubaOS-Switch an einem Pseudoterminal zeichnet seine Zeile neu und
|
||||
#: haengt dabei Sequenzen wie ``ESC [ K`` ("bis Zeilenende loeschen") an. Im
|
||||
#: Terminal sieht man davon **nichts** -- und genau das war der Fehler, der
|
||||
#: Konsole und PoE-Neustart lahmgelegt hat: der Prompt stand da, aber hinter
|
||||
#: ihm standen drei unsichtbare Bytes, und das verankerte Muster traf nicht
|
||||
#: mehr. Im Protokoll las man "kein Prompt", waehrend der Prompt im Text
|
||||
#: stand. Deshalb kommen die Steuerzeichen weg, bevor irgendetwas gesucht
|
||||
#: wird -- das macht zugleich die Ausgabe in der Konsole lesbar.
|
||||
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x07\x00]")
|
||||
|
||||
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
||||
#: Konfigurationsmodus (``switch(config)#``).
|
||||
#:
|
||||
#: Zwei Lockerungen gegenueber der ersten Fassung, beide aus dem Betrieb:
|
||||
#: der Prompt darf am Anfang des Puffers stehen (nicht nur nach einem
|
||||
#: Zeilenumbruch -- so kommt er nach einem geleerten Puffer), und hinter ihm
|
||||
#: darf beliebiger Leerraum stehen (nicht genau eines).
|
||||
PROMPT_RE = re.compile(rb"(?:^|[\r\n])[^\r\n]{0,120}?[>#]\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
|
||||
)
|
||||
|
||||
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
|
||||
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
|
||||
#: das bewaehrte Skript der Vorgaengerfassung.
|
||||
ABMELDUNG = ("exit", "exit", "exit")
|
||||
ABMELDE_PAUSE = 0.15
|
||||
|
||||
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:
|
||||
"""Meldet sich ab und legt dann erst auf.
|
||||
|
||||
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
|
||||
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
|
||||
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
|
||||
"Do you want to log out (y/n)?" ein ``y``.
|
||||
|
||||
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
|
||||
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
|
||||
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
|
||||
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
|
||||
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
|
||||
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
|
||||
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
|
||||
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
|
||||
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
|
||||
|
||||
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
|
||||
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
|
||||
"""
|
||||
kanal = self._channel
|
||||
if kanal is not None:
|
||||
try:
|
||||
for zeile in ABMELDUNG:
|
||||
kanal.send(zeile + "\r\n")
|
||||
# Kurz warten, damit der Switch die Zeile verarbeitet --
|
||||
# auf die Antwort zu warten wuerde beim Abmelden nur
|
||||
# Zeitlimits sammeln.
|
||||
time.sleep(ABMELDE_PAUSE)
|
||||
# "Do you want to log out (y/n)?"
|
||||
kanal.send("y\r\n")
|
||||
time.sleep(ABMELDE_PAUSE)
|
||||
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
|
||||
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
|
||||
|
||||
for closer in (kanal, 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:
|
||||
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
|
||||
|
||||
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
|
||||
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
|
||||
eine leere Antwort und machte weiter: ``configure terminal`` ins
|
||||
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
|
||||
Zustandsabfrage dann "unknown" ergab und "unknown" als
|
||||
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
|
||||
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
|
||||
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
|
||||
"durchgefuehrt".
|
||||
|
||||
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
|
||||
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
|
||||
hereinkam, denn daran erkennt man, wo die Sitzung steht.
|
||||
"""
|
||||
assert self._channel is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
# Zwei Puffer: das Rohe kommt herein, gesucht wird im Sichtbaren.
|
||||
# Ohne diese Trennung entscheiden unsichtbare Steuerzeichen darueber,
|
||||
# ob ein Prompt erkannt wird.
|
||||
sichtbar = b""
|
||||
prompt_gesehen = False
|
||||
while time.monotonic() < deadline:
|
||||
if self._channel.recv_ready():
|
||||
chunk = self._channel.recv(READ_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
|
||||
if PAGER_RE.search(sichtbar[-300:]):
|
||||
self._channel.send(" ")
|
||||
sichtbar = PAGER_RE.sub(b"", sichtbar)
|
||||
continue
|
||||
if PROMPT_RE.search(sichtbar[-300:]):
|
||||
prompt_gesehen = True
|
||||
break
|
||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||
if initial and sichtbar.rstrip().endswith(b"continue"):
|
||||
self._channel.send("\r\n")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
text = sichtbar.decode("utf-8", errors="replace")
|
||||
self.transcript.append(text)
|
||||
if not prompt_gesehen:
|
||||
letzte = " ".join(text.split())[-200:]
|
||||
if initial:
|
||||
raise SshError(
|
||||
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
|
||||
f"(kein Prompt innerhalb von {timeout}s)."
|
||||
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||
)
|
||||
raise SshError(
|
||||
f"{self.host} hat auf das Kommando nicht geantwortet "
|
||||
f"(kein Prompt innerhalb von {timeout}s)."
|
||||
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||
)
|
||||
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 geöffnet.")
|
||||
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
|
||||
if before == "enabled" and off_state == "unknown":
|
||||
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
|
||||
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
|
||||
# zu zaehlen war der Fehler, der einen Neustart meldete, den
|
||||
# es nicht gab.
|
||||
result.error = (
|
||||
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
|
||||
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
|
||||
)
|
||||
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
|
||||
|
||||
if before == "unknown" and off_state == "unknown" and after == "unknown":
|
||||
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
|
||||
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
|
||||
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
|
||||
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
|
||||
# gesagt.
|
||||
result.add(
|
||||
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
|
||||
"angenommen, bestätigt ist der Zyklus damit nicht."
|
||||
)
|
||||
|
||||
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-Übersicht aller Ports"),
|
||||
("show interfaces brief", "Portstatus"),
|
||||
("show vlans", "VLAN-Uebersicht"),
|
||||
("show mac-address", "MAC-Adresstabelle"),
|
||||
("show lldp info remote-device", "Nachbargeräte (LLDP)"),
|
||||
("show version", "Firmware-Version"),
|
||||
("show logging", "Switch-Protokoll"),
|
||||
)
|
||||
@@ -0,0 +1,837 @@
|
||||
"""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.rbac.legacy_permissions import umgeschrieben
|
||||
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", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
|
||||
Category("switches", "Switche", "Switch-Inventar"),
|
||||
Category(
|
||||
"credentials",
|
||||
"Zugangsdaten",
|
||||
"SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
|
||||
"ausschließlich durch die Passphrase.",
|
||||
sensitive=True,
|
||||
),
|
||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||
Category(
|
||||
"directory",
|
||||
"Verzeichnisdienst",
|
||||
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
|
||||
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
|
||||
admin_only=True,
|
||||
sensitive=True,
|
||||
),
|
||||
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(
|
||||
"signatures",
|
||||
"Unterschriften",
|
||||
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
|
||||
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
|
||||
"veraenderten.",
|
||||
),
|
||||
)
|
||||
|
||||
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 entschlüsselbar"
|
||||
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 "directory" in wanted:
|
||||
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
|
||||
# das man nach einer Wiederherstellung von Hand nachbauen musste --
|
||||
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
|
||||
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
|
||||
from tesm_core.auth import directory as verzeichnis
|
||||
|
||||
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
|
||||
gesetzt = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||
}
|
||||
payload["directory"] = {
|
||||
# Nur die Schluessel der Anbindung, und nur die, die wirklich
|
||||
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
|
||||
# Entscheidung und ueberschreibt beim Einlesen eine echte.
|
||||
"settings": {
|
||||
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
|
||||
if spec.key in gesetzt
|
||||
},
|
||||
"bind_username": bind_benutzer,
|
||||
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
|
||||
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
|
||||
# muesste man die halbe Anbindung doch wieder tippen.
|
||||
"bind_password": bind_passwort if include_secrets else "",
|
||||
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
|
||||
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
|
||||
# Gruppen-Id nichts wert -- der Name ist es.
|
||||
"group_mappings": [
|
||||
{
|
||||
"ad_group_dn": str(row["ad_group_dn"]),
|
||||
"ad_group_name": str(row["ad_group_name"]),
|
||||
"group_name": str(row["group_name"] or ""),
|
||||
"grants_admin": bool(row["grants_admin"]),
|
||||
}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
|
||||
"FROM ldap_group_mappings m "
|
||||
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.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_")
|
||||
}
|
||||
if "signatures" in wanted:
|
||||
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
|
||||
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
|
||||
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
|
||||
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
|
||||
payload["signatures"] = Database.all(
|
||||
conn,
|
||||
"SELECT sha256, share_label, path, signed_at, actor, names "
|
||||
"FROM document_signatures ORDER BY id",
|
||||
)
|
||||
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
|
||||
|
||||
#: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten).
|
||||
alte_fassung: set[bool] = set()
|
||||
|
||||
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", []):
|
||||
gruppe = str(entry.get("ad_group_dn") or "")
|
||||
pfad = str(entry.get("unc") or "")
|
||||
if not gruppe or not pfad:
|
||||
bump("fileshare", "skipped")
|
||||
continue
|
||||
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
|
||||
# stand dort auch bei einer frischen Installation "2 aktualisiert",
|
||||
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
|
||||
# prueft, liest genau diese Zahlen.
|
||||
vorhanden = Database.value(
|
||||
conn,
|
||||
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
|
||||
(gruppe, pfad),
|
||||
)
|
||||
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",
|
||||
(
|
||||
gruppe,
|
||||
str(entry.get("ad_group_name") or ""),
|
||||
str(entry.get("label") or ""),
|
||||
pfad,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("fileshare", "updated" if vorhanden else "created")
|
||||
|
||||
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,))
|
||||
# Eine Sicherung aus 2.7.x nennt devices.view und devices.edit.
|
||||
# Ohne die Umschreibung landen lauter unbekannte Schluessel in der
|
||||
# Gruppe, und sanitize() wirft Unbekanntes still weg: die
|
||||
# wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung.
|
||||
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
|
||||
neue = umgeschrieben(mitgebracht)
|
||||
if sorted(set(mitgebracht)) != neue:
|
||||
# Das Paket kennt die alten Schluessel -- es stammt also von
|
||||
# vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber
|
||||
# die Schluessel selbst sind das verlaesslichere Kennzeichen:
|
||||
# sie stehen genau dann drin, wenn die Sicherung aelter ist.
|
||||
alte_fassung.add(True)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, permission) for permission in neue],
|
||||
)
|
||||
|
||||
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 "signatures" in wanted:
|
||||
for eintrag in payload.get("signatures") or []:
|
||||
if not isinstance(eintrag, dict):
|
||||
continue
|
||||
summe = str(eintrag.get("sha256") or "").strip()
|
||||
pfad = str(eintrag.get("path") or "").strip()
|
||||
if not summe:
|
||||
continue
|
||||
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
|
||||
# Pruefsumme und Ablageort, genau wie beim Journal.
|
||||
vorhanden = Database.value(
|
||||
conn,
|
||||
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||
(summe, pfad),
|
||||
)
|
||||
if vorhanden:
|
||||
bump("signatures", "skipped")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||
(
|
||||
summe,
|
||||
str(eintrag.get("share_label") or ""),
|
||||
pfad,
|
||||
str(eintrag.get("signed_at") or stamp),
|
||||
str(eintrag.get("actor") or ""),
|
||||
str(eintrag.get("names") or ""),
|
||||
),
|
||||
)
|
||||
bump("signatures", "created")
|
||||
|
||||
if "directory" in wanted:
|
||||
from tesm_core.auth import directory as verzeichnis
|
||||
|
||||
abschnitt = payload.get("directory") or {}
|
||||
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
|
||||
for key, value in (abschnitt.get("settings") or {}).items():
|
||||
if str(key) not in bekannt:
|
||||
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
|
||||
# hin; die Kategorie "Einstellungen" nimmt alles andere.
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
set_setting(str(key), value)
|
||||
bump("directory", "updated")
|
||||
|
||||
benutzer = str(abschnitt.get("bind_username") or "").strip()
|
||||
passwort = str(abschnitt.get("bind_password") or "")
|
||||
if benutzer:
|
||||
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
|
||||
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
|
||||
# das Passwort nicht loeschen.
|
||||
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
|
||||
bump("directory", "updated")
|
||||
|
||||
for eintrag in abschnitt.get("group_mappings") or []:
|
||||
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not dn:
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
kennzeichen = bool(eintrag.get("grants_admin"))
|
||||
gruppe = None
|
||||
if not kennzeichen:
|
||||
name = str(eintrag.get("group_name") or "").strip()
|
||||
gruppe = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
|
||||
)
|
||||
if gruppe is None:
|
||||
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
|
||||
# waere eine Zuordnung ins Leere: beim naechsten Login
|
||||
# bekaeme das Konto nichts, und niemand wuesste warum.
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings "
|
||||
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||
"grants_admin=excluded.grants_admin",
|
||||
(
|
||||
dn,
|
||||
str(eintrag.get("ad_group_name") or dn),
|
||||
int(gruppe) if gruppe is not None else None,
|
||||
int(kennzeichen),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("directory", "created")
|
||||
|
||||
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")
|
||||
|
||||
if alte_fassung and "users" in wanted:
|
||||
# Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der
|
||||
# diese Sicherung kommt, konnte ein Administrator alles. Ohne die
|
||||
# Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er
|
||||
# haelt das fuer einen Fehler der Wiederherstellung.
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
rbac.funktionsgruppe_fuer_administratoren(
|
||||
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||
)
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Bildschirmzugriff per VNC: Ziel bestimmen, Erreichbarkeit pruefen, Datei bauen.
|
||||
|
||||
Auf den HAPs (Raspberry Pis) laeuft ein VNC-Server. TESM kennt von jedem
|
||||
Client Namen und Adresse -- daraus laesst sich die Verbindung erzeugen, statt
|
||||
sie in jedem Viewer von Hand zu pflegen.
|
||||
|
||||
Zwei Wege, beide aus denselben zwei Angaben:
|
||||
|
||||
* eine **Verbindungsdatei** (``.vnc``), wie der RealVNC Viewer sie schreibt.
|
||||
Sie enthaelt genau drei Zeilen -- ``ConnMethod``, ``FriendlyName``, ``Host``
|
||||
-- und **nichts weiter**. Jede zusaetzliche Angabe ist eine Gelegenheit,
|
||||
etwas zu verlangen, das hier niemand verlangen soll: keine
|
||||
Verschluesselungsvorgabe, keine Identitaetspruefung, kein Benutzername, kein
|
||||
Passwortfeld. Was am Ende gefragt wird, entscheidet ohnehin der VNC-Server
|
||||
auf dem Geraet; die Datei fragt von sich aus nichts.
|
||||
* eine **Adresse** (``vnc://host``) fuer den Fall, dass der Viewer als
|
||||
Handler registriert ist -- ein Klick statt Herunterladen und Oeffnen.
|
||||
|
||||
Der Zugang erscheint nur dort, wo wirklich ein VNC-Server antwortet
|
||||
(:func:`erreichbar`). Das ist der Unterschied zu einem Schalter am Geraet:
|
||||
niemand muss ihn pflegen, und er zeigt nie ins Leere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .monitor import tcp_reachable
|
||||
|
||||
#: Vorgabeport von VNC (Anzeige :0). Ueberschreibbar in den Einstellungen.
|
||||
STANDARD_PORT = 5900
|
||||
|
||||
#: So kurz, dass die Detailseite es nicht merkt, und so lang, dass ein Geraet
|
||||
#: im selben Netz antworten kann. Gefragt wird ohnehin nur, wenn der Client
|
||||
#: gerade als erreichbar gilt.
|
||||
ZEITLIMIT = 0.6
|
||||
|
||||
#: Ein Name, den ein Rechner aufloesen kann: Buchstaben, Ziffern, Bindestrich,
|
||||
#: Punkt als Trenner. "HAP 02" oder "Kamera Eingang" ist keiner -- dann bleibt
|
||||
#: die IP-Adresse, die im Datensatz ohnehin steht.
|
||||
HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
|
||||
|
||||
#: Zeilenende der Datei: CRLF. Der Viewer liest beides, aber die Datei landet
|
||||
#: auf einem Windows-Arbeitsplatz -- und dort soll sie in jedem Editor
|
||||
#: vernuenftig aussehen, wenn jemand nachsieht, was drinsteht.
|
||||
ZEILE = "\r\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Zugang:
|
||||
"""Was die Detailseite ueber den Bildschirmzugriff eines Clients weiss."""
|
||||
|
||||
ziel: str
|
||||
port: int
|
||||
erreichbar: bool
|
||||
#: Die Sicherheitsverfahren, die der Server angeboten hat (leer: nicht
|
||||
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
|
||||
#: gehen wird oder nicht.
|
||||
verfahren: tuple[int, ...] = ()
|
||||
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
|
||||
#: dann kein Verfahren, sondern eine 0 und einen Grund.
|
||||
absage: str = ""
|
||||
|
||||
@property
|
||||
def browsertauglich(self) -> bool:
|
||||
"""Ist mindestens ein angebotenes Verfahren eines, das noVNC kann?"""
|
||||
return bool(set(self.verfahren) & BROWSER_VERFAHREN)
|
||||
|
||||
@property
|
||||
def hinderungsgrund(self) -> str:
|
||||
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
|
||||
if not self.erreichbar:
|
||||
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server."
|
||||
if self.absage:
|
||||
return (
|
||||
f"Der VNC-Server auf {self.ziel} weist die Verbindung ab: "
|
||||
f"„{self.absage}“. Das ist seine Auskunft, nicht unsere – "
|
||||
"typisch sind eine Sperre nach zu vielen Fehlversuchen "
|
||||
"(RealVNC: BlacklistTimeout), eine Adressfilterung oder eine "
|
||||
"belegte Sitzung."
|
||||
)
|
||||
if not self.verfahren:
|
||||
return ""
|
||||
if self.browsertauglich:
|
||||
return ""
|
||||
angeboten = ", ".join(verfahren_name(v) for v in self.verfahren)
|
||||
return (
|
||||
f"Der VNC-Server bietet nur {angeboten} an. Der Browser-Client kann "
|
||||
"davon keines: herstellereigene Verfahren sind nicht offengelegt, "
|
||||
"und RA2 (5) verschlüsselt die ganze Strecke mit einem Verfahren, "
|
||||
"das er nicht mitbringt. Auf dem Gerät hilft eine Zeile: "
|
||||
"Encryption=AlwaysOff (dann bietet der Server RA2ne an) oder "
|
||||
"Authentication=VncAuth samt vncpasswd (dann das klassische "
|
||||
"VNC-Passwort)."
|
||||
)
|
||||
|
||||
@property
|
||||
def adresse(self) -> str:
|
||||
"""``vnc://host`` -- den Standardport schreibt niemand dazu."""
|
||||
if self.port == STANDARD_PORT:
|
||||
return f"vnc://{self.ziel}"
|
||||
return f"vnc://{self.ziel}:{self.port}"
|
||||
|
||||
|
||||
#: Aufloesungen, die wir schon einmal versucht haben: ``name -> (Zeit, Ergebnis)``.
|
||||
#:
|
||||
#: Nicht wegen der Geschwindigkeit -- ein arbeitender Resolver antwortet in
|
||||
#: Bruchteilen einer Millisekunde --, sondern wegen des Gegenteils: faellt der
|
||||
#: Nameserver aus, laeuft jede Abfrage in sein eigenes Zeitlimit. Ohne diesen
|
||||
#: Puffer haette das der Seitenaufbau ausgebadet, und zwar bei jedem Aufruf.
|
||||
_AUFLOESUNG: dict[str, tuple[float, bool]] = {}
|
||||
|
||||
#: Fuenf Minuten. Lang genug, damit ein kaputter Resolver nicht jede Seite
|
||||
#: aufhaelt; kurz genug, dass ein neuer DNS-Eintrag nicht bis zum Neustart
|
||||
#: wartet.
|
||||
AUFLOESUNG_GILT = 300.0
|
||||
|
||||
|
||||
def aufloesbar(name: str) -> bool:
|
||||
"""Kennt der Resolver diesen Namen?"""
|
||||
jetzt = time.monotonic()
|
||||
gemerkt = _AUFLOESUNG.get(name)
|
||||
if gemerkt is not None and jetzt - gemerkt[0] < AUFLOESUNG_GILT:
|
||||
return gemerkt[1]
|
||||
try:
|
||||
socket.getaddrinfo(name, None)
|
||||
ergebnis = True
|
||||
except OSError:
|
||||
ergebnis = False
|
||||
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def ziel(device: Mapping[str, Any]) -> str:
|
||||
"""Der Name des Clients, sonst seine IP-Adresse.
|
||||
|
||||
Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
|
||||
Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
|
||||
(``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
|
||||
**taugen** ("Kamera Eingang" tut es nicht) und er muss sich **aufloesen**
|
||||
lassen. Sonst zeigte die Datei auf einen Namen, den der Arbeitsplatz nicht
|
||||
kennt, und niemand saehe, woran es liegt; dann ist die Adresse richtig, die
|
||||
ohnehin im Datensatz steht.
|
||||
|
||||
Loest der Name auf eine **andere** Adresse auf als die gespeicherte, gewinnt
|
||||
trotzdem der Name: dann ist der Eintrag im Bestand veraltet, nicht das DNS.
|
||||
"""
|
||||
name = str(device.get("name") or "").strip()
|
||||
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||
return name.lower()
|
||||
return str(device.get("ip") or "").strip()
|
||||
|
||||
|
||||
def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||
"""Nimmt auf diesem Host ein VNC-Server Verbindungen an?"""
|
||||
if not host:
|
||||
return False
|
||||
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||
|
||||
|
||||
#: Was der Browser-Client (noVNC 1.6) aushandeln kann. Aus dessen
|
||||
#: ``_isSupportedSecurityType`` uebernommen -- steht hier, weil die Oberflaeche
|
||||
#: **vor** dem Verbinden sagen koennen soll, ob es klappen wird.
|
||||
BROWSER_VERFAHREN: frozenset[int] = frozenset({1, 2, 6, 16, 19, 22, 30, 113})
|
||||
|
||||
#: Die Namen dazu. Was nicht drinsteht, ist herstellereigen -- die Nummern
|
||||
#: 7 bis 15 gehoeren RealVNC, 3 und 4 ebenfalls.
|
||||
VERFAHREN_NAMEN: dict[int, str] = {
|
||||
1: "ohne Authentifizierung",
|
||||
2: "VNC-Passwort",
|
||||
5: "RA2 (RSA-AES, verschlüsselt)",
|
||||
6: "RA2ne (RSA-AES, unverschlüsselt)",
|
||||
16: "Tight",
|
||||
18: "TLS",
|
||||
19: "VeNCrypt",
|
||||
22: "xvp",
|
||||
30: "Apple ARD",
|
||||
113: "MS-Logon II",
|
||||
}
|
||||
|
||||
|
||||
def verfahren_name(nummer: int) -> str:
|
||||
"""Lesbarer Name eines Sicherheitsverfahrens."""
|
||||
if nummer in VERFAHREN_NAMEN:
|
||||
return f"{nummer} ({VERFAHREN_NAMEN[nummer]})"
|
||||
if 3 <= nummer <= 15:
|
||||
return f"{nummer} (RealVNC-eigen)"
|
||||
return str(nummer)
|
||||
|
||||
|
||||
def _absage(sock: Any) -> str:
|
||||
"""Der Grund, den ein Server einer abgelehnten Verbindung mitgibt.
|
||||
|
||||
Auf eine 0 -- ob als Anzahl (ab 3.7) oder als bestimmtes Verfahren (3.3) --
|
||||
folgt eine Laenge und ein Text. Genau dieser Satz ist die Auskunft, die
|
||||
sonst niemand bekommt: "zu viele Fehlversuche", "Verbindung von dieser
|
||||
Adresse nicht erlaubt", "kein gemeinsames Verfahren". Ohne ihn steht da
|
||||
nur, dass es nicht geht.
|
||||
"""
|
||||
try:
|
||||
roh = sock.recv(4)
|
||||
if len(roh) < 4:
|
||||
return ""
|
||||
laenge = int.from_bytes(roh, "big")
|
||||
if not 0 < laenge <= 1024:
|
||||
return ""
|
||||
text = b""
|
||||
while len(text) < laenge:
|
||||
stueck = sock.recv(laenge - len(text))
|
||||
if not stueck:
|
||||
break
|
||||
text += stueck
|
||||
return text.decode("utf-8", "replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def sicherheitsverfahren(
|
||||
host: str, port: int = STANDARD_PORT
|
||||
) -> tuple[str, tuple[int, ...], str]:
|
||||
"""Fragt den Server, **wie** er sich authentifizieren will.
|
||||
|
||||
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt
|
||||
seine Protokollfassung, der Client die seine, dann kommt die Liste der
|
||||
Sicherheitsverfahren. Weiter gehen wir nicht -- die Verbindung wird sofort
|
||||
geschlossen, es wird nichts angemeldet und nichts geraten.
|
||||
|
||||
Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
|
||||
gelingt, und sie hier zu messen ist der Unterschied zwischen einer
|
||||
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
|
||||
der Browser nicht kennt".
|
||||
|
||||
Gibt ``(Fassung, Verfahren, Absage)`` zurueck. Die Absage ist der Text, den
|
||||
ein Server mitschickt, wenn er die Verbindung **ablehnt** -- er meldet dann
|
||||
kein Verfahren, sondern eine 0 und einen Grund. Bei einem Fehler
|
||||
``("", (), "")``.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=ZEITLIMIT) as sock:
|
||||
sock.settimeout(ZEITLIMIT)
|
||||
gruss = sock.recv(12)
|
||||
if not gruss.startswith(b"RFB "):
|
||||
return "", (), ""
|
||||
fassung = gruss[4:11].decode("ascii", "replace")
|
||||
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser
|
||||
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und
|
||||
# nennt trotzdem seine Verfahren.
|
||||
# Antworten wird mit *seiner* Fassung, hoechstens 3.8. Wer einem
|
||||
# 3.3-Server mit 3.8 antwortet, bekommt keine Liste, sondern eine
|
||||
# 32-Bit-Zahl -- und liest sie als "keine Verfahren".
|
||||
eigene = "003.003" if fassung == "003.003" else "003.008"
|
||||
sock.sendall(b"RFB " + eigene.encode("ascii") + bytes([10]))
|
||||
|
||||
if eigene == "003.003":
|
||||
# RFB 3.3: der Server bestimmt allein und schickt eine
|
||||
# 32-Bit-Zahl. Das ist kein Sonderfall aus der Vorzeit --
|
||||
# RealVNC faellt darauf zurueck, sobald keine Anmeldung
|
||||
# verlangt wird.
|
||||
rohwert = b""
|
||||
while len(rohwert) < 4:
|
||||
stueck = sock.recv(4 - len(rohwert))
|
||||
if not stueck:
|
||||
break
|
||||
rohwert += stueck
|
||||
if len(rohwert) < 4:
|
||||
return fassung, (), ""
|
||||
bestimmt = int.from_bytes(rohwert, "big")
|
||||
if bestimmt == 0:
|
||||
return fassung, (), _absage(sock)
|
||||
return fassung, (bestimmt,), ""
|
||||
|
||||
anzahl = sock.recv(1)
|
||||
if not anzahl:
|
||||
return fassung, (), ""
|
||||
wie_viele = anzahl[0]
|
||||
if wie_viele == 0:
|
||||
# Null heisst: der Server lehnt ab und schickt den Grund.
|
||||
return fassung, (), _absage(sock)
|
||||
liste = b""
|
||||
while len(liste) < wie_viele:
|
||||
stueck = sock.recv(wie_viele - len(liste))
|
||||
if not stueck:
|
||||
break
|
||||
liste += stueck
|
||||
return fassung, tuple(liste), ""
|
||||
except OSError:
|
||||
return "", (), ""
|
||||
|
||||
|
||||
def zugang_kurz(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||
"""Nur die Frage, ob dort jemand annimmt -- ohne ein Wort Protokoll.
|
||||
|
||||
Das ist die Fassung fuer den Ueberwachungsdurchlauf. Er laeuft alle paar
|
||||
Minuten ueber jedes Geraet, und eine angefangene und sofort wieder
|
||||
abgebrochene Protokollverhandlung ist nicht nur unhoeflich: ein RealVNC
|
||||
Server zaehlt abgebrochene Versuche und sperrt die Adresse eine Weile aus
|
||||
(``BlacklistTimeout``). Eine Ueberwachung, die ihr eigenes Ziel aussperrt,
|
||||
waere ein schoener Eigentor -- also fragt sie nur, ob der Port annimmt.
|
||||
|
||||
Welche Anmeldeverfahren der Server kann, misst erst die Seite, die es
|
||||
wirklich braucht -- und auch die nicht oefter als noetig.
|
||||
"""
|
||||
host = ziel(device)
|
||||
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port))
|
||||
|
||||
|
||||
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
|
||||
"""Ziel, Erreichbarkeit und Verfahren in einem Stueck -- so nimmt es die Vorlage."""
|
||||
host = ziel(device)
|
||||
if not pruefen:
|
||||
return Zugang(ziel=host, port=port, erreichbar=False)
|
||||
fassung, verfahren, absage = sicherheitsverfahren(host, port)
|
||||
return Zugang(
|
||||
ziel=host,
|
||||
port=port,
|
||||
erreichbar=bool(fassung),
|
||||
verfahren=verfahren,
|
||||
absage=absage,
|
||||
)
|
||||
|
||||
|
||||
def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||
"""Der Zugang, wie er in der Datenbank steht -- **ohne** das Geraet zu fragen.
|
||||
|
||||
Frueher hat diese Funktion selbst gemessen, wenn die letzte Messung alt war.
|
||||
Das ist gestrichen, und zwar aus Erfahrung: jede Messung ist eine
|
||||
angefangene und abgebrochene Protokollverhandlung, und ein RealVNC Server
|
||||
zaehlt so etwas. Wer eine Bildschirmseite oeffnet, klopfte damit zweimal an
|
||||
-- einmal zum Messen, einmal fuer die Sitzung --, und das zweite Klopfen
|
||||
fiel dem ersten zum Opfer.
|
||||
|
||||
Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die
|
||||
Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser
|
||||
Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen**
|
||||
Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn
|
||||
etwas nicht geht.
|
||||
"""
|
||||
gespeichert = str(device.get("vnc_types") or "")
|
||||
return Zugang(
|
||||
ziel=ziel(device),
|
||||
port=port,
|
||||
erreichbar=bool(device.get("vnc_open")),
|
||||
verfahren=tuple(
|
||||
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
|
||||
),
|
||||
absage=str(device.get("vnc_reason") or ""),
|
||||
)
|
||||
|
||||
|
||||
def verbindungsdatei(
|
||||
device: Mapping[str, Any], *, port: int = STANDARD_PORT, warnung_aus: bool = False
|
||||
) -> str:
|
||||
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
|
||||
|
||||
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
|
||||
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
|
||||
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
|
||||
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
|
||||
|
||||
``warnung_aus`` haengt eine vierte Zeile an: ``WarnUnencrypted=False``. Sie
|
||||
betrifft **den Viewer**, nicht den Server -- er zeigt sonst bei jeder
|
||||
Verbindung zu einem Geraet ohne Streckenverschluesselung einen roten
|
||||
Hinweis, den man wegklicken muss. Wer ``Encryption=AlwaysOff`` auf dem
|
||||
Geraet gesetzt hat (damit der Browser ueberhaupt verbinden kann), sieht
|
||||
diesen Hinweis fortan bei jedem Start. Er bleibt in der Vorgabe stehen:
|
||||
eine unverschluesselte Strecke soll man einmal bewusst hinnehmen, nicht
|
||||
stillschweigend. Abschaltbar unter Verwaltung -> System.
|
||||
"""
|
||||
host = ziel(device)
|
||||
if port != STANDARD_PORT:
|
||||
host = f"{host}:{port}"
|
||||
zeilen = [
|
||||
"ConnMethod=tcp",
|
||||
f"FriendlyName={str(device.get('name') or host).strip()}",
|
||||
f"Host={host}",
|
||||
]
|
||||
if warnung_aus:
|
||||
zeilen.append("WarnUnencrypted=False")
|
||||
return ZEILE.join(zeilen) + ZEILE
|
||||
|
||||
|
||||
def dateiname(device: Mapping[str, Any]) -> str:
|
||||
"""``HAP02.vnc`` -- aus dem Namen, auf Unbedenkliches beschraenkt.
|
||||
|
||||
Auch der Punkt faellt weg, nicht nur der Schraegstrich: ein Geraetename ist
|
||||
Freitext, und ein Dateiname, in dem ".." vorkommen kann, ist eine Frage,
|
||||
die man sich gar nicht erst stellen muss.
|
||||
"""
|
||||
name = re.sub(r"[^A-Za-z0-9_-]+", "-", str(device.get("name") or "client")).strip("-")
|
||||
return f"{name or 'client'}.vnc"
|
||||
|
||||
|
||||
def port_aus_einstellungen(extension: Any) -> int:
|
||||
"""Der eingestellte Port -- oder 0, wenn der Zugang abgeschaltet ist.
|
||||
|
||||
Nimmt die Erweiterung statt der Verbindung, weil der Ueberwachungsprozess
|
||||
ausserhalb eines Anfragekontextes laeuft und seine eigene Sitzung oeffnet.
|
||||
"""
|
||||
with extension.database.session() as conn:
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or STANDARD_PORT)
|
||||
@@ -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-Gerät abgewiesen. Bei einem "
|
||||
"Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
|
||||
"Backslash.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_ACCESS_DENIED",
|
||||
"Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
|
||||
"'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
|
||||
"Administratoren genügt dafür in der Regel.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_CONNECTION_REFUSED",
|
||||
"Das Gerät 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 Gerät erreichbar und die Firewall offen?",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_HOST_UNREACHABLE",
|
||||
"Das Gerät 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 Gerät keine Administratorrechte. Ein Neustart über "
|
||||
"RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
|
||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
|
||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||
"Der benötigte RPC-Dienst antwortet auf dem Gerät 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' lässt sich "
|
||||
"ein Windows-Gerät 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 überschritten.")
|
||||
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 überschrieben 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 Gerät keine "
|
||||
"Administratorrechte. Für einen Neustart über RPC braucht es sie.",
|
||||
)
|
||||
return RemoteResult(False, output, hint_for(output) or "Prüfung 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 ausgelöst.")
|
||||
return RemoteResult(False, output, hint_for(output))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""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",
|
||||
"Prüfintervall (Sekunden)",
|
||||
hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. 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 Fehlschlägen wird geschaltet. "
|
||||
"Der Vorgänger 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",
|
||||
"Statusübersicht ohne Anmeldung erreichbar",
|
||||
hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
|
||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||
SettingSpec(
|
||||
"vnc_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Bildschirmzugriff (VNC) anbieten",
|
||||
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_port",
|
||||
5900,
|
||||
"int",
|
||||
"VNC-Port",
|
||||
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||
section="monitoring",
|
||||
minimum=1,
|
||||
maximum=65535,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_warn_unencrypted_off",
|
||||
False,
|
||||
"bool",
|
||||
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
|
||||
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
|
||||
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
|
||||
"unverschlüsselt, wenn das Gerät es so anbietet.",
|
||||
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 lösen nach (Stunden)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
# -- 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 Rückkehr nach einem Neustart (Sekunden)",
|
||||
section="maintenance",
|
||||
minimum=30,
|
||||
maximum=1800,
|
||||
),
|
||||
# -- Unterschriften ------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"signature_witness_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
|
||||
"Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
|
||||
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
|
||||
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
|
||||
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
|
||||
section="fileshare",
|
||||
),
|
||||
# -- Terminal ----------------------------------------------------------- #
|
||||
SettingSpec(
|
||||
"terminal_switches_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Konsole für Switche zulassen",
|
||||
hint="Auch wenn aktiv: erfordert das Recht 'Ausführen' auf Switche, eine frische "
|
||||
"Passwortbestätigung und wird vollständig protokolliert. Das Freigeben von "
|
||||
"Host-Schlüsseln bleibt davon unberührt – es wird für den PoE-Neustart "
|
||||
"gebraucht.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"terminal_clients_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Konsole für Clients zulassen",
|
||||
hint="Braucht zusätzlich das Modul Wartung und das Recht 'Ausführen' auf Wartung. "
|
||||
"Das Freigeben von Host-Schlüsseln bleibt davon unberührt.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"terminal_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Terminal bei Untätigkeit trennen nach (Sekunden)",
|
||||
section="monitoring",
|
||||
minimum=60,
|
||||
maximum=3600,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
{# 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.write') 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>
|
||||
{# Die beiden Eingriffe stehen unten und immer an derselben
|
||||
Stelle: links der Bildschirm, rechts der Neustart. Wer
|
||||
eine Reihe Kacheln abarbeitet, trifft denselben Knopf,
|
||||
ohne hinzusehen -- und verwechselt ihn nicht mit dem
|
||||
anderen. #}
|
||||
{% if tile.vnc or tile.restart_methods %}
|
||||
<div class="tile__actions">
|
||||
{% if tile.vnc %}
|
||||
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
||||
Viewer auf dem Arbeitsplatz, keine Datei. #}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="tile__spacer" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
{% if 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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.write') %}
|
||||
<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.write') %}
|
||||
{% 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,277 @@
|
||||
{% 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('device_console.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 vnc and vnc.erreichbar %}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=device.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=15) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if 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.write') %}
|
||||
<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.write') %}
|
||||
<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.write') %}
|
||||
<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.write') %}
|
||||
<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>
|
||||
{% if vnc %}
|
||||
<dt>Bildschirm (VNC)</dt>
|
||||
<dd>
|
||||
{% if vnc.erreichbar %}
|
||||
<span class="mono">{{ vnc.ziel }}:{{ vnc.port }}</span>
|
||||
<div class="fs-xs faint">
|
||||
<a href="{{ url_for('screen.screen', device_id=device.id) }}">
|
||||
Im Browser öffnen</a> – oder die
|
||||
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
Verbindungsdatei</a> für einen Viewer auf dem Arbeitsplatz; sie
|
||||
enthält nur Ziel und Namen.
|
||||
{% if vnc.hinderungsgrund %}
|
||||
<div class="mt-1">{{ vnc.hinderungsgrund }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">
|
||||
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<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.write') %}
|
||||
<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 restart_methods.get(device.id) %}
|
||||
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<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.write') %}
|
||||
{% 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 + manuell</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Leases</span>
|
||||
<span class="stat__value">{{ leases | rejectattr('is_expired') | list | length }}</span>
|
||||
<span class="stat__hint">
|
||||
gültig{% if leases | selectattr('is_expired') | list %},
|
||||
{{ leases | selectattr('is_expired') | list | length }} abgelaufen{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has('dhcp_service.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_subnets.write') %}
|
||||
<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_subnets.create') %}
|
||||
<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_subnets.write') %}
|
||||
<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_options.write') %}
|
||||
<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_options.write') 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_options.write') %}
|
||||
<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 == 'manuell' and current_user.has('dhcp_reservations.delete') %}
|
||||
<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_reservations.create') %}
|
||||
<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_config.execute') %}
|
||||
<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,419 @@
|
||||
{% 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.write') %}
|
||||
<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', share=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', share=token) }}">{{ current.label }}</a></span>
|
||||
{% for crumb in listing.breadcrumb %}
|
||||
<span><a href="{{ url_for('fileshare.browse', share=token, path=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', share=token,
|
||||
path=(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="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||
{% endif %}
|
||||
{# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
|
||||
liess den Ordner aussehen wie zwei halbe Zeilen. #}
|
||||
<td class="file-cell">
|
||||
{{ icon('folder', size=15) }}
|
||||
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||
</td>
|
||||
{# An der Stelle der Dateigrösse: wie viel liegt darin. #}
|
||||
<td class="numeric faint fs-sm">
|
||||
{%- if entry.entries is not defined or entry.entries < 0 -%}
|
||||
–
|
||||
{%- elif entry.entries >= 500 -%}
|
||||
500+ Einträge
|
||||
{%- elif entry.entries == 1 -%}
|
||||
1 Eintrag
|
||||
{%- else -%}
|
||||
{{ entry.entries }} Einträge
|
||||
{%- endif -%}
|
||||
</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="paths" 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, path=entry.relative) }}"
|
||||
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||
data-fill-Kind="{{ entry.preview_kind }}"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}>
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</button>
|
||||
{% else %}
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
|
||||
nichts zu holen, und ein toter Link waere Spott. #}
|
||||
<span class="link-icon faint">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% 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, path=entry.relative) }}"
|
||||
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||
data-fill-Kind="{{ entry.preview_kind }}"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
|
||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-rename"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.stem }}"
|
||||
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||
{% endif %}
|
||||
</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="path" 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="path" 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>
|
||||
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
|
||||
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||
<div class="modal__body modal__body--flush" data-viewer
|
||||
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
|
||||
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
||||
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.read') else '' }}"
|
||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||
data-viewer-konto="{{ current_user.username }}"
|
||||
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
|
||||
data-viewer-zeit="{{ serverzeit }}"
|
||||
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
|
||||
<div class="viewer">
|
||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||
<img class="viewer__img" alt="" data-viewer-bild>
|
||||
</div>
|
||||
<div class="viewer__pdf" data-viewer-pdf hidden>
|
||||
{# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
|
||||
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
|
||||
den pdf.js verlangt. #}
|
||||
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
|
||||
<div class="pdfViewer" data-viewer-pdfseiten></div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
||||
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
||||
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
||||
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
|
||||
ablegen darf, sieht sie überhaupt. #}
|
||||
{% if current_user.has('fileshare_pdf.create') %}
|
||||
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
|
||||
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
|
||||
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
|
||||
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
|
||||
<div class="viewer__signfelder">
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Vorname</span>
|
||||
<input class="input" data-viewer-vorname autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Nachname</span>
|
||||
<input class="input" data-viewer-nachname autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
|
||||
aria-label="Unterschriftsfeld"></canvas>
|
||||
<div class="viewer__signaktionen">
|
||||
<span class="fs-xs faint" data-viewer-signhinweis></span>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
|
||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
|
||||
Unterschrift setzen
|
||||
</button>
|
||||
</div>
|
||||
<p class="fs-xs faint">
|
||||
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
|
||||
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
|
||||
einen, die ausgefüllte Fassung gehört in die andere. #}
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Freigabe</span>
|
||||
<select class="select" data-viewer-ziel
|
||||
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
|
||||
{% for eintrag in mounts %}
|
||||
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
|
||||
{{ eintrag.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Ordner</span>
|
||||
{# Auswahl statt Eingabe mit Vorschlagsliste: auf einem iPad laesst
|
||||
sich ein <datalist> kaum bedienen -- das Freigabefeld daneben
|
||||
zeigt, wie es sein soll. #}
|
||||
<select class="select" data-viewer-ordner>
|
||||
<option value="">Wurzelverzeichnis der Freigabe</option>
|
||||
{% for eintrag in verzeichnisse %}
|
||||
<option value="{{ eintrag }}">{{ eintrag }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||
<input class="input" data-viewer-name autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Speichern als</span>
|
||||
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
|
||||
Aussehen des Knopfes und lässt sich später austauschen) oder
|
||||
abgeschlossen (alle Felder werden Teil der Seite). #}
|
||||
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
|
||||
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
|
||||
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
|
||||
<select class="select" data-viewer-modus>
|
||||
{% if current_user.has('fileshare_pdf.write') %}
|
||||
<option value="formular">Formular – weiter ausfüllbar</option>
|
||||
{% endif %}
|
||||
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||
</select>
|
||||
</label>
|
||||
{# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
|
||||
links nach rechts (wohin, wie es heißt, wie gespeichert), und die
|
||||
Entscheidung steht am Ende. #}
|
||||
<div class="viewer__saveaktionen">
|
||||
<label class="check" data-viewer-ersetzenfeld>
|
||||
<input type="checkbox" data-viewer-ersetzen>
|
||||
<span>Vorhandene ersetzen</span>
|
||||
</label>
|
||||
<div class="viewer__saveknoepfe">
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
|
||||
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
|
||||
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
|
||||
<div class="viewer__bar" data-viewer-leiste hidden>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="fit">Anpassen</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="ein"
|
||||
aria-label="Vergrössern">{{ icon('plus', size=15) }}</button>
|
||||
<span class="viewer__hint fs-xs faint" data-viewer-hinweis></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Unterschreiben ist eine eigene Zeile der Matrix: wer ausgefuellte
|
||||
Formulare ablegen darf, darf damit nicht unterschreiben. Der Server
|
||||
weist es ohnehin ab -- aber ein Knopf, der immer in eine Meldung
|
||||
laeuft, ist keiner. #}
|
||||
{% if current_user.has('fileshare_signature.execute') %}
|
||||
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||
{{ icon('pencil', size=15) }} Unterschreiben
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare_pdf.create') %}
|
||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schließen</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.write') %}
|
||||
{% 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="path" data-fill-target="Pfad" value="">
|
||||
<div class="modal__body">
|
||||
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||
attrs='data-fill-target="Name"') }}
|
||||
<p class="fs-xs faint">
|
||||
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||
</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.write') %}
|
||||
<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.write') %}
|
||||
{% 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.write') or current_user.has('switches.write') %}
|
||||
<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,325 @@
|
||||
{% 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 and module in state.modules_off %}
|
||||
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
|
||||
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
||||
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
||||
{% endfor %}
|
||||
{% if state.modules_off %}
|
||||
<div class="fs-xs faint">
|
||||
Abgeschaltet wurde in
|
||||
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
|
||||
die Lizenz ist davon unberührt.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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.write') %}
|
||||
<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.write') %}
|
||||
<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.write') %}
|
||||
<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 %}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 manuell, ü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', method=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('servicing_update.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('servicing_update.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('servicing_update.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,113 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert, pill %}
|
||||
|
||||
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
|
||||
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span>
|
||||
<span><a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a></span>
|
||||
<span>Bildschirm</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem
|
||||
data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der
|
||||
Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist
|
||||
derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich
|
||||
unter die Augen kommt. #}
|
||||
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
|
||||
|
||||
<div class="stack">
|
||||
{% if zugang.hinderungsgrund %}
|
||||
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('monitor', size=17) }} {{ device.name }}
|
||||
{% if zugang.absage %}{{ pill('Verbindung abgelehnt', 'warning') }}
|
||||
{% elif zugang.verfahren %}
|
||||
{% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
|
||||
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
|
||||
bleibt getrennt.
|
||||
{% if zugang.verfahren %}
|
||||
Zuletzt angebotene Anmeldeverfahren:
|
||||
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
|
||||
{% else %}
|
||||
Welche Anmeldeverfahren das Gerät anbietet, ist nicht bekannt – die Seite
|
||||
fragt es nicht von sich aus.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row--tight">
|
||||
<button class="btn btn--sm" type="button" id="screen-cad" hidden
|
||||
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
|
||||
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
|
||||
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
|
||||
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('screen.probe', device_id=device.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet">
|
||||
Verfahren prüfen
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
{{ icon('download', size=14) }} Verbindungsdatei
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card__body stack">
|
||||
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
|
||||
|
||||
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
|
||||
<label class="field">
|
||||
<span class="field__label">Benutzer</span>
|
||||
<input class="input" type="text" id="screen-username" autocomplete="off"
|
||||
value="{{ vorgaben.username }}">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort</span>
|
||||
<input class="input" type="password" id="screen-password" autocomplete="off">
|
||||
</label>
|
||||
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<div id="screen" class="screen"
|
||||
data-socket="{{ websocket_path }}"
|
||||
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
|
||||
data-verfahren="{{ zugang.verfahren | join(',') }}"
|
||||
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
|
||||
data-grund="{{ zugang.hinderungsgrund }}">
|
||||
<div id="screen-canvas" class="screen__canvas" hidden></div>
|
||||
</div>
|
||||
|
||||
{% if not darf_geheimnis and device.credential_id %}
|
||||
<p class="fs-xs faint">
|
||||
Für diesen Client sind Zugangsdaten hinterlegt. TESM gibt sie nur an Konten
|
||||
heraus, die Geheimnisse ohnehin einsehen dürfen – sonst wären sie über den
|
||||
Umweg einer Bildschirmsitzung für jeden lesbar, der sie öffnen darf. Tragen
|
||||
Sie sie oben ein; sie werden nicht gespeichert.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,236 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
|
||||
|
||||
{% 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 manuelle 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) }}
|
||||
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||
checked=values.vnc_enabled,
|
||||
hint_text=specs['vnc_enabled'].hint) }}
|
||||
<div class="form-grid">
|
||||
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||
hint_text=specs['vnc_port'].hint) }}
|
||||
</div>
|
||||
{{ checkbox('vnc_warn_unencrypted_off',
|
||||
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
||||
checked=values.vnc_warn_unencrypted_off,
|
||||
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if module_rows %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
|
||||
<div class="card__sub">
|
||||
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
|
||||
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
|
||||
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
|
||||
sind nach dem Wiedereinschalten wieder da.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.modules_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{% for modul in module_rows %}
|
||||
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
|
||||
hint_text=('In der Lizenz enthalten.' if modul.licensed
|
||||
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
|
||||
freischaltet. Der Haken gilt dann sofort.')) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Terminal</div>
|
||||
<div class="card__sub">
|
||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||
vollständige Mitschrift. Abgeschaltet bleibt das Freigeben von
|
||||
SSH-Host-Schlüsseln möglich – es wird für den PoE-Neustart gebraucht.
|
||||
</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_switches_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
checked=values.terminal_switches_enabled,
|
||||
hint_text=specs['terminal_switches_enabled'].hint) }}
|
||||
{{ checkbox('terminal_clients_enabled', 'Browser-Konsole für Clients zulassen',
|
||||
checked=values.terminal_clients_enabled,
|
||||
hint_text=specs['terminal_clients_enabled'].hint) }}
|
||||
{{ 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('pencil', size=17) }} Unterschriften</div>
|
||||
<div class="card__sub">
|
||||
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||
Papier. Wer das Dokument abgelegt hat, ist dagegen belegt: in den
|
||||
Dokumenteigenschaften und im Änderungsprotokoll, auf Wunsch zusätzlich
|
||||
sichtbar unter der ersten Unterschrift.
|
||||
</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('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
|
||||
checked=values.signature_witness_enabled,
|
||||
hint_text=specs['signature_witness_enabled'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||
sucht sie nicht unter "Protokolle". #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||
<div class="card__sub">
|
||||
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
|
||||
eingebundene Dateifreigabe bestehen bleibt – sie endet mit der Sitzung.
|
||||
</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">
|
||||
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||
value=values.session_idle_minutes, type='number',
|
||||
attrs='min="5" max="1440" step="1"',
|
||||
hint_text=specs['session_idle_minutes'].hint) }}
|
||||
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||
value=values.session_absolute_hours, type='number',
|
||||
attrs='min="1" max="168" step="1"',
|
||||
hint_text=specs['session_absolute_hours'].hint) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('file-text', size=17) }} Protokolle und Papierkorb</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
{{ select_field('log_rotate_interval', 'Rotationsintervall',
|
||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
type='number') }}
|
||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||
value=values.trash_retention_days, type='number',
|
||||
hint_text=specs['trash_retention_days'].hint) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||
</div>
|
||||
<div class="card__body stack">
|
||||
<form method="post" action="{{ url_for('settings.set_hostname') }}" class="stack stack--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
{{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }}
|
||||
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
|
||||
Hostname setzen
|
||||
</button>
|
||||
</form>
|
||||
<div class="divider"></div>
|
||||
<form method="post" action="{{ url_for('settings.set_timezone') }}" class="stack stack--tight">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<label class="field">
|
||||
<span class="field__label">Zeitzone</span>
|
||||
<select class="select" name="timezone" {% if not sysops_available %}disabled{% endif %}>
|
||||
{% for zone in timezones %}
|
||||
<option value="{{ zone }}" {% if zone == timezone %}selected{% endif %}>{{ zone }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint">Wirkt sofort auf die laufende Anwendung.</span>
|
||||
</label>
|
||||
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
|
||||
Zeitzone setzen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||
{% 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('switch_console.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.write') %}
|
||||
<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.write') %}
|
||||
<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.write') %}
|
||||
<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('switch_console.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('switch_console.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.write') %}
|
||||
<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.write') %}
|
||||
{% 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,133 @@
|
||||
"""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, vnc
|
||||
|
||||
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", "Überwachungsprozess gestartet")
|
||||
logger.info("Überwachung läuft, 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(),
|
||||
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz übersprungen, %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", "Überwachungsprozess 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,787 @@
|
||||
#!/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=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
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' "$*"; }
|
||||
|
||||
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||
SICHERUNG_STEHT=0
|
||||
INSTALL_FERTIG=0
|
||||
ZURUECKGEROLLT=0
|
||||
|
||||
zurueckrollen_install() {
|
||||
[[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0
|
||||
# Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen
|
||||
# gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen.
|
||||
[[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; }
|
||||
ZURUECKGEROLLT=1
|
||||
warn "Alten Stand wiederherstellen."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
rm -rf "$FAILED_DIR"
|
||||
mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true
|
||||
warn "Wiederhergestellt aus $BACKUP_DIR"
|
||||
warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR"
|
||||
}
|
||||
|
||||
# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe:
|
||||
# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst
|
||||
# gestoppt und das Verzeichnis teils ersetzt zurueck.
|
||||
bei_fehler() {
|
||||
local code=$?
|
||||
[[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code"
|
||||
if [[ $SICHERUNG_STEHT -eq 1 ]]; then
|
||||
zurueckrollen_install
|
||||
else
|
||||
# Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im
|
||||
# Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein
|
||||
# anderes Skript haelt (upgrade.sh).
|
||||
# Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab,
|
||||
# gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum
|
||||
# Fehler machen.
|
||||
if [[ ${IS_UPDATE:-0} -eq 1 ]]; then
|
||||
# Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet.
|
||||
warn "Abgebrochen (Code $code), bevor etwas geaendert wurde."
|
||||
warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen."
|
||||
else
|
||||
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
|
||||
warn "also wurde nichts wiederhergestellt und nichts geloescht."
|
||||
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
|
||||
# existiert das Verzeichnis noch nicht.
|
||||
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
|
||||
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return "$code"
|
||||
}
|
||||
trap bei_fehler EXIT
|
||||
|
||||
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"
|
||||
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
|
||||
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
|
||||
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
|
||||
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
|
||||
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
|
||||
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
|
||||
EDITION="standard"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
|
||||
fi
|
||||
# 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"
|
||||
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
|
||||
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
|
||||
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
|
||||
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
|
||||
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
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
|
||||
|
||||
port_belegt() {
|
||||
local p="$1"
|
||||
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
|
||||
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
|
||||
# beanspruchen.
|
||||
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
|
||||
return 0
|
||||
# Oder gerade in Benutzung, von was auch immer.
|
||||
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
BASISPORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||
if [[ -n "$INSTANCE" ]]; then
|
||||
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
|
||||
# ("Address already in use") -- und die Gesundheitspruefung merkte es
|
||||
# nicht, weil die erste auf demselben Port antwortete.
|
||||
PORT=$((BASISPORT + 100))
|
||||
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
|
||||
else
|
||||
PORT=$BASISPORT
|
||||
fi
|
||||
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.
|
||||
|
||||
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
|
||||
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
|
||||
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
|
||||
# und niemand haette es gesagt.
|
||||
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
|
||||
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
|
||||
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
|
||||
ROLLE_ALT=""
|
||||
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
|
||||
ROLLE_ALT="$(python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1])).get("role", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
|
||||
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
|
||||
ist die Ausgabe fuer Sub-Lizenzserver.
|
||||
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
|
||||
Passendes Paket: Repository tesm-license-master (privat).
|
||||
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
|
||||
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
|
||||
Sub-Lizenzserver und dieses Paket das richtige."
|
||||
fi
|
||||
|
||||
# -- 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"
|
||||
SICHERUNG_STEHT=1
|
||||
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
|
||||
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
||||
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
|
||||
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
||||
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
||||
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
||||
[[ $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
|
||||
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
|
||||
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
|
||||
|
||||
# -- Health-Check und Rollback ----------------------------------------------
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
# Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu
|
||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||
if systemctl is-active --quiet "$UNIT" &&
|
||||
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/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
|
||||
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||
# sollen, laufen sonst irgendwann auseinander.
|
||||
zurueckrollen_install
|
||||
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
|
||||
|
||||
# -- Lizenzserver einrichten -------------------------------------------------
|
||||
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
|
||||
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
|
||||
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
|
||||
# ein bestehender Schluessel darf nie angetastet werden.
|
||||
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
|
||||
step "Signaturschluessel"
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
|
||||
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
|
||||
else
|
||||
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
|
||||
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "master" ]]; then
|
||||
step "Eigene Master-Lizenz"
|
||||
if [[ -s "$DATEN_DIR/license.json" ]]; then
|
||||
info "Es liegt schon eine Lizenzdatei -- unangetastet."
|
||||
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
|
||||
systemctl restart "$UNIT"
|
||||
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
|
||||
else
|
||||
fail "Die Master-Lizenz liess sich nicht ausstellen.
|
||||
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
INSTALL_FERTIG=1
|
||||
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" && "$EDITION" == "master" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
|
||||
mehr pruefbar:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
elif [[ "$APP" == "tesm-license" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
|
||||
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
|
||||
und stellt keine eigene Master-Lizenz aus.
|
||||
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
|
||||
Kundenlizenzen:
|
||||
$ROOT/data/master_signing_key.json
|
||||
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,38 @@
|
||||
#!/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 des Repositoriums. Genau den erwartet der Bootstrap von
|
||||
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
|
||||
# immer so: im Repositorium tesm-license-master liegt die Anwendung
|
||||
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
if [[ -n "$HERKUNFT" ]]; then
|
||||
NAME="$(basename "$HERKUNFT" .git)"
|
||||
else
|
||||
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
|
||||
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
|
||||
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-test --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
|
||||
# --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-test (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,15 @@
|
||||
# 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.
|
||||
#
|
||||
# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
#
|
||||
# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -0,0 +1,34 @@
|
||||
[Unit]
|
||||
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||
After=tesm.service
|
||||
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||
# Hochfahren.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
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-admin housekeeping
|
||||
|
||||
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0027
|
||||
ReadWritePaths=/srv/tesm/data /var/log/tesm
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=TESM -- taegliches Aufraeumen
|
||||
|
||||
[Timer]
|
||||
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||
# und die Archivierung des Aenderungsprotokolls.
|
||||
#
|
||||
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
||||
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||
RandomizedDelaySec=15m
|
||||
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,34 @@
|
||||
[Unit]
|
||||
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||
After=tesm-license.service
|
||||
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||
# Hochfahren.
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
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=PYTHONUNBUFFERED=1
|
||||
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
|
||||
|
||||
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
LockPersonality=yes
|
||||
SystemCallArchitectures=native
|
||||
UMask=0027
|
||||
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=TESM-Lizenzserver -- taegliches Aufraeumen
|
||||
|
||||
[Timer]
|
||||
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||
# und die Archivierung des Aenderungsprotokolls.
|
||||
#
|
||||
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
||||
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||
RandomizedDelaySec=15m
|
||||
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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-test"). 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,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
# sudo bash update.sh tesm
|
||||
#
|
||||
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
#
|
||||
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
#
|
||||
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
|
||||
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
|
||||
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
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-test" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||
# muesste sie abweisen.
|
||||
REPO="$APP"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
NAME="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
|
||||
# ausfuehren kann und nicht nur den Quelltext liest.
|
||||
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
|
||||
REPO="tesm-license-master"
|
||||
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier 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-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_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 "$BOOTSTRAP_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
|
||||
return 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
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
release_holen
|
||||
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
|
||||
done
|
||||
|
||||
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
|
||||
echo "==> Version $TAG wird ohne Rueckfrage installiert."
|
||||
elif [[ -t 0 ]]; then
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
else
|
||||
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
|
||||
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
|
||||
# fuer erledigt. Lieber laut abbrechen.
|
||||
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
|
||||
echo "installiert. Aufruf mit --yes wiederholen." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
|
||||
# entpacktes Release unter /tmp liegen.
|
||||
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
# sudo bash upgrade.sh --app tesm
|
||||
#
|
||||
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
|
||||
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
|
||||
# dessen install.sh benutzt statt eines Downloads:
|
||||
#
|
||||
# sudo bash deploy/upgrade.sh --app tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance test --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
|
||||
TAG="latest"
|
||||
ROLLBACK=""
|
||||
|
||||
#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet
|
||||
#: die EXIT-Falle, ob sie zurueckrollt.
|
||||
SICHERUNG_STEHT=0
|
||||
UPGRADE_FERTIG=0
|
||||
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||
#: Hygienetest vergleicht sie.
|
||||
#:
|
||||
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
|
||||
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
|
||||
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
|
||||
#: dem gesucht wird, nicht der erste.
|
||||
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.
|
||||
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
|
||||
Wird nur beachtet, wenn das Release geladen wird.
|
||||
--rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte
|
||||
Installation aus der Sicherung wiederherstellen und
|
||||
ihren Dienst wieder einschalten. Ohne Pfad wird die
|
||||
neueste Sicherung dieses Namens genommen.
|
||||
--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 test --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
|
||||
--tag) TAG="${2:?}"; shift 2 ;;
|
||||
--rollback)
|
||||
# Der Pfad ist optional: allein genommen heisst es "die neueste".
|
||||
if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2
|
||||
else ROLLBACK="auto"; shift; fi
|
||||
;;
|
||||
--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"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||
ALT_KOPIE="$BACKUP/alt"
|
||||
SYSTEM_KOPIE="$BACKUP/system"
|
||||
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.
|
||||
# -- Rueckweg ---------------------------------------------------------------- #
|
||||
# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
|
||||
# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
|
||||
SYSTEMDATEIEN=(
|
||||
"/etc/systemd/system/$NAME.service"
|
||||
"/etc/systemd/system/$NAME-monitor.service"
|
||||
"/etc/nginx/sites-available/$NAME"
|
||||
"/etc/logrotate.d/$NAME"
|
||||
"/etc/update-motd.d/50-$NAME"
|
||||
"/etc/tesm/$NAME.env"
|
||||
)
|
||||
|
||||
zurueckrollen() {
|
||||
local sicherung="$1" grund="${2:-}"
|
||||
local alt="$sicherung/alt" system="$sicherung/system"
|
||||
[[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts."
|
||||
|
||||
# Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf
|
||||
# abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen
|
||||
# Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht
|
||||
# IN_PLACE zwar fest, beim spaeteren --rollback aber nicht.
|
||||
local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && ziel="$vermerkt"
|
||||
vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt"
|
||||
fi
|
||||
[[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben."
|
||||
|
||||
[[ -n "$grund" ]] && warn "$grund"
|
||||
step "Rueckweg: alten Stand wiederherstellen"
|
||||
|
||||
# Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt
|
||||
# werden.
|
||||
for unit in "$NAME.service" "$NAME-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"
|
||||
fi
|
||||
done
|
||||
|
||||
# Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die
|
||||
# alte Installation nie angefasst.
|
||||
if [[ "$gleiches_verzeichnis" == "1" ]]; then
|
||||
rm -rf "$ziel"
|
||||
mkdir -p "$ziel"
|
||||
tar -C "$alt" -cf - . | tar -C "$ziel" -xf -
|
||||
info "$ziel aus der Sicherung wiederhergestellt"
|
||||
[[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt."
|
||||
else
|
||||
info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist"
|
||||
if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then
|
||||
rm -rf "$NEW_ROOT"
|
||||
info "$NEW_ROOT entfernt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat.
|
||||
local site_wieder=0
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r zeile; do
|
||||
case "$zeile" in
|
||||
unit-enabled\ *|site-enabled\ *) continue ;;
|
||||
esac
|
||||
local name; name="$(basename "$zeile")"
|
||||
if [[ -e "$system/$name" ]]; then
|
||||
cp -a "$system/$name" "$zeile"
|
||||
info "wiederhergestellt: $zeile"
|
||||
[[ "$zeile" == /etc/nginx/* ]] && site_wieder=1
|
||||
fi
|
||||
done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \
|
||||
&& info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen"
|
||||
fi
|
||||
|
||||
# Und die Dienste wieder einschalten, die vorher liefen.
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r _ unit; do
|
||||
systemctl enable --now "$unit" >/dev/null 2>&1 \
|
||||
&& info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten"
|
||||
done < <(grep '^unit-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
step "Zurueckgerollt"
|
||||
info "Sicherung bleibt liegen: $sicherung"
|
||||
# Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und
|
||||
# bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es.
|
||||
local hinweis
|
||||
hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)"
|
||||
info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}"
|
||||
}
|
||||
|
||||
# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode.
|
||||
# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an
|
||||
# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C.
|
||||
beim_beenden() {
|
||||
local code="${1:-0}"
|
||||
[[ "$code" == "0" ]] && return 0
|
||||
[[ $UPGRADE_FERTIG -eq 1 ]] && return 0
|
||||
if [[ $SICHERUNG_STEHT -eq 0 ]]; then
|
||||
warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen."
|
||||
return 0
|
||||
fi
|
||||
zurueckrollen "$BACKUP" "Abgebrochen (Code $code)."
|
||||
}
|
||||
|
||||
neueste_sicherung() {
|
||||
local kandidat
|
||||
kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)"
|
||||
[[ -n "$kandidat" ]] || return 1
|
||||
printf '%s' "${kandidat%/}"
|
||||
}
|
||||
|
||||
if [[ -n "$ROLLBACK" ]]; then
|
||||
if [[ "$ROLLBACK" == "auto" ]]; then
|
||||
ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben."
|
||||
info "Neueste Sicherung: $ROLLBACK"
|
||||
fi
|
||||
[[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht"
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: es wuerde $ROLLBACK zurueckgerollt."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
zurueckrollen "$ROLLBACK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon
|
||||
# beendet. Erst jetzt braucht es ein Release und eine Quelle.
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier 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-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_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 "$BOOTSTRAP_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
|
||||
return 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
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
|
||||
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
|
||||
# ungefragt ein anderes geladen wird. Sonst hol das Release.
|
||||
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
|
||||
SRC="$REPO_DIR"
|
||||
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
|
||||
else
|
||||
release_holen || fail "Das Release liess sich nicht holen."
|
||||
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
|
||||
fi
|
||||
|
||||
# -- 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 (dort liegt sie normalerweise)"
|
||||
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
|
||||
warn " der Hauptname schon vergeben war)"
|
||||
[[ -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"
|
||||
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses
|
||||
# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh
|
||||
# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie
|
||||
# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene
|
||||
# Instanz hochzieht.
|
||||
#
|
||||
# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur
|
||||
# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit.
|
||||
alte_units_finden() {
|
||||
local quelle="$1" gefunden=() datei name runde
|
||||
shopt -s nullglob
|
||||
for datei in /etc/systemd/system/*.service; do
|
||||
grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")")
|
||||
done
|
||||
# Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann
|
||||
# noch einmal fuer deren eigene Timer.
|
||||
for runde in 1 2; do
|
||||
for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do
|
||||
name="$(basename "$datei")"
|
||||
[[ " ${gefunden[*]-} " == *" $name "* ]] && continue
|
||||
for treffer in ${gefunden[*]-}; do
|
||||
if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi
|
||||
done
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
printf '%s\n' ${gefunden[*]-}
|
||||
}
|
||||
|
||||
mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE")
|
||||
if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then
|
||||
if [[ "$SOURCE" == "/srv/$APP" ]]; then
|
||||
# Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der
|
||||
# Fassung 1.x als Rueckfall.
|
||||
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
|
||||
else
|
||||
ALTE_UNITS=()
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Neue Fassung : $RELEASE_HERKUNFT"
|
||||
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" "$SYSTEM_KOPIE"
|
||||
chmod 700 "$BACKUP"
|
||||
|
||||
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
|
||||
# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
|
||||
# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
|
||||
# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen
|
||||
# anderen Pfad).
|
||||
if [[ $IN_PLACE -eq 1 ]]; then
|
||||
tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)"
|
||||
else
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
fi
|
||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||
|
||||
# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit,
|
||||
# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand
|
||||
# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht.
|
||||
: > "$SYSTEM_KOPIE/manifest"
|
||||
# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer
|
||||
# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach
|
||||
# und mit anderen Schaltern.
|
||||
printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest"
|
||||
printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest"
|
||||
for pfad in "${SYSTEMDATEIEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")"
|
||||
printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
# Welche Units liefen, und war die Site eingeschaltet?
|
||||
for unit in "${ALTE_UNITS[@]}"; do
|
||||
systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest"
|
||||
info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt"
|
||||
SICHERUNG_STEHT=1
|
||||
|
||||
# -- 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")
|
||||
# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag
|
||||
# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C.
|
||||
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
|
||||
if [[ $WITH_DATA -eq 0 ]]; then
|
||||
UPGRADE_FERTIG=1
|
||||
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 --------------------------------------------------------------- #
|
||||
UPGRADE_FERTIG=1
|
||||
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
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
# 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`
|
||||
|
||||
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||
von diesem Projekt liegt:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
sudo bash update.sh tesm
|
||||
```
|
||||
|
||||
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/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 /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
|
||||
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
|
||||
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
|
||||
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
|
||||
anderes geladen wird:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
|
||||
|
||||
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
|
||||
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
|
||||
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
|
||||
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
|
||||
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
|
||||
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
|
||||
und ein Manifest hält fest, woher der Stand kam.
|
||||
|
||||
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
|
||||
gefallen hat:
|
||||
|
||||
```bash
|
||||
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
|
||||
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
|
||||
```
|
||||
|
||||
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
|
||||
|
||||
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 test --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-test
|
||||
```
|
||||
|
||||
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-test --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --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/health
|
||||
```
|
||||
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,188 @@
|
||||
# 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 |
|
||||
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
|
||||
| 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.
|
||||
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
|
||||
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
|
||||
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
|
||||
aufschließt.
|
||||
* **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
|
||||
+166
-238
@@ -1,253 +1,181 @@
|
||||
#!/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.
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Installation -- der kurze Weg.
|
||||
#
|
||||
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
|
||||
# existiert (an sqlite.db), und verhält sich entsprechend:
|
||||
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
|
||||
#
|
||||
# - 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.
|
||||
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# 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
|
||||
# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie
|
||||
# sich das aktuelle Release selbst.
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium
|
||||
# tesm-license-master. Allein-Betrieb dafuer:
|
||||
#
|
||||
# export TESM_RELEASE_REPO=tesm-license-master
|
||||
# export TESM_RELEASE_TOKEN=<Gitea-Token>
|
||||
# sudo -E bash install.sh --app tesm-license --https
|
||||
#
|
||||
# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches
|
||||
# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel
|
||||
# und eigene Master-Lizenz selbst an.
|
||||
#
|
||||
# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe:
|
||||
#
|
||||
# bash install.sh --help
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
print_status() { echo -e "${GREEN}✔${NC} ${1} completed."; }
|
||||
step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
|
||||
# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den
|
||||
# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert.
|
||||
APP=""
|
||||
TAG="latest"
|
||||
ARGUMENTE=("$@")
|
||||
for ((i = 0; i < $#; i++)); do
|
||||
case "${ARGUMENTE[i]}" in
|
||||
--app) APP="${ARGUMENTE[i + 1]:-}" ;;
|
||||
--tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 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
|
||||
# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu
|
||||
# finden -- die beiden Anwendungen liegen in getrennten Repositorien.
|
||||
if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2
|
||||
echo "dafuer braucht sie --app <tesm|tesm-license>." >&2
|
||||
echo "Verwendung: install.sh --app <tesm|tesm-license> [weitere Optionen]" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier 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-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
# "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
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
# ---- 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
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
NEEDS_MIGRATION_GUARD=0
|
||||
BACKUP_DIR=""
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
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")"
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
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"
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_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 "$BOOTSTRAP_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
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
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"
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
# Im Paket: der Installer liegt daneben.
|
||||
exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
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' \
|
||||
"$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)."
|
||||
# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst
|
||||
# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp.
|
||||
release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; }
|
||||
bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.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,483 @@
|
||||
"""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 urllib.parse import urlencode
|
||||
|
||||
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from . import audit, legacy_urls, 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 . import uploads
|
||||
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 Einträge beim nächsten Aufruf endgültig entfernt.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=3650,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_absolute_hours",
|
||||
12,
|
||||
"int",
|
||||
"Längstmögliche Sitzungsdauer (Stunden)",
|
||||
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
|
||||
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||
section="system",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||
"Gilt auch für 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(
|
||||
"modules_disabled",
|
||||
[],
|
||||
"json",
|
||||
"Vor Ort abgeschaltete Module",
|
||||
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||
section="system",
|
||||
),
|
||||
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 Schlüssel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail für 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 Anfragegröße (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 = "",
|
||||
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||
#: falsche Version in die Fussnote schreiben.
|
||||
app_version: str = "unbekannt",
|
||||
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",
|
||||
)
|
||||
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
|
||||
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
|
||||
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
|
||||
app.json.ensure_ascii = False
|
||||
|
||||
# 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)
|
||||
|
||||
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
|
||||
# der Konfiguration beim Start.
|
||||
app.request_class = uploads.UploadLimitRequest
|
||||
|
||||
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,
|
||||
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
|
||||
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
|
||||
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
|
||||
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
|
||||
|
||||
def _gesamtdauer(conn: Any) -> int:
|
||||
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||
try:
|
||||
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wert = 0
|
||||
return wert if wert > 0 else config.security.session_absolute_hours
|
||||
|
||||
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,
|
||||
absolute_lookup=_gesamtdauer,
|
||||
)
|
||||
|
||||
# -- 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 error.code == 404:
|
||||
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||
ziel = legacy_urls.neue_adresse(request.path)
|
||||
if ziel is not None:
|
||||
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||
if parameter:
|
||||
ziel = ziel + "?" + urlencode(parameter)
|
||||
return redirect(ziel)
|
||||
if error.code == 413:
|
||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||
# sehen. Vorher stand hier nur der nackte Status.
|
||||
grenze = uploads.limit_mb()
|
||||
error.description = (
|
||||
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||
"eingestellt; ändern kann das ein Administrator unter "
|
||||
'"Einstellungen -> Webserver und TLS".'
|
||||
)
|
||||
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("/health", "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,326 @@
|
||||
"""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, Collection, Iterable, Mapping, Sequence
|
||||
|
||||
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 diff(
|
||||
vorher: Mapping[str, Any] | None,
|
||||
nachher: Mapping[str, Any] | None,
|
||||
*,
|
||||
felder: Sequence[tuple[str, str]] | None = None,
|
||||
geheim: Collection[str] = (),
|
||||
) -> str:
|
||||
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
|
||||
|
||||
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
|
||||
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
|
||||
|
||||
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
|
||||
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
|
||||
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
|
||||
geaendert haben.
|
||||
"""
|
||||
if vorher is None or nachher is None:
|
||||
return ""
|
||||
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
|
||||
teile: list[str] = []
|
||||
for name, beschriftung in paare:
|
||||
if name not in vorher or name not in nachher:
|
||||
continue
|
||||
alt, neu = vorher[name], nachher[name]
|
||||
if alt == neu:
|
||||
continue
|
||||
if name in geheim:
|
||||
teile.append(f"{beschriftung}: geaendert")
|
||||
continue
|
||||
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
|
||||
return ", ".join(teile)
|
||||
|
||||
|
||||
def _wert(wert: Any) -> str:
|
||||
if wert is None or wert == "":
|
||||
return "(leer)"
|
||||
if isinstance(wert, bool):
|
||||
return "ein" if wert else "aus"
|
||||
text = " ".join(str(wert).split())
|
||||
return text if len(text) <= 80 else text[:79] + "…"
|
||||
|
||||
|
||||
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": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
|
||||
}
|
||||
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 geändert.",
|
||||
}
|
||||
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,756 @@
|
||||
"""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
|
||||
import time
|
||||
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 prüfen", section="ldap"),
|
||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||
SettingSpec(
|
||||
"ldap_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über 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 übernehmen", 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 entschlüsselt 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]):
|
||||
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||
|
||||
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||
|
||||
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||
"""
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||
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="DSA" if braucht_basis else "NO_INFO",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||
#:
|
||||
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||
#: ein sofortiger zweiter Versuch klappte.
|
||||
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||
|
||||
|
||||
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||
global TRANSIENTE_FEHLER
|
||||
if TRANSIENTE_FEHLER:
|
||||
return TRANSIENTE_FEHLER
|
||||
from ldap3.core.exceptions import (
|
||||
LDAPResponseTimeoutError,
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
)
|
||||
|
||||
TRANSIENTE_FEHLER = (
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
LDAPResponseTimeoutError,
|
||||
)
|
||||
return TRANSIENTE_FEHLER
|
||||
|
||||
|
||||
def mit_wiederholung(aufbau: Any) -> Any:
|
||||
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||
|
||||
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||
"""
|
||||
try:
|
||||
return aufbau()
|
||||
except _transiente_fehler() as fehler:
|
||||
logger.info(
|
||||
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||
type(fehler).__name__,
|
||||
)
|
||||
return aufbau()
|
||||
|
||||
|
||||
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(
|
||||
"Unverschlüsselte 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"]),
|
||||
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||
auto_referrals=False,
|
||||
)
|
||||
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("Unverschlüsselte Verzeichnisverbindung in Benutzung")
|
||||
if not config["ldap_verify_cert"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||
|
||||
begonnen = time.monotonic()
|
||||
zeiten: list[str] = []
|
||||
|
||||
def gemessen(name: str, seit: float) -> float:
|
||||
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||
jetzt = time.monotonic()
|
||||
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||
return jetzt
|
||||
|
||||
try:
|
||||
service_conn, bound = mit_wiederholung(
|
||||
lambda: _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),
|
||||
)
|
||||
|
||||
marke = gemessen("Dienstbindung", begonnen)
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
marke = gemessen("Basis-DN", marke)
|
||||
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,
|
||||
)
|
||||
marke = gemessen("Benutzersuche", marke)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
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.
|
||||
#
|
||||
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||
# den Sperrzaehler des Kontos zweimal hoch.
|
||||
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||
marke = gemessen("Passwortpruefung", marke)
|
||||
try:
|
||||
if not user_bound:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
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)
|
||||
marke = gemessen("Gruppen", marke)
|
||||
|
||||
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,
|
||||
)
|
||||
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||
# Verbindung ist, die Suche oder die Gruppen.
|
||||
logger.info(
|
||||
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||
username,
|
||||
", ".join(zeiten),
|
||||
int((time.monotonic() - begonnen) * 1000),
|
||||
)
|
||||
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("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schattenkonto
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
|
||||
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
|
||||
from ..rbac import service as rbac
|
||||
|
||||
row = rbac.find_user_by_username(conn, profile.username)
|
||||
with core().database.transaction(conn):
|
||||
if row is None:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
|
||||
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
|
||||
(
|
||||
profile.username,
|
||||
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
|
||||
profile.first_name,
|
||||
profile.last_name,
|
||||
profile.email,
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=profile.username,
|
||||
actor_id=user_id,
|
||||
action="auth.directory_account_created",
|
||||
target=profile.username,
|
||||
detail=profile.dn,
|
||||
severity="notice",
|
||||
)
|
||||
else:
|
||||
user_id = int(row["id"])
|
||||
conn.execute(
|
||||
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
|
||||
"updated_at=? WHERE id=?",
|
||||
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
|
||||
)
|
||||
|
||||
_apply_group_mappings(conn, user_id, profile.group_dns)
|
||||
|
||||
result = rbac.get_user(conn, user_id)
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
|
||||
def _apply_group_mappings(
|
||||
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
|
||||
|
||||
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
|
||||
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
|
||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||
"""
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
lowered = {dn.lower() for dn in group_dns}
|
||||
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
|
||||
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
|
||||
_kennzeichen_setzen(conn, user_id, mappings, lowered)
|
||||
if not group_dns:
|
||||
return
|
||||
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
|
||||
if not gruppenzuordnungen:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
|
||||
wanted = {
|
||||
int(row["app_group_id"])
|
||||
for row in gruppenzuordnungen
|
||||
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 _kennzeichen_setzen(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
mappings: list[dict[str, Any]],
|
||||
gruppen: set[str],
|
||||
) -> None:
|
||||
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
|
||||
|
||||
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
|
||||
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
|
||||
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
|
||||
hier nie ankommt.
|
||||
|
||||
Zwei Grenzen, beide absichtlich:
|
||||
|
||||
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
|
||||
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
|
||||
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
|
||||
* Nie den letzten entsperrten Administrator herunterstufen. Ein
|
||||
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
|
||||
verschliessen, und niemand koennte ihn korrigieren.
|
||||
"""
|
||||
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
|
||||
if not zuordnungen:
|
||||
return
|
||||
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
|
||||
zeile = Database.one(
|
||||
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
|
||||
)
|
||||
if zeile is None or str(zeile["auth_source"]) != "ldap":
|
||||
return
|
||||
ist = bool(zeile["is_admin"])
|
||||
if ist == soll:
|
||||
return
|
||||
if not soll:
|
||||
uebrig = Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
|
||||
"AND deleted_at IS NULL AND id<>?",
|
||||
(user_id,),
|
||||
default=0,
|
||||
)
|
||||
if not uebrig:
|
||||
logger.warning(
|
||||
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="auth.admin_flag_changed",
|
||||
target=str(user_id),
|
||||
detail=(
|
||||
"Administrator: "
|
||||
+ ("nein -> ja" if soll else "ja -> nein")
|
||||
+ " (aus der Verzeichniszuordnung)"
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
Loaded 100 of 341 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user